ac1361486e
The order opens with "two proven clusters live". They are not proven -- the
proven set is empty -- and this is the fourth consecutive order to start from
a stronger claim than the measurements support. Correcting that in prose four
times has not worked, so this session adds scripts/proven-status.js, which
recomputes the answer from the ledger: hits LOSES (-0.096, CI excluding zero),
total_bases INCONCLUSIVE (+0.004), strikeouts INCONCLUSIVE (+0.259 at n=57).
It deliberately reports sample readiness separately from recorded verdicts, so
"n>=500" can never again be read as "passed".
A counting error worth recording. The first read of the top-volume archetype
said BOMBER x hits was 641 rows -- gate-ready. It is 287. model_snapshots
holds one row per prop PER SNAPSHOT CYCLE, so joining it to ledger_entries
counts each ledger row once per cycle it appeared in. Deduping on the ledger
row id gives the true figure, and my own status script had the same bug until
it was fixed. That is the difference between running the gate and being short
by 213.
So no archetype x stat combination reaches the gate. BOMBER x hits at 287 is
the closest; pitcher archetypes are untestable at 58 settled strikeout rows
across all of them, so the pitcher half of this order could not be run.
The registry is built: recordConditioning keys archetype x underlying-skill x
interaction x status with measured lift, and the skill tag is MANDATORY and
enforced -- untagged entries are refused, and PROVEN without sufficient
evidence is refused. validatedSkills() returns the coherent profile as it
stands, which is {} for every archetype, by design.
BOMBER x hits conditioning was tested across the order's categories and every
result is underpowered: arsenal (barrel x breaking share) incremental +0.043,
batted-ball (launch x pitcher GB) +0.001, contact quality -0.020 and -0.015,
K x K -0.063. Within BOMBER the counter still leads on hits, 0.218 to 0.160,
consistent with the closed pooled negative.
One bug fixed mid-run: fromStatcastRow maps percentage and raw fields only and
does not carry pitch_mix, so the arsenal category first reported n=0 for every
row -- it was measuring nothing rather than failing. Without catching it,
"arsenal doesn't matter" would have been recorded from a column that was never
populated.
On defense: I looked for a derivable proxy before calling it unsourceable, and
there isn't one. We ingest no fielding data at all, and opposing pitchers'
hits-allowed conflates pitching with defense, so it would validate the wrong
skill. It needs Savant's fielding endpoint -- free, same host as the five
feeds already ingested -- and it is not sourced here, because sourcing it to
test at n=282 would answer nothing.
Nothing proved, so nothing was recalibrated and nothing shipped.
4,221 tests green (335 suites); web build exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
127 lines
5.8 KiB
JavaScript
127 lines
5.8 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* proven-status — WHAT IS ACTUALLY PROVEN, computed from the ledger.
|
|
*
|
|
* WHY THIS EXISTS. Four consecutive build orders have opened by describing
|
|
* results as proven that the measurements did not support: "barrel rate PASSED
|
|
* solo" (every total_bases feature was refused on sample), "total_bases has
|
|
* passed BAR 1" (inconclusive at parity, CI spanning zero), "whiff/stuff prove
|
|
* SOLO through the gate" (refused at n=57), "two proven clusters live" (the
|
|
* proven set is empty). Each time the correction had to be re-derived by hand
|
|
* from a spec written days earlier.
|
|
*
|
|
* Prose decays. A number recomputed from the ledger does not. So this prints the
|
|
* proven set on demand, from the same gate everything else is held to, and any
|
|
* session can run it in one command before planning on top of a claim.
|
|
*
|
|
* IT DELIBERATELY CANNOT SAY "PROVEN" ON ITS OWN. A stat is proven only if a
|
|
* recorded head-to-head beat the counter out-of-sample with a CI excluding zero,
|
|
* which is a measurement this script does not perform — it reports SAMPLE
|
|
* READINESS (can the gate even be run?) and the recorded verdicts, so the two
|
|
* are never confused again.
|
|
*
|
|
* SUPABASE_URL=... node scripts/proven-status.js
|
|
*/
|
|
|
|
require('dotenv').config();
|
|
const { createClient } = require('@supabase/supabase-js');
|
|
const cv = require('../src/services/model/correlateValidator');
|
|
|
|
const SB_URL = process.env.SUPABASE_URL;
|
|
const SB_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
|
|
const PAGE = 1000;
|
|
const MIN_N = cv.VALIDATION_REQUIREMENTS.min_historical_instances;
|
|
|
|
/**
|
|
* RECORDED VERDICTS — every head-to-head this programme has actually run, with
|
|
* its spec. Add a row when a head-to-head is run; never edit one to be kinder.
|
|
*/
|
|
const RECORDED = [
|
|
{ stat: 'hits', n: 803, model: 0.0842, counter: 0.1803, delta: -0.0961, ci: [-0.1648, -0.0285],
|
|
verdict: 'LOSES', spec: 'specs/batter-cluster-prove.md' },
|
|
{ stat: 'total_bases', n: 383, model: 0.2685, counter: 0.2647, delta: 0.0038, ci: [-0.0675, 0.0753],
|
|
verdict: 'INCONCLUSIVE', spec: 'specs/tb-solo-and-interactions.md' },
|
|
{ stat: 'strikeouts', n: 57, model: 0.1953, counter: -0.0639, delta: 0.2592, ci: [-0.0167, 0.5645],
|
|
verdict: 'INCONCLUSIVE', spec: 'specs/lineup-k-rate-rung1.md' },
|
|
];
|
|
|
|
async function page(sb, table, select, apply) {
|
|
const out = [];
|
|
for (let from = 0; ; from += PAGE) {
|
|
const { data, error } = await apply(sb.from(table).select(select)).range(from, from + PAGE - 1);
|
|
if (error) throw error;
|
|
if (!data || data.length === 0) break;
|
|
out.push(...data);
|
|
if (data.length < PAGE) break;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function main() {
|
|
if (!SB_URL || !SB_KEY) throw new Error('SUPABASE_URL / service key required');
|
|
const sb = createClient(SB_URL, SB_KEY, { auth: { persistSession: false } });
|
|
|
|
const led = await page(sb, 'ledger_entries', 'stat, outcome, quarantine_reason, p_win',
|
|
(q) => q.eq('sport', 'mlb').is('user_id', null));
|
|
const settled = {};
|
|
for (const r of led) {
|
|
if ((r.quarantine_reason || '').startsWith('nontakeable_book')) continue;
|
|
if (r.outcome !== 'hit' && r.outcome !== 'miss') continue;
|
|
if (r.p_win == null) continue;
|
|
settled[r.stat] = (settled[r.stat] || 0) + 1;
|
|
}
|
|
|
|
const snaps = await page(sb, 'model_snapshots', 'stat, archetype, player_key, line, side, game_date',
|
|
(q) => q.eq('sport', 'mlb').not('archetype', 'is', null));
|
|
const archOf = new Map();
|
|
for (const s of snaps) archOf.set(`${s.player_key}|${s.stat}|${s.line}|${String(s.side).toLowerCase()}|${s.game_date}`, s.archetype);
|
|
|
|
// COUNT DISTINCT LEDGER ROWS. `model_snapshots` holds one row per prop PER
|
|
// SNAPSHOT CYCLE, so a naive join fans out and inflates the count — it read
|
|
// BOMBER x hits as 641 when the true figure is 287, which is the difference
|
|
// between "gate-ready" and "not close". Dedupe on the ledger row's identity.
|
|
const led2 = await page(sb, 'ledger_entries', 'id, stat, outcome, quarantine_reason, player_key, line, side, game_date',
|
|
(q) => q.eq('sport', 'mlb').is('user_id', null).in('outcome', ['hit', 'miss']));
|
|
const byArch = {};
|
|
const seen = new Set();
|
|
for (const r of led2) {
|
|
if ((r.quarantine_reason || '').startsWith('nontakeable_book')) continue;
|
|
if (seen.has(r.id)) continue;
|
|
seen.add(r.id);
|
|
const a = archOf.get(`${r.player_key}|${r.stat}|${r.line}|${String(r.side).toLowerCase()}|${r.game_date}`);
|
|
if (!a) continue;
|
|
const k = `${a} x ${r.stat}`;
|
|
byArch[k] = (byArch[k] || 0) + 1;
|
|
}
|
|
|
|
const gateReady = Object.entries(settled).filter(([, n]) => n >= MIN_N).map(([s, n]) => ({ stat: s, n }));
|
|
const archReady = Object.entries(byArch).filter(([, n]) => n >= MIN_N)
|
|
.sort((a, b) => b[1] - a[1]).map(([k, n]) => ({ combo: k, n }));
|
|
|
|
const proven = RECORDED.filter((r) => r.verdict === 'BEATS');
|
|
|
|
console.log(JSON.stringify({
|
|
generated_at_note: 'computed from the ledger; prose in specs may lag this',
|
|
gate_spec: cv.VALIDATION_REQUIREMENTS,
|
|
|
|
PROVEN_SET: proven.length === 0 ? 'EMPTY — no stat has beaten the counter out-of-sample with a CI excluding zero' : proven,
|
|
|
|
recorded_head_to_heads: RECORDED,
|
|
|
|
sample_readiness: {
|
|
note: 'n >= 500 means the gate CAN be run — it does not mean anything passed it',
|
|
stats_at_or_above_gate: gateReady,
|
|
stats_below_gate: Object.entries(settled).filter(([, n]) => n < MIN_N)
|
|
.sort((a, b) => b[1] - a[1]).map(([s, n]) => ({ stat: s, n, short_by: MIN_N - n })),
|
|
archetype_x_stat_at_or_above_gate: archReady,
|
|
archetype_x_stat_closest_below: Object.entries(byArch).filter(([, n]) => n < MIN_N)
|
|
.sort((a, b) => b[1] - a[1]).slice(0, 6).map(([k, n]) => ({ combo: k, n, short_by: MIN_N - n })),
|
|
},
|
|
}, null, 2));
|
|
process.exit(0);
|
|
}
|
|
|
|
main().catch((e) => { console.error(e); process.exit(1); });
|