Files
vyndr/src/services/model/factorFreeze.js
T
builtbykev f61ec6b391 Read integrity, as-of context, and the shadow matchup resolve (A1-A7)
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>
2026-08-11 22:49:56 -04:00

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 };