f61ec6b391
Seven orders of measurement-first repair. The served grade does not move. A0/A1 — the unordered page walk returned the right COUNT and the wrong ROWS: 410-617 of 2,490 duplicated with an equal number never returned, while rows.length matched the server exactly. safePaginate orders on a real unique key, verifies the tuple at runtime, and THROWS on a query error instead of treating it as end-of-data. Both hits PROVES are withdrawn: they were drawn through that reader, and defense_by_direction's distinct-n was likely below the gate floor all along. A2/A2b — rolled across every reader: 11 FAIL -> 0. Composite keys pulled from pg_index (the context tables are dated-composite and had no single unique column). The unordered helper is deleted, not parked. A3 — ledgerService and retentionService defaulted the SAME env var to DIFFERENT versions, so no ledger row ever carried the marker eligibility requires. One source now. model_snapshots settlement moved onto the cron: 15,484 -> 28,894 settled, repaired-champion 0 -> 7,556. A4 — hitsFactorContext takes an as-of cutoff. Refusal over reconstruction: no row at-or-before the date means the factor does not apply, never the nearest row. Live path unchanged, proven 400/400 on real rows. A5 — factor_inputs freezes what the factor READ, never the multiplier, so an audit can recompute and check. It also recorded the finding: the three hits factors have NEVER fired. prop.opponent and prop.opposing_pitcher are read by the resolver and written by nothing. A6/A7 — matchupKeys resolves those keys from the posted lineup plus the schedule's probable pitchers, and fires the factors into a SHADOW freeze: 248 fires on 308 props, 245 of which would move the grade. The served forecast is untouched. specs/a8-shadow-factor-gate.md pre-registers the test that decides whether they ever go live. Nothing is turned on. CALIBRATION_DEPLOYED stays []. Both verdicts stay withdrawn. 4,772 tests / 371 suites green, web build exit 0, read-integrity harness 34/34. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
165 lines
6.9 KiB
JavaScript
165 lines
6.9 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* factorFreeze — record WHAT THE FACTOR READ, at the moment it read it.
|
|
*
|
|
* Spec: specs/read-integrity-harness.md §10
|
|
*
|
|
* ── WHY ──────────────────────────────────────────────────────────────────
|
|
* A4 established that a re-audit joining today's context to a past grade is
|
|
* contamination, and fixed it with an as-of cutoff. But an as-of join still
|
|
* RECONSTRUCTS: it asks "what would the context have been" rather than "what did
|
|
* the grader actually see". Those differ whenever a context table was refreshed
|
|
* late, backfilled, or corrected. The only way a row becomes independently
|
|
* checkable is if it carries its own inputs.
|
|
*
|
|
* ── INPUTS, NEVER OUTPUTS ────────────────────────────────────────────────
|
|
* This freezes the RAW values the factor consumed — spray shares, the opposing
|
|
* team's positional OAA, the pitcher's hard-hit rate, the platoon split counts,
|
|
* handedness — and deliberately NOT the resulting multiplier. A stored
|
|
* multiplier is unfalsifiable: it can only be compared to itself. Stored inputs
|
|
* can be re-run through `hitsFactors` and the answer checked, which is what
|
|
* makes an audit an audit. `recompute()` below exists precisely so that check is
|
|
* one call.
|
|
*
|
|
* ── IT RECORDS ABSENCE AS ABSENCE ────────────────────────────────────────
|
|
* Measured on 596 real graded hits props (2026-08-10): `positionOaa`, `throws`
|
|
* and `pitcherHardHit` resolved on ZERO of them, because nothing in the pipeline
|
|
* ever sets `prop.opponent` or `prop.opposing_pitcher` — the two join keys the
|
|
* resolver needs. All three factors were skipped on 596/596 and the multiplier
|
|
* was exactly 1 every time.
|
|
*
|
|
* So today this vector mostly freezes nulls, and that is the correct behaviour:
|
|
* it is the evidence that the factors are wired but inert. `available` records
|
|
* what COULD have been joined from the row, so a later order can measure what
|
|
* plumbing those keys would change before changing it. `available` is never fed
|
|
* to a factor — reading it here would silently alter the served grade.
|
|
*/
|
|
|
|
const { knownNumber } = require('../../utils/known');
|
|
|
|
/** Version the shape, so a later change is distinguishable from a null. */
|
|
const FREEZE_VERSION = 'hits-factors@1';
|
|
|
|
const num = (v) => knownNumber(v);
|
|
|
|
/** The spray shares a defence read consumes — raw, not the multiplier. */
|
|
function sprayInputs(spray) {
|
|
if (!spray) return null;
|
|
return {
|
|
as_of: spray.as_of_date || null,
|
|
pull_gb: num(spray.pull_gb), straight_gb: num(spray.straight_gb), oppo_gb: num(spray.oppo_gb),
|
|
pull_air: num(spray.pull_air), straight_air: num(spray.straight_air), oppo_air: num(spray.oppo_air),
|
|
};
|
|
}
|
|
|
|
/** Platoon split counts — the raw PAs and hits, so severity is re-derivable. */
|
|
function platoonInputs(splits) {
|
|
if (!splits) return null;
|
|
const side = (s) => (s ? { pa: num(s.pa), ab: num(s.atBats), hits: num(s.hits) } : null);
|
|
return { vl: side(splits.vl), vr: side(splits.vr) };
|
|
}
|
|
|
|
/**
|
|
* Build the frozen vector from the SAME context object the factor was handed.
|
|
*
|
|
* @param {object} ctx the resolved factor context (exactly what hitsFactors got)
|
|
* @param {object} prop the graded prop, for the join keys as they stood
|
|
*/
|
|
/**
|
|
* FIX A6 — the SHADOW would-fire block.
|
|
*
|
|
* With the join keys resolved, the factors CAN compute. This records what they
|
|
* WOULD have applied, namespaced under `would_fire`, and it is never added to
|
|
* the served forecast. It is evidence for a later, gated decision — not a grade.
|
|
*
|
|
* `multiplier` is stored here (unlike the inputs rule) because the shadow inputs
|
|
* are stored ALONGSIDE it under `shadow_inputs`, so it stays recomputable and
|
|
* checkable. A number you cannot re-derive is the thing that rule forbids.
|
|
*/
|
|
function shadowBlock(shadowCtx, applied, keys) {
|
|
if (!shadowCtx || !applied) return null;
|
|
return {
|
|
keys: {
|
|
opponent: (keys && keys.opponent) || null,
|
|
opposing_pitcher: (keys && keys.opposing_pitcher) || null,
|
|
source: (keys && keys.source) || null,
|
|
refused: (keys && keys.refused) || null,
|
|
},
|
|
multiplier: applied.multiplier,
|
|
factors_fired: applied.factors_fired,
|
|
per_factor: applied.applied.map((a) => ({ factor: a.factor, multiplier: a.multiplier })),
|
|
skipped: applied.skipped.map((sk) => sk.factor),
|
|
shadow_inputs: {
|
|
position_oaa: shadowCtx.positionOaa || null,
|
|
throws: shadowCtx.throws || null,
|
|
pitcher_hard_hit: knownNumber(shadowCtx.pitcherHardHit),
|
|
},
|
|
};
|
|
}
|
|
|
|
function freeze(ctx, prop = {}, shadow = null) {
|
|
if (!ctx) return null;
|
|
return {
|
|
v: FREEZE_VERSION,
|
|
as_of: ctx.as_of || null,
|
|
|
|
// ── the join keys, as the RESOLVER saw them ──
|
|
// Null here is the finding, not an omission: nothing sets these, so every
|
|
// opponent-side and pitcher-side input below is null as a consequence.
|
|
join: {
|
|
opponent: prop.opponent || prop.opp_team || null,
|
|
opposing_pitcher: prop.opposing_pitcher || null,
|
|
},
|
|
|
|
// ── the raw inputs each factor consumed ──
|
|
spray: sprayInputs(ctx.spray),
|
|
position_oaa: ctx.positionOaa || null,
|
|
bats: ctx.bats || null,
|
|
throws: ctx.throws || null,
|
|
pitcher_hard_hit: num(ctx.pitcherHardHit),
|
|
platoon: platoonInputs(ctx.platoonSplits),
|
|
|
|
// Carried from the graded row, never invented (A4).
|
|
archetype: ctx.archetype || null,
|
|
|
|
// ── WHAT WAS AVAILABLE BUT NOT USED ──
|
|
// Recorded so a later order can measure the effect of plumbing the join keys
|
|
// BEFORE it changes a served number. Never read by a factor.
|
|
available: {
|
|
team: prop.team || null,
|
|
game_id: prop.game_id || null,
|
|
game_date: prop.game_date || null,
|
|
},
|
|
|
|
// SHADOW ONLY (A6). Never read by the served grade.
|
|
would_fire: shadow || null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Re-run the factors from a FROZEN vector.
|
|
*
|
|
* This is the whole point of freezing inputs rather than a multiplier: an audit
|
|
* can recompute and compare. Returns the same shape `hitsFactorMultiplier` does.
|
|
*/
|
|
function recompute(frozen, deps = {}) {
|
|
if (!frozen) return null;
|
|
const hf = deps.hitsFactors || require('./hitsFactors');
|
|
return hf.hitsFactorMultiplier({
|
|
spray: frozen.spray,
|
|
positionOaa: frozen.position_oaa,
|
|
bats: frozen.bats,
|
|
throws: frozen.throws,
|
|
pitcherHardHit: frozen.pitcher_hard_hit,
|
|
platoonSplits: frozen.platoon
|
|
? {
|
|
vl: frozen.platoon.vl ? { pa: frozen.platoon.vl.pa, atBats: frozen.platoon.vl.ab, hits: frozen.platoon.vl.hits } : null,
|
|
vr: frozen.platoon.vr ? { pa: frozen.platoon.vr.pa, atBats: frozen.platoon.vr.ab, hits: frozen.platoon.vr.hits } : null,
|
|
}
|
|
: null,
|
|
});
|
|
}
|
|
|
|
module.exports = { freeze, recompute, shadowBlock, FREEZE_VERSION, sprayInputs, platoonInputs };
|