352016790a
Release-isolated slice built from 41ba38e. Ships ONLY the event-integrity +
publication + lineage-canary closure; the 90-path development tree stays
undeployed.
- canonical MLB event identity from statsapi gamePk (mlb:gamepk:<pk>), with
event_identity_source/method/version recorded. The id is canonical; the
binding is derived and says so.
- IMPOSSIBLE-BINDING REFUSAL. Verified in prod 2026-08-26: Joe Mack (Marlins)
was bound to Dodgers@Braves and Yandy Diaz (Rays) to Rangers@WhiteSox, both
from one book in the 01:00/03:01 UTC cycles after their own games began. Root
cause is source market data, not the binder. A prop whose player's team is not
an event participant now refuses; unknown team preserves uncertainty.
- event-aware dedupe: books still collapse, events no longer do. An unresolved
MLB event fractures rather than falling back to the collision-prone
date+teams key.
- publication commit moved AFTER the authoritative Redis slate write, with
exact parity-gap identity when the product publishes and the record does not.
- lineage dual-write behind LINEAGE_CANARY_SPORTS, DISABLED for this deploy.
Excluded deliberately: WNBA feed/chain, market ontology, PerformanceDistribution,
calibration certification, truth diagnostics, applyRevision Phase-1, and the
analyzeViaEngine1 confidence-rounding change (a served field).
Suite 380/5,040/0 from this worktree; web tsc exit 0; champion output identical
to production.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
488 lines
21 KiB
JavaScript
488 lines
21 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* CHAIN v1 — does the shadow accrue on the SCHEDULED path, and does it leak?
|
|
*
|
|
* Two questions, and the second one is the gate.
|
|
*
|
|
* A5's whole finding was a factor that was built, correct, and never invoked.
|
|
* A6's answer was to drive the real path rather than read the code. Same here:
|
|
* this runs the REAL `snapshotService.runSnapshot` with injected deps and checks
|
|
* that a `chain_shadow` block lands on a retention row — because evidence that
|
|
* is generated and never persisted is that same failure one layer along.
|
|
*
|
|
* Then it proves the thing that actually matters: the served `p_win` is
|
|
* BYTE-IDENTICAL with the shadow on and off. A shadow that moves a served number
|
|
* is not a shadow, and no amount of labelling fixes it.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const svc = require('../../src/services/snapshotService');
|
|
const retention = require('../../src/services/retentionService');
|
|
const cs = require('../../src/services/model/chainShadow');
|
|
const { nameKey } = require('../../src/utils/playerName');
|
|
|
|
const ROOT = path.join(__dirname, '..', '..');
|
|
|
|
/** A chainable Supabase stub — the context builders are gated on a client. */
|
|
function fakeSbTop() {
|
|
const q = {};
|
|
for (const m of ['select', 'eq', 'lte', 'gte', 'in', 'order', 'not', 'is']) q[m] = () => q;
|
|
q.range = async () => ({ data: [], error: null });
|
|
return { from: () => q };
|
|
}
|
|
|
|
function memCache() {
|
|
const store = {};
|
|
return {
|
|
store,
|
|
cacheGet: async (k) => (k in store ? store[k] : null),
|
|
cacheSet: async (k, v) => { store[k] = v; },
|
|
};
|
|
}
|
|
|
|
const PROPS = [
|
|
{ player: 'Mookie Betts', stat_type: 'hits', line: 0.5, over_odds: -130, under_odds: 100, book: 'dk' },
|
|
{ player: 'Aaron Judge', stat_type: 'hits', line: 0.5, over_odds: -140, under_odds: 110, book: 'dk' },
|
|
];
|
|
|
|
/** Served grades, with the counter's p_win — the number that must not move. */
|
|
const GRADES = [
|
|
{
|
|
player: 'Mookie Betts', stat_type: 'hits', line: 0.5, direction: 'over',
|
|
grade: 'B', confidence: 61, p_win: 0.61, game_id: 'mlb:2026-08-12:LAD@SFG',
|
|
},
|
|
{
|
|
player: 'Aaron Judge', stat_type: 'hits', line: 0.5, direction: 'under',
|
|
grade: 'C', confidence: 42, p_win: 0.42, game_id: 'mlb:2026-08-12:NYY@BOS',
|
|
},
|
|
];
|
|
|
|
const statcastRow = (key, over = {}) => ({
|
|
player_key: key, role: 'batter',
|
|
k_pct: 20.0, bb_pct: 9.0, barrel_pct: 10.0, hard_hit_pct: 43.0,
|
|
avg_exit_velo: 90.5, avg_launch_angle: 13.0, bats: 'R', sample_pa: 420,
|
|
...over,
|
|
});
|
|
|
|
const STATCAST = new Map([
|
|
[nameKey('Mookie Betts'), statcastRow(nameKey('Mookie Betts'))],
|
|
[nameKey('Aaron Judge'), statcastRow(nameKey('Aaron Judge'), { barrel_pct: 16.0 })],
|
|
]);
|
|
|
|
/** Retention stub that captures rows instead of writing them. */
|
|
function captureRetention(sink) {
|
|
return {
|
|
...retention,
|
|
persist: async (rows) => { sink.push(...rows); return { attempted: rows.length, written: rows.length }; },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The challenger pass fetches park/weather and probable pitchers over the
|
|
* network. Stubbed to no-ops so this suite is hermetic — the shadow reuses the
|
|
* SAME statcast handle either way, which is the coupling under test.
|
|
*/
|
|
const inertChallengerDeps = () => ({
|
|
notify: async () => {},
|
|
gameBinder: { attachGameTimes: async () => ({ bound: 0, alreadyHad: 2, unresolved: 0, ambiguous: 0 }) },
|
|
environmentContext: {
|
|
buildContext: async () => ({
|
|
_internals: { oppPitcherByTeam: new Map() },
|
|
contextFor: () => null,
|
|
stats: {},
|
|
}),
|
|
},
|
|
challenger: { attachChallenger: async (g) => g },
|
|
archetypeAxes: { classifyPlayer: () => null },
|
|
contactChallenger: { buildRefs: () => ({}), attachContactChallenger: async (g) => g },
|
|
projectionChallenger: { attachProjection: async (g) => g },
|
|
loadArsenals: async () => new Map(),
|
|
mlbAdapter: { getPlayerGameLog: async () => [] },
|
|
lineupContext: { refreshContext: async () => ({ lineups: 0, opportunity: 0 }) },
|
|
ledger: { recordPipelineGrades: async () => ({ written: 0 }), captureClosing: async () => {} },
|
|
buildEspnIndex: async () => ({}),
|
|
captureBookPrices: () => ({}),
|
|
lockLineCapture: { buildLockRows: () => [], persist: async () => ({ retired: true, attempted: 0 }) },
|
|
});
|
|
|
|
function deps(cache, sink, over = {}) {
|
|
return {
|
|
...inertChallengerDeps(),
|
|
getOdds: async () => ({ sport: 'mlb', props: PROPS, provider: 'propline' }),
|
|
gradeAndCacheSlate: async (_sport, props, opts) => {
|
|
// The REAL collector path: rowsFromSides is what writes model_snapshots.
|
|
for (const base of props) {
|
|
const sides = GRADES.filter((g) => g.player === base.player)
|
|
.map((g) => ({ ...g, stat_type: g.stat_type, line: g.line }));
|
|
if (opts.onGraded) opts.onGraded({ ...base, game_id: sides[0] && sides[0].game_id }, sides);
|
|
}
|
|
await opts.cacheSet('grades:x', { grades: GRADES, updated_at: opts.now(), source: 'test' });
|
|
},
|
|
resolveStats: async () => ({ found: true, classifierInput: { hr: 30, avg: 0.29, ops: 0.93, k_rate: 20 } }),
|
|
classify: require('../../src/services/archetypeService').classify,
|
|
retention: captureRetention(sink),
|
|
loadStatcast: async () => STATCAST,
|
|
cacheGet: cache.cacheGet,
|
|
cacheSet: cache.cacheSet,
|
|
now: () => '2026-08-12T18:00:00Z',
|
|
nowMs: () => 1000,
|
|
...over,
|
|
};
|
|
}
|
|
|
|
describe('the chain shadow accrues on the REAL scheduled path', () => {
|
|
it('a graded hits row lands with chain_shadow on it', async () => {
|
|
const sink = [];
|
|
await svc.runSnapshot('mlb', deps(memCache(), sink));
|
|
const row = sink.find((r) => r.chain_shadow);
|
|
expect(row).toBeDefined();
|
|
expect(row.chain_shadow.chain_p).toBeGreaterThan(0);
|
|
expect(row.chain_shadow.chain_p).toBeLessThan(1);
|
|
});
|
|
|
|
it('THE TRIPLE: chain_p and counter_p on the same row, both side-aligned', async () => {
|
|
const sink = [];
|
|
await svc.runSnapshot('mlb', deps(memCache(), sink));
|
|
for (const r of sink.filter((x) => x.chain_shadow)) {
|
|
expect(r.chain_shadow.side).toBe(r.side);
|
|
expect(r.chain_shadow.counter_p).toBe(r.p_win);
|
|
expect(r.chain_shadow.divergence)
|
|
.toBeCloseTo(r.chain_shadow.chain_p - r.chain_shadow.counter_p, 4);
|
|
}
|
|
// `outcome` is the third leg and is written by the ordinary settle pass —
|
|
// it is null here because these games have not been played.
|
|
expect(sink.every((r) => r.outcome === undefined || r.outcome === null)).toBe(true);
|
|
});
|
|
|
|
it('an UNDER row carries 1 - p_over, so no later comparison inverts', async () => {
|
|
const sink = [];
|
|
await svc.runSnapshot('mlb', deps(memCache(), sink));
|
|
const pair = sink.filter((r) => r.player_key === nameKey('Aaron Judge') && r.chain_shadow);
|
|
const over = pair.find((r) => r.side === 'over');
|
|
const under = pair.find((r) => r.side === 'under');
|
|
if (over && under) expect(over.chain_shadow.chain_p + under.chain_shadow.chain_p).toBeCloseTo(1, 4);
|
|
});
|
|
|
|
it('every stored block declares itself UN-SERVABLE', async () => {
|
|
const sink = [];
|
|
await svc.runSnapshot('mlb', deps(memCache(), sink));
|
|
for (const r of sink.filter((x) => x.chain_shadow)) {
|
|
expect(r.chain_shadow.servable).toBe(false);
|
|
expect(r.chain_shadow.status).toBe('UN-SERVABLE');
|
|
}
|
|
});
|
|
|
|
it('the run reports its own counts, so a shadow reading NOTHING is visible', async () => {
|
|
const r = await svc.runSnapshot('mlb', deps(memCache(), []));
|
|
expect(r.chainShadow).toBeTruthy();
|
|
expect(r.chainShadow.atoms).toBeGreaterThan(0);
|
|
expect(r.chainShadow.servable).toBe(false);
|
|
});
|
|
|
|
it('no statcast profiles => no shadow, and the snapshot still succeeds', async () => {
|
|
const sink = [];
|
|
const r = await svc.runSnapshot('mlb', deps(memCache(), sink, { loadStatcast: async () => new Map() }));
|
|
expect(r.status).toBe('ok');
|
|
expect(sink.every((x) => !x.chain_shadow)).toBe(true);
|
|
});
|
|
|
|
it('a THROWING shadow never breaks the snapshot', async () => {
|
|
const sink = [];
|
|
const r = await svc.runSnapshot('mlb', deps(memCache(), sink, {
|
|
chainShadow: { runShadow: () => { throw new Error('boom'); } },
|
|
}));
|
|
expect(r.status).toBe('ok');
|
|
expect(r.gradeCount).toBe(2);
|
|
expect(r.chainShadow).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('CHAIN v2 — the hand split reaches the shadow through the REAL path', () => {
|
|
const SPLIT = { vl: { pa: 220, atBats: 200, hits: 57 }, vr: { pa: 480, atBats: 430, hits: 95 } };
|
|
|
|
/**
|
|
* The A4 context resolver and the A6 key resolver, in miniature. The shadow
|
|
* composes them: keys give the opposing starter's hand, the context gives the
|
|
* hitter's own split. WITHOUT the keys the resolver's `throws` is null — the
|
|
* A5 finding — and the split silently never fires, which is the exact failure
|
|
* this order exists inside.
|
|
*/
|
|
const factorContext = (prop, sport, keys) => ({
|
|
spray: null, positionOaa: null, bats: 'R',
|
|
throws: keys && keys.opposing_pitcher ? 'L' : null,
|
|
pitcherHardHit: null, platoonSplits: SPLIT, as_of: '2026-08-12',
|
|
});
|
|
const KEYS = { opponent: 'Boston Red Sox', opposing_pitcher: 'Chris Sale', source: 'lineup_context+schedule', refused: null };
|
|
|
|
/** A chainable Supabase stub — the context builders are gated on a client. */
|
|
const fakeSb = () => {
|
|
const q = {};
|
|
for (const m of ['select', 'eq', 'lte', 'gte', 'in', 'order', 'not', 'is']) q[m] = () => q;
|
|
q.range = async () => ({ data: [], error: null });
|
|
q.then = undefined;
|
|
return { from: () => q };
|
|
};
|
|
|
|
it('FIRES when both the context and the join keys are present', async () => {
|
|
const sink = [];
|
|
await svc.runSnapshot('mlb', deps(memCache(), sink, {
|
|
supabase: fakeSb(),
|
|
hitsFactorContext: { build: async () => factorContext },
|
|
matchupKeys: { build: async () => Object.assign(() => KEYS, { stats: {} }) },
|
|
}));
|
|
const row = sink.find((r) => r.chain_shadow);
|
|
expect(row).toBeDefined();
|
|
expect(row.chain_shadow.platoon_applied).toBe(true);
|
|
expect(row.chain_shadow.platoon_multiplier).toBeGreaterThan(0);
|
|
expect(row.chain_shadow.platoon_reason).toBeNull();
|
|
});
|
|
|
|
it('WITHOUT the join keys it falls to a SEASON read — and says which', async () => {
|
|
// This is the A5 shape reproduced deliberately: the context loads, the
|
|
// hitter's own split is right there, and the pitcher hand is missing, so
|
|
// the matchup conditioner cannot run. It must be visible, not silent.
|
|
const sink = [];
|
|
await svc.runSnapshot('mlb', deps(memCache(), sink, {
|
|
supabase: fakeSb(),
|
|
hitsFactorContext: { build: async () => factorContext },
|
|
matchupKeys: { build: async () => null },
|
|
}));
|
|
const row = sink.find((r) => r.chain_shadow);
|
|
expect(row.chain_shadow.platoon_applied).toBe(false);
|
|
expect(row.chain_shadow.platoon_reason).toBe('no_pitcher_hand');
|
|
});
|
|
|
|
it('the run reports the matchup rate SEPARATELY from the fire rate', async () => {
|
|
const r = await svc.runSnapshot('mlb', deps(memCache(), [], {
|
|
supabase: fakeSb(),
|
|
hitsFactorContext: { build: async () => factorContext },
|
|
matchupKeys: { build: async () => Object.assign(() => KEYS, { stats: {} }) },
|
|
}));
|
|
// Two different questions: did the chain produce a probability at all, and
|
|
// did it read tonight's matchup or this season's average?
|
|
expect(r.chainShadow.readable).toBeGreaterThan(0);
|
|
expect(r.chainShadow.platoon_applied).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('THE HAND SPLIT MOVES THE NUMBER — otherwise plumbing it changed nothing', async () => {
|
|
const withSplit = [];
|
|
await svc.runSnapshot('mlb', deps(memCache(), withSplit, {
|
|
supabase: fakeSb(),
|
|
hitsFactorContext: { build: async () => factorContext },
|
|
matchupKeys: { build: async () => Object.assign(() => KEYS, { stats: {} }) },
|
|
}));
|
|
const seasonOnly = [];
|
|
await svc.runSnapshot('mlb', deps(memCache(), seasonOnly, {
|
|
supabase: fakeSb(),
|
|
hitsFactorContext: { build: async () => null },
|
|
matchupKeys: { build: async () => null },
|
|
}));
|
|
const a = withSplit.find((r) => r.chain_shadow).chain_shadow.chain_p;
|
|
const b = seasonOnly.find((r) => r.chain_shadow).chain_shadow.chain_p;
|
|
expect(a).not.toBeCloseTo(b, 3);
|
|
});
|
|
});
|
|
|
|
describe('CHAIN v3 — the opportunity term is REALLY fed, not a constant 4.1', () => {
|
|
const KEYS_WITH_SLOT = (slot) => ({
|
|
opponent: 'Boston Red Sox', opposing_pitcher: 'Chris Sale',
|
|
batting_order: slot, source: 'lineup_context+schedule', refused: null,
|
|
});
|
|
const ctxFor = (prop, sport, keys) => ({
|
|
spray: null, positionOaa: null, bats: 'R',
|
|
throws: keys && keys.opposing_pitcher ? 'L' : null,
|
|
pitcherHardHit: null, platoonSplits: null, as_of: '2026-08-12',
|
|
});
|
|
const withSlot = (slot, sink) => svc.runSnapshot('mlb', deps(memCache(), sink, {
|
|
supabase: fakeSbTop(),
|
|
hitsFactorContext: { build: async () => ctxFor },
|
|
matchupKeys: { build: async () => Object.assign(() => KEYS_WITH_SLOT(slot), { stats: {} }) },
|
|
}));
|
|
|
|
it('a POSTED lineup slot reaches the chain and sets E[PA]', async () => {
|
|
const sink = [];
|
|
await withSlot(1, sink);
|
|
const row = sink.find((r) => r.chain_shadow);
|
|
expect(row.chain_shadow.opportunity_source).toBe('lineup_slot');
|
|
expect(row.chain_shadow.expected_pa).toBe(4.65); // leadoff, not 4.1
|
|
});
|
|
|
|
it('LEADOFF and the NINE HOLE do not get the same probability', async () => {
|
|
// This is the v2 defect made a test: without the slot every hitter ran on
|
|
// DEFAULT_PA (4.1), so the opportunity half of `rate x opportunity` was a
|
|
// constant across the entire lineup.
|
|
const lead = []; const nine = [];
|
|
await withSlot(1, lead);
|
|
await withSlot(9, nine);
|
|
const a = lead.find((r) => r.chain_shadow).chain_shadow;
|
|
const b = nine.find((r) => r.chain_shadow).chain_shadow;
|
|
expect(a.expected_pa).toBeGreaterThan(b.expected_pa);
|
|
expect(a.chain_p).not.toBeCloseTo(b.chain_p, 3);
|
|
});
|
|
|
|
it('an UNPOSTED lineup falls to a regular AND SAYS SO', async () => {
|
|
const sink = [];
|
|
await svc.runSnapshot('mlb', deps(memCache(), sink, {
|
|
supabase: fakeSbTop(),
|
|
hitsFactorContext: { build: async () => ctxFor },
|
|
matchupKeys: { build: async () => Object.assign(() => KEYS_WITH_SLOT(null), { stats: {} }) },
|
|
}));
|
|
const row = sink.find((r) => r.chain_shadow);
|
|
expect(row.chain_shadow.opportunity_source).toBe('default_regular');
|
|
// 4.1 is "a regular", which is a claim about the league, not about him. It
|
|
// must never be reported as if the lineup had been read.
|
|
expect(row.chain_shadow.expected_pa).toBeNull();
|
|
});
|
|
|
|
it('the run reports posted-slot coverage separately from the fire rate', async () => {
|
|
const r = await svc.runSnapshot('mlb', deps(memCache(), [], {
|
|
supabase: fakeSbTop(),
|
|
hitsFactorContext: { build: async () => ctxFor },
|
|
matchupKeys: { build: async () => Object.assign(() => KEYS_WITH_SLOT(3), { stats: {} }) },
|
|
}));
|
|
expect(r.chainShadow.opportunity_posted).toBeGreaterThan(0);
|
|
expect(r.chainShadow.props).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('matchupKeys carries batting_order on EVERY return shape, including refusals', () => {
|
|
// A field present only on the happy path is a field consumers cannot rely
|
|
// on; the refusal shapes must carry it as an explicit null.
|
|
const src = fs.readFileSync(path.join(ROOT, 'src/services/model/matchupKeys.js'), 'utf8');
|
|
const returns = src.slice(src.indexOf('function resolve(prop)'));
|
|
expect(returns).toMatch(/const empty = \{[^}]*batting_order: null/);
|
|
expect((returns.match(/batting_order/g) || []).length).toBeGreaterThanOrEqual(4);
|
|
});
|
|
});
|
|
|
|
describe('SERVED-GRADE-UNCHANGED — the gate this whole order sits behind', () => {
|
|
/** Everything a user can see, pulled from what the snapshot actually wrote. */
|
|
const servedShape = (store) => JSON.stringify({
|
|
grades: (store['grades:mlb'] || {}).grades,
|
|
snapshot: (store['snapshot:mlb:latest'] || {}).grades,
|
|
});
|
|
|
|
it('the served payload is BYTE-IDENTICAL with the shadow on and off', async () => {
|
|
const on = memCache();
|
|
await svc.runSnapshot('mlb', deps(on, []));
|
|
|
|
const off = memCache();
|
|
// `chainShadow: null` cannot be injected (the ?? default would re-require
|
|
// it), so the OFF arm is a shadow service that reads and returns nothing.
|
|
await svc.runSnapshot('mlb', deps(off, [], {
|
|
chainShadow: { runShadow: () => ({ version: 'off', byKey: new Map(), summary: { atoms: 0, readable: 0, refused: 0, games: 0, games_read: 0, per_game: [] } }) },
|
|
}));
|
|
|
|
expect(servedShape(on)).toBe(servedShape(off));
|
|
});
|
|
|
|
it('p_win on every served grade is exactly the counter value', async () => {
|
|
const cache = memCache();
|
|
await svc.runSnapshot('mlb', deps(cache, []));
|
|
const byPlayer = Object.fromEntries(GRADES.map((g) => [g.player, g.p_win]));
|
|
for (const g of cache.store['grades:mlb'].grades) {
|
|
expect(g.p_win).toBe(byPlayer[g.player]);
|
|
}
|
|
});
|
|
|
|
it('NO served grade carries a chain field at all', async () => {
|
|
const cache = memCache();
|
|
await svc.runSnapshot('mlb', deps(cache, []));
|
|
for (const g of cache.store['grades:mlb'].grades) {
|
|
for (const k of Object.keys(g)) {
|
|
expect(k).not.toMatch(/^chain/);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('0 LEAKS — measured against the source, not asserted in prose', () => {
|
|
const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
|
|
const walk = (dir, out = []) => {
|
|
for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
|
const rel = `${dir}/${e.name}`;
|
|
if (e.isDirectory()) { if (e.name !== 'node_modules' && e.name !== '.next') walk(rel, out); }
|
|
else if (/\.(js|ts|tsx)$/.test(e.name)) out.push(rel);
|
|
}
|
|
return out;
|
|
};
|
|
|
|
it('nothing under src/routes reads the chain shadow', () => {
|
|
for (const f of walk('src/routes')) {
|
|
const src = read(f);
|
|
expect(src).not.toMatch(/chain_shadow|chainShadow|chain_p\b/);
|
|
}
|
|
});
|
|
|
|
it('nothing in the web app reads the chain shadow', () => {
|
|
for (const f of walk('web/src')) {
|
|
const src = read(f);
|
|
expect(src).not.toMatch(/chain_shadow|chainShadow|chain_p\b/);
|
|
}
|
|
});
|
|
|
|
it('the ONLY writers are the shadow service, retention and the snapshot', () => {
|
|
// The EVALUATOR names the column and must: `gradeFreeze` exists to verify
|
|
// that the shadow reaches nothing served, and a verifier that cannot say
|
|
// the name of the thing it verifies is not a verifier. It is excluded from
|
|
// the writer list and held to the stricter rule below — it may NAME the
|
|
// column, never assign to it.
|
|
const EVALUATOR = 'src/services/evaluator/';
|
|
const writers = walk('src')
|
|
.filter((f) => /chain_shadow|chainShadow/.test(read(f)))
|
|
.filter((f) => !f.startsWith(EVALUATOR));
|
|
expect(writers.sort()).toEqual([
|
|
'src/services/model/chainShadow.js',
|
|
'src/services/retentionService.js',
|
|
'src/services/snapshotService.js',
|
|
]);
|
|
});
|
|
|
|
|
|
it('the evaluator NAMES the shadow column but never assigns to it', () => {
|
|
// Excluding it above is only safe if this holds. Proven, not assumed.
|
|
const dir = 'src/services/evaluator/';
|
|
const files = walk('src').filter((f) => f.startsWith(dir));
|
|
expect(files.length).toBeGreaterThan(0);
|
|
for (const f of files) {
|
|
const src = read(f);
|
|
expect(src).not.toMatch(/chain_shadow\s*[:=][^=]/);
|
|
expect(src).not.toMatch(/\.chain_shadow\s*=/);
|
|
}
|
|
});
|
|
it('the shadow runs with requireCalibrated FALSE and says so where it is stored', () => {
|
|
expect(read('src/services/model/chainShadow.js')).toMatch(/requireCalibrated:\s*false/);
|
|
expect(cs.SHADOW_VERSION).toBe('chain-shadow@1');
|
|
});
|
|
|
|
it('CALIBRATION_DEPLOYED is still empty — nothing is served calibrated', () => {
|
|
expect(svc.CALIBRATION_DEPLOYED).toEqual([]);
|
|
expect(Object.isFrozen(svc.CALIBRATION_DEPLOYED)).toBe(true);
|
|
});
|
|
|
|
it('the counter still serves: probabilityEstimator is what analyzeViaEngine1 calls', () => {
|
|
const engine = read('src/services/intelligence/analyzeViaEngine1.js');
|
|
expect(engine).toMatch(/require\('\.\/probabilityEstimator'\)/);
|
|
// And the engine knows nothing about the chain — the shadow is downstream.
|
|
expect(engine).not.toMatch(/chainShadow|baseballChain/);
|
|
});
|
|
|
|
it('A8 and the factor freeze are untouched — factor_inputs still lands', async () => {
|
|
const rows = require('../../src/services/retentionService').rowsFromSides(
|
|
{ player: 'X', stat_type: 'hits', line: 0.5 },
|
|
[{ player: 'X', stat_type: 'hits', line: 0.5, direction: 'over', grade: 'B', factor_inputs: { v: 'hits-factors@1' } }],
|
|
{ snapshotId: 's', capturedAt: 't', sport: 'mlb', gameDate: '2026-08-12' },
|
|
);
|
|
expect(rows[0].factor_inputs).toEqual({ v: 'hits-factors@1' });
|
|
expect(rows[0].chain_shadow).toBeNull(); // declared, so batches keep one shape
|
|
});
|
|
|
|
it('WNBA is untouched — the shadow is MLB-gated', async () => {
|
|
const src = read('src/services/snapshotService.js');
|
|
const i = src.indexOf('CHAIN v1 — THE SHADOW READ');
|
|
const block = src.slice(i, src.indexOf('LINEUP + BASERUNNER CONTEXT', i));
|
|
expect(block).toMatch(/if \(sp === 'mlb'\)/);
|
|
});
|
|
});
|