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

173 lines
7.9 KiB
JavaScript

'use strict';
/**
* A6 — the join keys, resolved into a SHADOW resolve.
*
* A5 measured all three hits factors skipped on 596/596 because nothing sets
* `prop.opponent` / `prop.opposing_pitcher`. A6 resolves them and freezes what
* the factors WOULD do. The served grade must not move.
*/
const mk = require('../../src/services/model/matchupKeys');
const ctxSvc = require('../../src/services/model/hitsFactorContext');
const hf = require('../../src/services/model/hitsFactors');
const ff = require('../../src/services/model/factorFreeze');
const { nameKey } = require('../../src/utils/playerName');
const JUDGE = nameKey('Aaron Judge');
const fakeSb = (rows) => ({
from() {
const st = { lte: null };
const q = {
select() { return q; }, eq() { return q; },
lte(c, v) { if (c === 'as_of_date') st.lte = v; return q; },
order() { return q; },
async range(f, t2) {
const r = st.lte ? rows.filter((x) => String(x.as_of_date) <= st.lte) : rows;
return { data: r.slice(f, t2 + 1), error: null };
},
};
return q;
},
});
const LINEUP = [{
as_of_date: '2026-08-09', game_date: '2026-08-09', sport: 'mlb',
game_pk: 1, team: 'New York Yankees', side: 'home', player_key: JUDGE,
}];
const SCHED = [{
home: { team: 'New York Yankees', probablePitcher: { id: 1, name: 'Gerrit Cole' } },
away: { team: 'Boston Red Sox', probablePitcher: { id: 2, name: 'Chris Sale' } },
}];
describe('key resolution', () => {
it('resolves opponent + opposing pitcher from lineup + schedule', async () => {
const r = await mk.build({ sb: fakeSb(LINEUP), getSchedule: async () => SCHED, gameDate: '2026-08-09' });
const k = r({ player: 'Aaron Judge' });
expect(k.opponent).toBe('Boston Red Sox');
expect(k.opposing_pitcher).toBe('Chris Sale');
expect(k.source).toBe('lineup_context+schedule');
expect(k.refused).toBeNull();
});
it('REFUSES a player with no posted lineup row — never guesses from the prop teams', async () => {
const r = await mk.build({ sb: fakeSb(LINEUP), getSchedule: async () => SCHED, gameDate: '2026-08-09' });
// The prop knows both teams; the resolver still refuses, because which side
// the hitter bats for is not knowable from that.
const k = r({ player: 'Nobody Atall', home_team: 'NYY', away_team: 'BOS' });
expect(k.opponent).toBeNull();
expect(k.opposing_pitcher).toBeNull();
expect(k.refused).toBe('no_lineup_row');
});
it('refuses the pitcher when the game has no probable, keeping the opponent', async () => {
const noSP = [{ home: { team: 'New York Yankees' }, away: { team: 'Boston Red Sox' } }];
const r = await mk.build({ sb: fakeSb(LINEUP), getSchedule: async () => noSP, gameDate: '2026-08-09' });
const k = r({ player: 'Aaron Judge' });
expect(k.opponent).toBe('Boston Red Sox');
expect(k.opposing_pitcher).toBeNull();
expect(k.refused).toBe('no_probable_pitcher');
});
it('bounds the lineup read by as-of (A4 discipline)', async () => {
const future = [{ ...LINEUP[0], as_of_date: '2026-08-20' }];
const r = await mk.build({ sb: fakeSb(future), getSchedule: async () => SCHED, gameDate: '2026-08-09', asOf: '2026-08-09' });
expect(r({ player: 'Aaron Judge' }).refused).toBe('no_lineup_row');
});
it('returns null (not a stub) when nothing loads', async () => {
expect(await mk.build({})).toBeNull();
});
});
describe('the resolver: LIVE unchanged, SHADOW fires', () => {
const TABLES = {
batter_spray: [{ as_of_date: '2026-08-09', sport: 'mlb', season: 2026, source_id: 1, player_key: JUDGE, pull_gb: 0.3, straight_gb: 0.2, oppo_gb: 0.1, pull_air: 0.2, straight_air: 0.1, oppo_air: 0.1 }],
team_defense: [{ as_of_date: '2026-08-09', sport: 'mlb', season: 2026, team: 'Boston Red Sox', position_oaa: { '3B': { oaa: 2 }, SS: { oaa: 1 }, LF: { oaa: 0 }, '1B': { oaa: 0 }, '2B': { oaa: 1 }, RF: { oaa: -1 }, CF: { oaa: 0 } } }],
platoon_splits: [{ as_of_date: '2026-08-09', sport: 'mlb', season: 2026, player_key: JUDGE, vl_pa: 120, vl_ab: 110, vl_hits: 33, vr_pa: 300, vr_ab: 280, vr_hits: 70 }],
statcast_aggregates: [
{ sport: 'mlb', season: 2026, source_id: 1, role: 'batter', player_key: JUDGE, bats: 'R', hard_hit_pct: 40 },
{ sport: 'mlb', season: 2026, source_id: 2, role: 'pitcher', player_key: nameKey('Chris Sale'), throws: 'L', hard_hit_pct: 45 },
],
};
const sbCtx = () => ({
from(table) {
const q = { select() { return q; }, eq() { return q; }, lte() { return q; }, order() { return q; },
async range(f, t2) { return { data: (TABLES[table] || []).slice(f, t2 + 1), error: null }; } };
return q;
},
});
const PROP = { player: 'Aaron Judge', stat_type: 'hits' };
const KEYS = { opponent: 'Boston Red Sox', opposing_pitcher: 'Chris Sale', source: 'lineup_context+schedule', refused: null };
it('WITHOUT keys nothing fires — the measured live state', async () => {
const ctx = await ctxSvc.build(sbCtx());
const c = ctx(PROP, 'mlb');
const m = hf.hitsFactorMultiplier(c);
expect(c.positionOaa).toBeNull();
expect(c.throws).toBeNull();
expect(m.factors_fired).toBe(0);
expect(m.multiplier).toBe(1);
});
it('WITH keys the factors fire — same indexes, different join', async () => {
const ctx = await ctxSvc.build(sbCtx());
const c = ctx(PROP, 'mlb', KEYS);
const m = hf.hitsFactorMultiplier(c);
expect(c.positionOaa).not.toBeNull();
expect(c.throws).toBe('L');
expect(m.factors_fired).toBeGreaterThan(0);
expect(m.multiplier).not.toBe(1);
});
it('the shadow block records the would-fire result and stays recomputable', async () => {
const ctx = await ctxSvc.build(sbCtx());
const live = ctx(PROP, 'mlb');
const shadowCtx = ctx(PROP, 'mlb', KEYS);
const shM = hf.hitsFactorMultiplier(shadowCtx);
const frozen = ff.freeze(live, PROP, ff.shadowBlock(shadowCtx, shM, KEYS));
expect(frozen.would_fire.multiplier).toBe(shM.multiplier);
expect(frozen.would_fire.factors_fired).toBe(shM.factors_fired);
expect(frozen.would_fire.keys.opponent).toBe('Boston Red Sox');
// Recomputable from what was stored beside it.
const re = hf.hitsFactorMultiplier({
spray: live.spray, bats: live.bats, platoonSplits: live.platoonSplits,
positionOaa: frozen.would_fire.shadow_inputs.position_oaa,
throws: frozen.would_fire.shadow_inputs.throws,
pitcherHardHit: frozen.would_fire.shadow_inputs.pitcher_hard_hit,
});
expect(re.multiplier).toBe(frozen.would_fire.multiplier);
});
it('the LIVE frozen inputs still show the refusal — shadow does not overwrite them', async () => {
const ctx = await ctxSvc.build(sbCtx());
const live = ctx(PROP, 'mlb');
const shadowCtx = ctx(PROP, 'mlb', KEYS);
const frozen = ff.freeze(live, PROP, ff.shadowBlock(shadowCtx, hf.hitsFactorMultiplier(shadowCtx), KEYS));
expect(frozen.position_oaa).toBeNull(); // what the SERVED grade saw
expect(frozen.throws).toBeNull();
expect(frozen.would_fire.shadow_inputs.throws).toBe('L'); // what it WOULD have seen
});
it('no shadow block when the keys were refused', async () => {
const ctx = await ctxSvc.build(sbCtx());
const live = ctx(PROP, 'mlb');
expect(ff.freeze(live, PROP, null).would_fire).toBeNull();
});
});
describe('the served grade cannot read the shadow', () => {
it('the engine assigns pOver only from the LIVE adjust, never from would_fire', () => {
const src = require('fs').readFileSync(
require('path').join(__dirname, '..', '..', 'src/services/intelligence/analyzeViaEngine1.js'), 'utf8');
const i = src.indexOf('FIX A6 — SHADOW RESOLVE');
const block = src.slice(i, i + 900);
expect(block).toMatch(/shadow = ffz\.shadowBlock/);
expect(block).not.toMatch(/pOver\s*=/);
// and the only pOver assignment in the factor area is gated on the LIVE adj
expect(src).toMatch(/if \(adj\.p_adjusted != null && adj\.factors_fired > 0\) \{\s*\n\s*pOver = adj\.p_adjusted;/);
});
});