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>
This commit is contained in:
Kev
2026-08-11 22:49:56 -04:00
parent 387ae4d54e
commit f61ec6b391
49 changed files with 4874 additions and 308 deletions
+191
View File
@@ -0,0 +1,191 @@
'use strict';
/**
* hitsFactorContext as-of — Fix A4.
*
* The contamination this locks out: without a cutoff, `latestBy` returns TODAY'S
* dated row, so auditing a 2026-08-07 grade would join a profile built from
* games played after it. A clean read of the wrong date is still contamination.
*
* Fake client throughout; nothing here touches a network.
*/
const ctxSvc = require('../../src/services/model/hitsFactorContext');
const hf = require('../../src/services/model/hitsFactors');
const { nameKey } = require('../../src/utils/playerName');
// Derived, never hardcoded: nameKey resolves nicknames ('Chris' -> 'christopher'),
// so a literal key would silently miss and look like an honest absence.
const BATTER = nameKey('Aaron Judge');
const PITCHER = nameKey('Chris Sale');
/** Records every filter so an unapplied as-of bound fails the test. */
function fakeSb(tables) {
const calls = [];
return {
calls,
from(table) {
const state = { table, lte: null, eq: {} };
calls.push(state);
const q = {
select() { return q; },
eq(c, v) { state.eq[c] = v; return q; },
lte(c, v) { if (c === 'as_of_date') state.lte = v; return q; },
order() { return q; },
async range(from, to) {
let rows = tables[table] || [];
if (state.lte) rows = rows.filter((r) => String(r.as_of_date) <= state.lte);
return { data: rows.slice(from, to + 1), error: null };
},
};
return q;
},
};
}
const spray = (d, key, id) => ({
as_of_date: d, sport: 'mlb', season: 2026, source_id: id, player_key: key,
pull_gb: 0.3, straight_gb: 0.2, oppo_gb: 0.1, pull_air: 0.2, straight_air: 0.1, oppo_air: 0.1,
});
// positionOaa is { position: { oaa, fielders } } — not a bare number.
const defense = (d, team) => ({
as_of_date: d, sport: 'mlb', season: 2026, team,
position_oaa: {
'3B': { oaa: 2 }, SS: { oaa: 1 }, LF: { oaa: 0 }, '1B': { oaa: 0 },
'2B': { oaa: 1 }, RF: { oaa: -1 }, CF: { oaa: 0 },
},
});
const platoon = (d, key) => ({
as_of_date: d, sport: 'mlb', season: 2026, player_key: key,
vl_pa: 120, vl_ab: 110, vl_hits: 33, vr_pa: 300, vr_ab: 280, vr_hits: 70,
});
const sc = (d, key, id, role, extra = {}) => ({
as_of_date: d, sport: 'mlb', season: 2026, source_id: id, role, player_key: key,
bats: 'R', throws: 'L', hard_hit_pct: 40, ...extra,
});
const TABLES = {
batter_spray: [spray('2026-08-05', BATTER, 1), spray('2026-08-09', BATTER, 1)],
team_defense: [defense('2026-08-05', 'Red Sox'), defense('2026-08-09', 'Red Sox')],
platoon_splits: [platoon('2026-08-05', BATTER), platoon('2026-08-09', BATTER)],
statcast_aggregates: [sc(null, BATTER, 1, 'batter'), sc(null, PITCHER, 2, 'pitcher', { hard_hit_pct: 30 })],
statcast_history: [
sc('2026-08-05', BATTER, 1, 'batter'), sc('2026-08-09', BATTER, 1, 'batter'),
sc('2026-08-05', PITCHER, 2, 'pitcher', { hard_hit_pct: 30 }),
sc('2026-08-09', PITCHER, 2, 'pitcher', { hard_hit_pct: 99 }), // today's value
],
};
const PROP = { player: 'Aaron Judge', opponent: 'Red Sox', opposing_pitcher: 'Chris Sale' };
describe('as-of bound is APPLIED, and only when asked', () => {
it('LIVE (no asOf) applies no as_of filter and reads statcast_aggregates', async () => {
const sb = fakeSb(TABLES);
const r = await ctxSvc.build(sb);
expect(r.asOf).toBeNull();
expect(r.__stats.mode).toBe('live');
expect(r.__stats.statcast_source).toBe('statcast_aggregates');
expect(sb.calls.every((c) => c.lte === null)).toBe(true);
expect(sb.calls.map((c) => c.table)).toContain('statcast_aggregates');
expect(sb.calls.map((c) => c.table)).not.toContain('statcast_history');
});
it('AS-OF applies lte on EVERY dated table and reads statcast_history', async () => {
const sb = fakeSb(TABLES);
const r = await ctxSvc.build(sb, { asOf: '2026-08-07' });
expect(r.__stats.mode).toBe('as-of (audit)');
expect(r.__stats.statcast_source).toBe('statcast_history');
for (const t of ['batter_spray', 'team_defense', 'platoon_splits', 'statcast_history']) {
const c = sb.calls.find((x) => x.table === t);
expect(c.lte).toBe('2026-08-07');
}
});
});
describe('THE CONTAMINATION, LOCKED OUT', () => {
it('never returns a row dated after asOf', async () => {
const r = await ctxSvc.build(fakeSb(TABLES), { asOf: '2026-08-07' });
const c = r(PROP);
expect(c.spray.as_of_date).toBe('2026-08-05'); // NOT the 08-09 row
expect(c.as_of).toBe('2026-08-07');
});
it("picks up TODAY's value on the live path — proving the test can tell them apart", async () => {
const live = await ctxSvc.build(fakeSb(TABLES));
const past = await ctxSvc.build(fakeSb(TABLES), { asOf: '2026-08-07' });
// The pitcher's hard-hit rate moved 30 -> 99 on 08-09.
expect(live(PROP).pitcherHardHit).toBeCloseTo(0.30, 5);
expect(past(PROP).pitcherHardHit).toBeCloseTo(0.30, 5);
const today = await ctxSvc.build(fakeSb(TABLES), { asOf: '2026-08-09' });
expect(today(PROP).pitcherHardHit).toBeCloseTo(0.99, 5);
});
it('takes the LATEST row within the bound, not the earliest', async () => {
const tables = { ...TABLES, batter_spray: [spray('2026-08-01', BATTER, 1), spray('2026-08-05', BATTER, 1), spray('2026-08-09', BATTER, 1)] };
const r = await ctxSvc.build(fakeSb(tables), { asOf: '2026-08-07' });
expect(r(PROP).spray.as_of_date).toBe('2026-08-05');
});
});
describe('REFUSAL OVER RECONSTRUCTION', () => {
it('an entity with NO row at-or-before asOf yields null — never the nearest row', async () => {
// Every dated row is AFTER the cutoff. The honest answer is "no context".
const r = await ctxSvc.build(fakeSb(TABLES), { asOf: '2026-07-01' });
expect(r).toBeNull(); // nothing loaded at all → serve unadjusted
});
it('refuses ONE entity while still serving another', async () => {
const tables = {
...TABLES,
batter_spray: [spray('2026-08-09', BATTER, 1)], // only AFTER the cutoff
platoon_splits: [platoon('2026-08-05', BATTER)], // present at the cutoff
};
const r = await ctxSvc.build(fakeSb(tables), { asOf: '2026-08-07' });
const c = r(PROP);
expect(c.spray).toBeNull(); // refused
expect(c.platoonSplits).not.toBeNull();
});
it('a refused input leaves the BASE RATE UNTOUCHED (factor does not apply)', () => {
// The end-to-end consequence: no spray → no defense_by_direction multiplier.
const withSpray = hf.hitsFactorMultiplier({
spray: TABLES.batter_spray[0], positionOaa: TABLES.team_defense[0].position_oaa, bats: 'R',
});
const refused = hf.hitsFactorMultiplier({
spray: null, positionOaa: TABLES.team_defense[0].position_oaa, bats: 'R',
});
expect(refused.applied.map((a) => a.factor)).not.toContain('defense_by_direction');
expect(refused.skipped.map((s) => s.factor)).toContain('defense_by_direction');
// and with nothing else readable the multiplier is exactly 1 — a no-op.
expect(refused.multiplier).toBe(1);
expect(withSpray.factors_fired).toBeGreaterThan(refused.factors_fired);
});
it('adjustProbability is unchanged when every input is refused', () => {
const out = hf.adjustProbability(0.42, { spray: null, positionOaa: null, bats: null });
expect(out.p_adjusted).toBe(0.42);
});
});
describe('archetype is CARRIED, never invented', () => {
it('passes the graded row archetype through', async () => {
const r = await ctxSvc.build(fakeSb(TABLES), { asOf: '2026-08-07' });
expect(r({ ...PROP, archetype: 'BOMBER' }).archetype).toBe('BOMBER');
});
it('is null when the caller supplies none — no context table holds it', async () => {
const r = await ctxSvc.build(fakeSb(TABLES), { asOf: '2026-08-07' });
expect(r(PROP).archetype).toBeNull();
});
});
describe('the context read stays on safePaginate with the real composite key', () => {
it('orders by the full unique tuple of each table', () => {
const src = require('fs').readFileSync(
require('path').join(__dirname, '..', '..', 'src/services/model/hitsFactorContext.js'), 'utf8');
expect(src).toMatch(/uniqueKeyFor\(table\)/);
expect(src).toMatch(/require\('\.\.\/\.\.\/utils\/safePaginate'\)/);
// No hand-rolled walk left behind.
expect(src).not.toMatch(/for \(let from = 0/);
});
});