Files
vyndr/tests/unit/factorFreeze.test.js
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

168 lines
7.2 KiB
JavaScript

'use strict';
/**
* factorFreeze — Fix A5.
*
* Freezes the RAW inputs a factor read, so a re-audit reads evidence instead of
* reconstructing context. The multiplier is deliberately NOT stored: a stored
* multiplier can only be compared to itself.
*/
const ff = require('../../src/services/model/factorFreeze');
const hf = require('../../src/services/model/hitsFactors');
const SPRAY = {
as_of_date: '2026-08-07', player_key: 'aaron judge',
pull_gb: 0.30, straight_gb: 0.20, oppo_gb: 0.10,
pull_air: 0.20, straight_air: 0.10, oppo_air: 0.10,
};
const OAA = {
'3B': { oaa: 2 }, SS: { oaa: 1 }, LF: { oaa: 0 }, '1B': { oaa: 0 },
'2B': { oaa: 1 }, RF: { oaa: -1 }, CF: { oaa: 0 },
};
const SPLITS = {
vl: { pa: 120, atBats: 110, hits: 33 },
vr: { pa: 300, atBats: 280, hits: 70 },
};
const FULL_CTX = {
spray: SPRAY, positionOaa: OAA, bats: 'R', throws: 'L',
pitcherHardHit: 0.42, platoonSplits: SPLITS, archetype: 'BOMBER', as_of: '2026-08-07',
};
const PROP = {
player: 'Aaron Judge', stat_type: 'hits', team: 'NYY',
opponent: 'Red Sox', opposing_pitcher: 'Chris Sale',
game_id: 'mlb:2026-08-07:BOS@NYY', game_date: '2026-08-07',
};
describe('INPUTS, NEVER OUTPUTS', () => {
it('stores the raw inputs', () => {
const f = ff.freeze(FULL_CTX, PROP);
expect(f.spray.pull_gb).toBe(0.30);
expect(f.position_oaa).toEqual(OAA);
expect(f.bats).toBe('R');
expect(f.throws).toBe('L');
expect(f.pitcher_hard_hit).toBe(0.42);
expect(f.platoon.vl).toEqual({ pa: 120, ab: 110, hits: 33 });
});
it('does NOT store the multiplier — it must stay recomputable, not asserted', () => {
const f = ff.freeze(FULL_CTX, PROP);
const flat = JSON.stringify(f);
expect(f.multiplier).toBeUndefined();
expect(f.factors_fired).toBeUndefined();
expect(flat).not.toMatch(/multiplier/);
});
it('carries the join keys AS THE RESOLVER SAW THEM', () => {
const f = ff.freeze(FULL_CTX, PROP);
expect(f.join).toEqual({ opponent: 'Red Sox', opposing_pitcher: 'Chris Sale' });
});
it('records the join keys as NULL when the resolver had none — the finding, not an omission', () => {
// This is the live case: nothing sets prop.opponent / prop.opposing_pitcher.
const f = ff.freeze({ spray: SPRAY, positionOaa: null, bats: 'R', throws: null, pitcherHardHit: null, platoonSplits: SPLITS }, { player: 'x', team: 'NYY', game_id: 'g1' });
expect(f.join).toEqual({ opponent: null, opposing_pitcher: null });
expect(f.position_oaa).toBeNull();
expect(f.throws).toBeNull();
expect(f.pitcher_hard_hit).toBeNull();
// and what COULD have been joined is recorded separately, never fed to a factor
expect(f.available).toEqual({ team: 'NYY', game_id: 'g1', game_date: null });
});
it('is versioned, so a shape change is distinguishable from a null', () => {
expect(ff.freeze(FULL_CTX, PROP).v).toBe(ff.FREEZE_VERSION);
});
it('returns null for no context rather than an empty husk', () => {
expect(ff.freeze(null, PROP)).toBeNull();
});
it('carries the archetype through (A4), never invents it', () => {
expect(ff.freeze(FULL_CTX, PROP).archetype).toBe('BOMBER');
expect(ff.freeze({ ...FULL_CTX, archetype: null }, PROP).archetype).toBeNull();
});
});
describe('RECOMPUTE — the reason inputs beat an output', () => {
it('a frozen vector reproduces the live multiplier EXACTLY', () => {
const live = hf.hitsFactorMultiplier(FULL_CTX);
const fromFrozen = ff.recompute(ff.freeze(FULL_CTX, PROP));
expect(fromFrozen.multiplier).toBe(live.multiplier);
expect(fromFrozen.factors_fired).toBe(live.factors_fired);
expect(fromFrozen.applied.map((a) => a.factor).sort())
.toEqual(live.applied.map((a) => a.factor).sort());
});
it('reproduces the live multiplier on the DEGRADED live shape too', () => {
const degraded = { spray: SPRAY, positionOaa: null, bats: 'R', throws: null, pitcherHardHit: null, platoonSplits: SPLITS };
const live = hf.hitsFactorMultiplier(degraded);
const fromFrozen = ff.recompute(ff.freeze(degraded, {}));
expect(fromFrozen.multiplier).toBe(live.multiplier);
expect(live.factors_fired).toBe(0); // the measured live state
expect(fromFrozen.factors_fired).toBe(0);
});
it('a frozen vector that fires would be CAUGHT if the factor code changed', () => {
// The audit property: recompute uses the CURRENT factor code against the
// FROZEN inputs, so a code change shows up as a difference.
const frozen = ff.freeze(FULL_CTX, PROP);
const fake = { hitsFactorMultiplier: () => ({ multiplier: 1.99, applied: [], skipped: [], factors_fired: 0 }) };
expect(ff.recompute(frozen, { hitsFactors: fake }).multiplier).toBe(1.99);
expect(ff.recompute(frozen).multiplier).not.toBe(1.99);
});
it('recompute(null) is null', () => {
expect(ff.recompute(null)).toBeNull();
});
});
describe('RECORDING IS NOT COMPUTING — the served grade cannot move', () => {
it('freezing does not mutate the context the factor uses', () => {
const ctx = JSON.parse(JSON.stringify(FULL_CTX));
const before = JSON.stringify(ctx);
ff.freeze(ctx, PROP);
expect(JSON.stringify(ctx)).toBe(before);
});
it('the grade path writes factor_inputs but leaves pOver alone', () => {
const src = require('fs').readFileSync(
require('path').join(__dirname, '..', '..', 'src/services/intelligence/analyzeViaEngine1.js'), 'utf8');
// Window from the A5 marker to the end of the factor block. A fixed byte
// count was brittle — A6 inserted the shadow resolve inside it and pushed
// the assignment past the window, failing a test whose subject had not moved.
const start = src.indexOf('FIX A5 — FREEZE THE INPUTS');
const block = src.slice(start, src.indexOf('const pWin = dir ===', start));
expect(block).toMatch(/legacy\.factor_inputs = /);
// The recording path must never assign the served probability.
expect(block).not.toMatch(/pOver\s*=[^=]/);
});
it('retention persists it as inputs, not as a multiplier', () => {
const src = require('fs').readFileSync(
require('path').join(__dirname, '..', '..', 'src/services/retentionService.js'), 'utf8');
expect(src).toMatch(/factor_inputs: s\.factor_inputs \|\| null/);
expect(src).not.toMatch(/factor_multiplier:/);
});
});
describe('retentionService carries the frozen vector onto the row', () => {
const ret = require('../../src/services/retentionService');
const ctx = { snapshotId: 's1', capturedAt: '2026-08-10T14:00:00Z', sport: 'mlb', gameDate: '2026-08-10' };
it('a graded hits side lands with its factor_inputs', () => {
const frozen = ff.freeze(FULL_CTX, PROP);
const rows = ret.__testing
? []
: ret.rowsFromSides({ player: 'Aaron Judge', stat_type: 'hits', line: 0.5, game_time: '2026-08-10T23:00:00Z' },
[{ direction: 'over', grade: 'B', p_win: 0.55, factor_inputs: frozen }], ctx);
expect(rows[0].factor_inputs).toEqual(frozen);
expect(rows[0].factor_inputs.multiplier).toBeUndefined();
});
it('is null when the grader had no factor context', () => {
const rows = ret.rowsFromSides({ player: 'A', stat_type: 'rbi', line: 0.5, game_time: '2026-08-10T23:00:00Z' },
[{ direction: 'over', grade: 'C', p_win: 0.5 }], ctx);
expect(rows[0].factor_inputs).toBeNull();
});
});