Per-archetype grade bands: built, gated, and the rescale blocked twice
The premise does not hold. proven-status.js run fresh: PROVEN_SET is EMPTY, no archetype x stat reaches the gate. pitcher_contact_profile has a CI upper bound of exactly 0.0000 and platoon_severity is held on 4.5%-contaminated splits, so the proven set is one factor, pooled, not three archetype-conditioned ones. The specific pattern the order names -- defense strong for GHOST/BRUSH, null for BOMBER -- is the one I measured running the OTHER WAY yesterday, both noise-dominated. But the second blocker is new and matters more, because it would stop the rescale even if the factors had proved: the grade does not separate within any archetype. Every archetype collapses to ONE band at the corrected bar, because bands merge when their intervals overlap and publishing two letters we cannot tell apart is a distinction we have not measured. Uncorrected, so the ranking is visible rather than hidden by the bar, this INVERTS the order's design. The order gives contact types the factor-rich treatment and power types honest base-rate, reasoning that single-game hits are variance for a power profile. Measured: BOMBER n=466 corr(p_win,outcome) +0.207 quintiles 0.75 0.62 0.60 0.48 0.48 GHOST n=192 corr(p_win,outcome) -0.007 quintiles 0.47 0.63 0.74 0.58 0.45 BOMBER is the one archetype the model ranks, and it splits into a real A 0.660 / B 0.481 at 95%. GHOST is flat, and non-monotone -- its most confident reads hit 47% while its middle reads hit 74%. Shipping as specified would have given the factor-rich treatment to the archetype the model reads worst and left base-rate on the one it reads best. That is mechanically sensible in hindsight: a power hitter's hit tracks whether he can damage the arm, a contact hitter's depends on balls finding holes. BOMBER's split does not survive the cumulative correction at 106 tests. Exposing it by loosening the correction is the curve-to-make-A's the order forbids, so it stays one band. BUILT: gradeBands.js -- lift against the archetype's OWN base rate (the same 62% is lift for a 45% profile and a deficit for a 68% one), indistinguishable neighbours merged, thin bands PROVISIONAL not dropped, Wilson intervals widened by the cumulative correction. The two-bar rule is structural: proven-alone, calibrated-alone and neither all return base_rate with the reason stated, so with nothing proven no factor-informed band can be produced at all. reasoning() is built and tested but NOT wired to the card -- there is no per-archetype band being served, so attaching the copy now would ship product language for a rescale that does not exist. NOT BUILT: the specified power-type reason "the matchup edge is in total_bases". total_bases is recorded INCONCLUSIVE (+0.0038, CI [-0.068,+0.075]). Wiring it would assert an edge measured as indistinguishable from zero -- the exact fabricated-reason failure this module exists to prevent. BOMBER x hits is 29 rows short of the gate and is the archetype the model actually reads. That is the first slot to test, not GHOST. Counter and frozen clusters byte-identical. No letter was moved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* gradeBands — WHAT DOES A LETTER MEAN, PER ARCHETYPE, AND WHO SAYS SO?
|
||||
*
|
||||
* A grade is the product. So a letter has to be backed by a realized rate the
|
||||
* ledger can stand behind — not by a threshold someone chose. This builds bands
|
||||
* from settled outcomes, per archetype, and refuses to publish any band the
|
||||
* evidence cannot support.
|
||||
*
|
||||
* ── LIFT, NOT RAW RATE ────────────────────────────────────────────────────
|
||||
* An A must beat the archetype's OWN naive base rate. A power hitter and a
|
||||
* contact hitter have different base rates for a hit, so the same 62% realized
|
||||
* rate is a strong read for one and slightly below water for the other. Bands
|
||||
* are therefore drawn where the realized rate SEPARATES from that archetype's
|
||||
* base rate, and a band whose interval still contains the base rate has shown no
|
||||
* lift — whatever its raw number looks like.
|
||||
*
|
||||
* ── THE TWO-BAR RULE IS STRUCTURAL, NOT A HABIT ───────────────────────────
|
||||
* A band may be described as FACTOR-INFORMED only when that archetype's factors
|
||||
* are both PROVEN and CALIBRATED. There is no argument that overrides it: the
|
||||
* caller passes evidence, and without it every band comes back `basis:
|
||||
* 'base_rate'` and says so. A test asserts that with nothing proven — which is
|
||||
* the state today — no factor-informed band can be produced at all.
|
||||
*
|
||||
* This is the same shape as featureRegistry.liveFeatures(): the honest state is
|
||||
* the DEFAULT, and the richer claim has to be earned past a gate, so it cannot
|
||||
* be reached by forgetting.
|
||||
*
|
||||
* ── WHY A BAND CAN BE REFUSED ─────────────────────────────────────────────
|
||||
* Thin bands are labelled PROVISIONAL rather than dropped, because "we are still
|
||||
* counting" and "there is nothing here" are different claims. But a band whose
|
||||
* corrected interval spans the base rate does NOT get a lift letter — it is
|
||||
* reported as the base-rate read it is.
|
||||
*/
|
||||
|
||||
const { knownNumber } = require('../../utils/known');
|
||||
|
||||
/** Below this a band is published but flagged — counting, not concluded. */
|
||||
const PROVISIONAL_N = 30;
|
||||
/** Below this a band is not published at all; there is nothing to stand behind. */
|
||||
const MIN_BAND_N = 12;
|
||||
/** Letters, richest first. Assigned by measured lift, never by curve. */
|
||||
const LETTERS = ['A', 'B', 'C', 'D', 'F'];
|
||||
|
||||
/**
|
||||
* Wilson score interval, widened by the cumulative correction.
|
||||
*
|
||||
* Wilson rather than normal-approximation because these bands are small and
|
||||
* rates sit near the edges, where the normal interval runs past 0 and 1 and
|
||||
* quietly implies impossible rates.
|
||||
*/
|
||||
function wilson(hits, n, cumulativeTests = 1) {
|
||||
const k = knownNumber(hits); const N = knownNumber(n);
|
||||
if (k === null || N === null || N <= 0) return null;
|
||||
const tests = Math.max(1, Math.round(knownNumber(cumulativeTests) ?? 1));
|
||||
const z = zFor(1 - (0.05 / tests) / 2);
|
||||
const p = k / N;
|
||||
const d = 1 + (z * z) / N;
|
||||
const centre = (p + (z * z) / (2 * N)) / d;
|
||||
const half = (z * Math.sqrt((p * (1 - p)) / N + (z * z) / (4 * N * N))) / d;
|
||||
return [round4(Math.max(0, centre - half)), round4(Math.min(1, centre + half))];
|
||||
}
|
||||
|
||||
/** Inverse normal CDF (Acklam) — good to ~1e-9, enough for an interval bound. */
|
||||
function zFor(p) {
|
||||
if (p <= 0 || p >= 1) return 0;
|
||||
const a = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02,
|
||||
1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00];
|
||||
const b = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02,
|
||||
6.680131188771972e+01, -1.328068155288572e+01];
|
||||
const c = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00,
|
||||
-2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00];
|
||||
const d = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00,
|
||||
3.754408661907416e+00];
|
||||
const pl = 0.02425;
|
||||
let q; let r;
|
||||
if (p < pl) {
|
||||
q = Math.sqrt(-2 * Math.log(p));
|
||||
return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5])
|
||||
/ ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
|
||||
}
|
||||
if (p > 1 - pl) {
|
||||
q = Math.sqrt(-2 * Math.log(1 - p));
|
||||
return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5])
|
||||
/ ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
|
||||
}
|
||||
q = p - 0.5; r = q * q;
|
||||
return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q
|
||||
/ (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build bands for ONE archetype.
|
||||
*
|
||||
* @param {Array} rows [{ p, won }] — p is the model's probability, won 0/1
|
||||
* @param {object} opts
|
||||
* opts.archetype label
|
||||
* opts.cumulativeTests Bonferroni denominator (applied to every interval)
|
||||
* opts.proven this archetype's factors passed the gate
|
||||
* opts.calibrated this archetype's probabilities are certified calibrated
|
||||
* opts.targetBands how many bands to attempt (default 5)
|
||||
*/
|
||||
function buildBands(rows, opts = {}) {
|
||||
const usable = (rows || []).filter((r) =>
|
||||
knownNumber(r && r.p) !== null && knownNumber(r && r.won) !== null);
|
||||
const n = usable.length;
|
||||
const tests = opts.cumulativeTests ?? 1;
|
||||
|
||||
// THE TWO-BAR RULE. Both, or the bands are a base-rate read and say so.
|
||||
const factorInformed = Boolean(opts.proven) && Boolean(opts.calibrated);
|
||||
|
||||
if (n < MIN_BAND_N) {
|
||||
return {
|
||||
archetype: opts.archetype || null,
|
||||
basis: factorInformed ? 'factor_informed' : 'base_rate',
|
||||
n,
|
||||
base_rate: null,
|
||||
bands: [],
|
||||
refused: 'insufficient settled outcomes to stand behind any band',
|
||||
};
|
||||
}
|
||||
|
||||
const wins = usable.reduce((s, r) => s + (r.won > 0 ? 1 : 0), 0);
|
||||
const baseRate = wins / n;
|
||||
|
||||
// Candidate cuts by quantile of the model's own probability, then merged so
|
||||
// every published band is distinguishable from its neighbour.
|
||||
const sorted = [...usable].sort((a, b) => b.p - a.p);
|
||||
const target = Math.max(2, Math.min(LETTERS.length, opts.targetBands || 5));
|
||||
const per = Math.max(MIN_BAND_N, Math.floor(n / target));
|
||||
|
||||
let raw = [];
|
||||
for (let i = 0; i < sorted.length; i += per) {
|
||||
const slice = sorted.slice(i, i + per);
|
||||
// A trailing remainder too small to stand alone joins the previous band
|
||||
// rather than being published as its own thin claim.
|
||||
if (slice.length < MIN_BAND_N && raw.length) raw[raw.length - 1].push(...slice);
|
||||
else raw.push(slice);
|
||||
}
|
||||
|
||||
// Merge neighbours whose corrected intervals overlap — if we cannot tell two
|
||||
// bands apart, publishing them as different letters is a distinction we have
|
||||
// not measured.
|
||||
let merged = true;
|
||||
while (merged && raw.length > 1) {
|
||||
merged = false;
|
||||
for (let i = 0; i < raw.length - 1; i += 1) {
|
||||
const a = stats(raw[i], tests); const b = stats(raw[i + 1], tests);
|
||||
if (a.ci && b.ci && a.ci[0] <= b.ci[1] && b.ci[0] <= a.ci[1]) {
|
||||
raw.splice(i, 2, raw[i].concat(raw[i + 1]));
|
||||
merged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bands = raw.map((slice, i) => {
|
||||
const s = stats(slice, tests);
|
||||
// LIFT: does this band's interval clear the archetype's own base rate?
|
||||
const clearsAbove = s.ci !== null && s.ci[0] > baseRate;
|
||||
const clearsBelow = s.ci !== null && s.ci[1] < baseRate;
|
||||
return {
|
||||
letter: LETTERS[Math.min(i, LETTERS.length - 1)],
|
||||
n: slice.length,
|
||||
p_range: [round4(Math.min(...slice.map((r) => r.p))), round4(Math.max(...slice.map((r) => r.p)))],
|
||||
realized_rate: s.rate,
|
||||
ci: s.ci,
|
||||
lift_vs_archetype_base: round4(s.rate - baseRate),
|
||||
// The claim the letter is allowed to make.
|
||||
shows_lift: clearsAbove,
|
||||
shows_deficit: clearsBelow,
|
||||
separation: clearsAbove ? 'above_base_rate' : (clearsBelow ? 'below_base_rate' : 'indistinguishable_from_base_rate'),
|
||||
provisional: slice.length < PROVISIONAL_N,
|
||||
basis: factorInformed ? 'factor_informed' : 'base_rate',
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
archetype: opts.archetype || null,
|
||||
basis: factorInformed ? 'factor_informed' : 'base_rate',
|
||||
// Stated explicitly so a consumer never has to infer why it is a base-rate read.
|
||||
two_bar: {
|
||||
proven: Boolean(opts.proven),
|
||||
calibrated: Boolean(opts.calibrated),
|
||||
factor_informed_allowed: factorInformed,
|
||||
...(factorInformed ? {} : {
|
||||
reason: !opts.proven
|
||||
? 'no factor has passed the gate for this archetype'
|
||||
: 'factors proved but this archetype\'s probabilities are not certified calibrated',
|
||||
}),
|
||||
},
|
||||
n,
|
||||
base_rate: round4(baseRate),
|
||||
bands,
|
||||
bonferroni_tests: Math.max(1, Math.round(knownNumber(tests) ?? 1)),
|
||||
bands_showing_lift: bands.filter((b) => b.shows_lift).length,
|
||||
};
|
||||
}
|
||||
|
||||
function stats(slice, tests) {
|
||||
const n = slice.length;
|
||||
const w = slice.reduce((s, r) => s + (r.won > 0 ? 1 : 0), 0);
|
||||
return { rate: round4(n ? w / n : null), ci: wilson(w, n, tests) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The sentence attached to a grade. TRUE or absent — never a fluent fallback.
|
||||
*
|
||||
* A factor-informed reason names the factors that proved FOR THIS ARCHETYPE. A
|
||||
* base-rate reason says plainly that it is a base-rate read, because a user who
|
||||
* is told "favourable matchup" when nothing about the matchup was read has been
|
||||
* given a fabricated reason, and that is worse than being given none.
|
||||
*/
|
||||
function reasoning(band, opts = {}) {
|
||||
if (!band) return null;
|
||||
const arch = opts.archetype ? String(opts.archetype).toUpperCase() : null;
|
||||
if (band.basis === 'factor_informed') {
|
||||
const proved = (opts.provenFactors || []).filter(Boolean);
|
||||
if (!proved.length) return null; // cannot name what did not prove
|
||||
return `${arch ? `${arch}: ` : ''}${proved.join(' + ')} — proved for this profile`;
|
||||
}
|
||||
const sep = band.separation === 'above_base_rate'
|
||||
? 'above this profile\'s base rate'
|
||||
: (band.separation === 'below_base_rate' ? 'below this profile\'s base rate' : 'at this profile\'s base rate');
|
||||
return `${arch ? `${arch}: ` : ''}base-rate read, ${sep}`
|
||||
+ ' — no matchup factor is proven for this profile yet';
|
||||
}
|
||||
|
||||
const round4 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 10000) / 10000);
|
||||
|
||||
module.exports = {
|
||||
buildBands, reasoning, wilson,
|
||||
PROVISIONAL_N, MIN_BAND_N, LETTERS,
|
||||
};
|
||||
Reference in New Issue
Block a user