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>
168 lines
7.2 KiB
JavaScript
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();
|
|
});
|
|
});
|