The probability never leaked across stats; the attribution did

Post-fix cohort df4ec562 closed the primary question: eleven non-hits stats,
1,910 rows, ZERO certified and ZERO numeric served probabilities. The cross-stat
repair holds.

But every one of those 1,910 rows still recorded
`artifact_id: mlb-hits-isotonic@2026-09-03` beside state UNSUPPORTED. A `doubles`
row named the hits artifact. Nothing was calibrated by it, so no number leaked —
but a later query for "rows this artifact produced" would have returned 2,186
instead of 133, and that is the shape of footgun this programme keeps finding.

The contract check runs BEFORE any artifact is relevant: with no certified
contract for the sport/stat, no artifact applies, and naming one asserts a
relationship that does not exist. UNSUPPORTED now carries null artifact,
artifact_id, estimator_type, estimator_version, certification_version and
procedure_version.

Attribution is KEPT where the artifact is genuinely the thing that declined —
UNCERTIFIED (out of support) and VERSION_MISMATCH both still name it. A test
holds both directions so this does not over-correct into erasing real provenance.

One existing test called resolve() without naming a stat and relied on the
service substituting one. That substitution was the original defect, so the test
now names its stat, as production does.

Artifact unchanged. Live OFF. Suite 405/405, 5,662 passed. Teeth 35/35 + 10/10 + 23/23.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
This commit is contained in:
Kev
2026-09-03 18:33:31 -04:00
parent 3184c05d51
commit 94f7c3c3ef
4 changed files with 73 additions and 3 deletions
+45
View File
@@ -274,3 +274,48 @@ describe('ONE ARTIFACT, ONE STAT — the cross-stat leak found by cohort 0353c55
expect(r.served_probability).toBeNull();
});
});
describe('an UNSUPPORTED row attributes no artifact', () => {
const retention = require('../../src/services/retentionService');
it('names no artifact for a stat that has no contract', () => {
const r = pc.resolve({ sport: 'mlb', stat: 'doubles', model_version: ERA, p_win: 0.65 },
{ estimate: est, artifact: good });
expect(r.probability_state).toBe(pc.STATE.UNSUPPORTED);
expect(r.served_probability).toBeNull();
// the attribution, not just the number
expect(r.artifact).toBeNull();
expect(r.artifact_id).toBeNull();
expect(r.estimator_type).toBeNull();
expect(r.procedure_version).toBeNull();
expect(JSON.stringify(r)).not.toContain(good.artifact_id);
});
it('but a hits row in the same batch still carries the full identity', async () => {
const contract = await svc.build(null, { sport: 'mlb', stat: 'hits' });
const c = retention.createCollector({
snapshotId: 's2', sport: 'mlb', modelVersion: ERA, codeSha: 't',
gameDate: '2026-09-03', gameIdFor: () => 'g',
});
for (const stat of ['hits', 'doubles']) {
c.onGraded({ player: `P ${stat}`, stat_type: stat, line: 0.5, sport: 'mlb', over_odds: -110, under_odds: -110 },
[{ direction: 'over', grade: 'C+', p_win: 0.65, confidence: 65 }]);
}
const merged = retention.mergeProbabilityContract(c.rows, contract);
const hits = merged.find((r) => r.stat === 'hits').probability_contract;
const doubles = merged.find((r) => r.stat === 'doubles').probability_contract;
expect(hits.artifact.artifact_id).toBe(good.artifact_id);
expect(hits.served_probability).not.toBeNull();
expect(doubles.artifact).toBeNull();
expect(doubles.served_probability).toBeNull();
expect(doubles.probability_state).toBe(pc.STATE.UNSUPPORTED);
});
it('an artifact IS still named where it is the relevant one', () => {
// uncertified (out of support) and version-mismatch rows keep attribution:
// there the artifact was consulted and is the thing that declined.
expect(pc.resolve(read(0.91), { estimate: est, artifact: good }).artifact).not.toBeNull();
expect(pc.resolve(read(0.65, { model_version: 'other' }), { estimate: est, artifact: good }).artifact)
.not.toBeNull();
});
});
+3 -2
View File
@@ -217,8 +217,9 @@ describe('probabilityContractService — loads, never fits', () => {
const b1 = await svc.build(null, { sport: 'mlb', stat: 'hits' });
const b2 = await svc.build(null, { sport: 'mlb', stat: 'hits' });
for (const p of [0.50, 0.55, 0.601, 0.72, 0.799]) {
const a = b1.resolve({ model_version: ERA, p_win: p });
const c = b2.resolve({ model_version: ERA, p_win: p });
// the read NAMES its stat — the resolver no longer substitutes one
const a = b1.resolve({ sport: 'mlb', stat: 'hits', model_version: ERA, p_win: p });
const c = b2.resolve({ sport: 'mlb', stat: 'hits', model_version: ERA, p_win: p });
expect(a.served_probability).toBe(c.served_probability);
expect(a.artifact.knot_digest).toBe(c.artifact.knot_digest);
}