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:
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user