9538e11198
Two premise corrections first. Pitcher stuff features have NOT proven solo through the gate -- every one was refused on sample (n=57 against 500). Four exceed the effect-size bar (arm angle -0.250, whiff +0.213, k rate +0.206, chase +0.195), which is why they are worth pursuing, but clearing one of three thresholds is not passing. And the carrier was not blocked only on the lineup input: that input was built and measured last session at 94.7% coverage. What blocks it is n, and n was being throttled by the grading cap. RUNG 1 IS DERIVED AND COSTS NOTHING. Opposing-team K-rate comes from joining the opposing roster to the batter k_pct values already in statcast_aggregates -- no new feed. The improvement this session is that it is PA-WEIGHTED: an unweighted roster mean counts a 12-PA callup the same as an everyday starter, which is not the lineup a pitcher faces. That change alone reversed the term's sign. Unweighted, the lineup term HURT the model (0.1738 -> 0.1285). PA-weighted, it HELPS (0.1738 -> 0.1953). Same hypothesis, same data -- the derivation was the problem, not the signal, which is the entire argument for deriving the best honest version before sourcing anything. Head-to-head is now +0.2592 with a CI of [-0.0167, +0.5645], very nearly excluding zero, at n=57. Within archetype, the two strata come out with OPPOSITE signs -- FLAME incremental -0.152, non-FLAME +0.145 -- and the pooled value (+0.077) sits between them, which is the shape a conditional effect makes and is invisible when pooled. That is what stratifying was for. But n is 20 and 24, the standard error on a correlation there is about 0.22, and the direction contradicts the theory that predicted a stronger effect for finesse arms. It is recorded as a structure to re-test, not as a finding. Rungs 2 and 3 are NOT triggered. A rung fails only once it has been fairly tested, and Rung 1 is n-blocked rather than failed. Sourcing confirmed lineups now would be paying for precision on top of a proxy we have not yet measured. THE RESULT THAT DECIDES THE TIMELINE: yesterday's cap raise is fingerprinted in production at 907 grades per snapshot, up from 334, with strikeouts going 6 to 17. That puts n>=500 for pitcher Ks about a week out instead of three months. Operational note: the manual internal snapshot endpoint now 524s at the Cloudflare edge because grading the full board exceeds 100s -- the run still completes server-side (this very snapshot was written by a 524'd request) and the cron is in-process, so a 524 there is not a failure. Nothing proven, nothing calibrated, nothing shipped. The counter remains anti-predictive on strikeouts at -0.064 and the skill model leads it by 0.26. 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
349 lines
17 KiB
JavaScript
349 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* pitcher-prove-k — STRIKEOUTS through the both-ways gate.
|
|
*
|
|
* Same bar as everything else: solo pass as the control, theory-first
|
|
* interactions each measured against their own components, gate at n>=500 /
|
|
* |r|>=0.15 / p<0.05 / Bonferroni, then head-to-head vs the counter.
|
|
*
|
|
* THE THEORIZED SIGNAL-CARRIER is `stuff x opposing-lineup K-rate`. An elite
|
|
* strikeout arm against a contact lineup that never whiffs is a different bet
|
|
* from the same arm against a three-true-outcomes lineup, and neither side says
|
|
* it alone — the pitcher analogue of the batter model's contact-quality term.
|
|
* The lineup rate is built from the OPPOSING TEAM'S OWN BATTERS (roster join to
|
|
* their statcast K%), not from a league constant, or the interaction would be a
|
|
* relabelled copy of the pitcher's own rate.
|
|
*
|
|
* VALIDITY: statcast_aggregates still carries one as-of date (2026-08-03) and
|
|
* `statcast_history` has one day, so there is no point-in-time window yet.
|
|
* Results here are CONTAMINATED / DIRECTIONAL and are not gate verdicts.
|
|
*
|
|
* SUPABASE_URL=... node scripts/pitcher-prove-k.js
|
|
*/
|
|
|
|
require('dotenv').config();
|
|
const { createClient } = require('@supabase/supabase-js');
|
|
const cv = require('../src/services/model/correlateValidator');
|
|
const pe = require('../src/services/model/pitcherEngine');
|
|
const sk = require('../src/services/model/skillProjection');
|
|
const mlb = require('../src/services/adapters/mlbStatsAdapter');
|
|
const { knownRate, knownNumber } = require('../src/utils/known');
|
|
|
|
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 r4 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 10000) / 10000);
|
|
const mean = (a) => (a.length ? a.reduce((x, y) => x + y, 0) / a.length : null);
|
|
const brier = (ps, ys) => (ps.length ? ps.reduce((s, p, i) => s + (p - ys[i]) ** 2, 0) / ps.length : null);
|
|
|
|
function olsResiduals(y, Xcols) {
|
|
const n = y.length; const p = Xcols.length + 1;
|
|
const X = []; for (let i = 0; i < n; i += 1) { const row = [1]; for (const c of Xcols) row.push(c[i]); X.push(row); }
|
|
const XtX = Array.from({ length: p }, () => new Array(p).fill(0)); const Xty = new Array(p).fill(0);
|
|
for (let i = 0; i < n; i += 1) for (let a = 0; a < p; a += 1) {
|
|
Xty[a] += X[i][a] * y[i];
|
|
for (let b = 0; b < p; b += 1) XtX[a][b] += X[i][a] * X[i][b];
|
|
}
|
|
const M = XtX.map((row, i) => [...row, Xty[i]]);
|
|
for (let col = 0; col < p; col += 1) {
|
|
let piv = col;
|
|
for (let r = col + 1; r < p; r += 1) if (Math.abs(M[r][col]) > Math.abs(M[piv][col])) piv = r;
|
|
if (Math.abs(M[piv][col]) < 1e-12) return null;
|
|
[M[col], M[piv]] = [M[piv], M[col]];
|
|
const d = M[col][col];
|
|
for (let k = col; k <= p; k += 1) M[col][k] /= d;
|
|
for (let r = 0; r < p; r += 1) { if (r === col) continue; const f = M[r][col]; for (let k = col; k <= p; k += 1) M[r][k] -= f * M[col][k]; }
|
|
}
|
|
const beta = M.map((row) => row[p]);
|
|
return y.map((v, i) => v - X[i].reduce((s, xv, j) => s + xv * beta[j], 0));
|
|
}
|
|
function partialCorr(a, b, ctrl) {
|
|
for (let i = 0; i < ctrl.length; i += 1) for (let j = i + 1; j < ctrl.length; j += 1) {
|
|
const rr = cv.pearson(ctrl[i], ctrl[j]).r;
|
|
if (rr !== null && Math.abs(rr) > 0.999) return null; // same variable twice
|
|
}
|
|
const ra = olsResiduals(a, ctrl); const rb = olsResiduals(b, ctrl);
|
|
if (!ra || !rb) return null;
|
|
return cv.pearson(ra, rb).r;
|
|
}
|
|
function makeRnd(seed) { let s = seed >>> 0; return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; }; }
|
|
function bootstrapDiff(rows, kA, kB, iters = 4000, seed = 20260805) {
|
|
if (rows.length < 30) return null;
|
|
const rnd = makeRnd(seed); const n = rows.length; const diffs = [];
|
|
for (let it = 0; it < iters; it += 1) {
|
|
const ys = []; const a = []; const b = [];
|
|
for (let i = 0; i < n; i += 1) { const r = rows[Math.floor(rnd() * n)]; ys.push(r.won); a.push(r[kA]); b.push(r[kB]); }
|
|
const ca = cv.pearson(a, ys).r; const cb = cv.pearson(b, ys).r;
|
|
if (ca == null || cb == null) continue;
|
|
diffs.push(ca - cb);
|
|
}
|
|
if (diffs.length < 100) return null;
|
|
diffs.sort((x, y) => x - y);
|
|
const q = (pp) => r4(diffs[Math.floor(pp * (diffs.length - 1))]);
|
|
const ci = [q(0.025), q(0.975)];
|
|
return { point: r4(cv.pearson(rows.map((r) => r[kA]), rows.map((r) => r.won)).r - cv.pearson(rows.map((r) => r[kB]), rows.map((r) => r.won)).r), ci95: ci, ci_excludes_zero: ci[0] > 0 || ci[1] < 0 };
|
|
}
|
|
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;
|
|
}
|
|
|
|
const SOLO = ['pitcher_whiff_pct', 'pitcher_k_pct', 'pitcher_chase_pct', 'pitcher_gb_pct',
|
|
'pitcher_arm_angle', 'opposing_lineup_k_rate'];
|
|
|
|
const INTERACTIONS = [
|
|
{
|
|
key: 'stuff_x_lineup_k_rate',
|
|
components: ['pitcher_whiff_pct', 'opposing_lineup_k_rate'],
|
|
mechanism: 'THE theorized carrier. Strikeouts need a pitcher who can miss bats AND a lineup that can be missed. An elite arm against a contact lineup and a modest arm against a whiff-prone one can produce the same count, so neither factor alone orders the props — the product should.',
|
|
build: (r) => r.pitcher_whiff_pct * r.opposing_lineup_k_rate,
|
|
},
|
|
{
|
|
key: 'stuff_x_power_archetype',
|
|
components: ['pitcher_whiff_pct', 'archetype_flame'],
|
|
mechanism: 'ARCHETYPE-CONDITIONAL. Stuff should govern strikeouts more for a power arm than for a finesse arm, whose Ks come from chase and sequencing. Discipline 2 as a testable claim, with a categorical conditioner independent of whiff by construction.',
|
|
build: (r) => r.pitcher_whiff_pct * r.archetype_flame,
|
|
},
|
|
{
|
|
key: 'chase_x_lineup_k_rate',
|
|
components: ['pitcher_chase_pct', 'opposing_lineup_k_rate'],
|
|
mechanism: 'The finesse channel: expanding the zone only works against a lineup that will chase. Same shape as the stuff term, different mechanism, so it is tested separately rather than assumed to be the same effect.',
|
|
build: (r) => r.pitcher_chase_pct * r.opposing_lineup_k_rate,
|
|
},
|
|
];
|
|
|
|
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 statcast = await page(sb, 'statcast_aggregates', '*', (q) => q.eq('sport', 'mlb'));
|
|
const freezeDate = statcast.reduce((mx, r) => (String(r.updated_at) > mx ? String(r.updated_at) : mx), '').slice(0, 10);
|
|
const pitchByKey = new Map(); const batterByKey = new Map();
|
|
for (const r of statcast) {
|
|
const prof = sk.fromStatcastRow(r);
|
|
if (r.role === 'pitcher' && r.player_key) pitchByKey.set(r.player_key, prof);
|
|
if (r.role === 'batter' && r.player_key) batterByKey.set(r.player_key, prof);
|
|
}
|
|
|
|
const led = await page(sb, 'ledger_entries',
|
|
'player_key, player_name, stat, line, side, outcome, game_date, p_win, quarantine_reason',
|
|
(q) => q.eq('sport', 'mlb').is('user_id', null).eq('stat', 'strikeouts')
|
|
.in('outcome', ['hit', 'miss']).not('p_win', 'is', null));
|
|
const clean = led.filter((r) => !(r.quarantine_reason || '').startsWith('nontakeable_book'));
|
|
|
|
// ── OPPOSING LINEUP K-RATE, from the opposing team's OWN batters ────────
|
|
// Roster join, not a league constant: a constant would make the interaction a
|
|
// rescaled copy of the pitcher's own rate and guarantee a false "redundant".
|
|
const { nameKey } = require('../src/utils/playerName');
|
|
const teamKRate = new Map();
|
|
async function lineupKFor(teamName) {
|
|
if (!teamName) return null;
|
|
if (teamKRate.has(teamName)) return teamKRate.get(teamName);
|
|
let val = null;
|
|
try {
|
|
// The game log gives a full team NAME ("Cincinnati Reds"); resolveTeam
|
|
// wants an ABBREVIATION. Passing the name straight through silently
|
|
// resolved nothing and produced 0% lineup coverage on the first run — the
|
|
// theorized signal-carrier was not failing, it was never being tested.
|
|
const { NAME_TO_ABBR } = require('../src/services/environmentContext');
|
|
const abbr = /^[A-Z]{2,3}$/.test(String(teamName).trim())
|
|
? String(teamName).trim().toUpperCase()
|
|
: NAME_TO_ABBR[String(teamName).toLowerCase()];
|
|
if (!abbr) { teamKRate.set(teamName, null); return null; }
|
|
const team = await mlb.resolveTeam(abbr);
|
|
const roster = team && team.id ? await mlb.getTeamRoster(team.id) : null;
|
|
// PA-WEIGHTED, not a flat roster average. An unweighted mean counts a
|
|
// 12-PA September call-up the same as an everyday starter, which is not
|
|
// the lineup a pitcher faces. Weighting by each batter's own sample_pa is
|
|
// the closest honest approximation of "who actually bats" from data we
|
|
// already hold — and it needs no new sourcing at all.
|
|
let wSum = 0; let wK = 0; let counted = 0;
|
|
for (const p of roster || []) {
|
|
const prof = batterByKey.get(nameKey(p.name || p.fullName || ''));
|
|
if (!prof) continue;
|
|
const k = knownRate(prof.k_pct);
|
|
const pa = knownRate(prof.sample_pa);
|
|
if (k === null) continue;
|
|
const w = pa === null ? 0 : pa; // no PA read -> contributes nothing
|
|
if (w <= 0) continue;
|
|
wSum += w; wK += w * k; counted += 1;
|
|
}
|
|
if (counted >= 5 && wSum > 0) val = wK / wSum;
|
|
} catch { val = null; }
|
|
teamKRate.set(teamName, val);
|
|
return val;
|
|
}
|
|
|
|
// The opponent a pitcher faced on a date, from his own game log.
|
|
const oppBy = new Map();
|
|
const names = new Map();
|
|
for (const r of clean) if (!names.has(r.player_key)) names.set(r.player_key, r.player_name);
|
|
for (const [key, name] of names) {
|
|
try {
|
|
const found = await mlb.searchPlayer(name);
|
|
if (!found || !found.id) continue;
|
|
const log = await mlb.getPlayerGameLog(found.id, undefined, 'pitching');
|
|
for (const g of log || []) if (g && g.date && g.opponent) oppBy.set(`${key}|${String(g.date).slice(0, 10)}`, g.opponent);
|
|
} catch { /* no log → no lineup term */ }
|
|
}
|
|
|
|
const rows = [];
|
|
for (const r of clean) {
|
|
const prof = pitchByKey.get(r.player_key);
|
|
if (!prof) continue;
|
|
const opp = oppBy.get(`${r.player_key}|${r.game_date}`) || null;
|
|
const lineupK = opp ? await lineupKFor(opp) : null;
|
|
const cls = pe.classifyPitcher(prof);
|
|
const arch = cls ? cls.primary : null;
|
|
const under = String(r.side).toLowerCase() === 'under';
|
|
const won = r.outcome === 'hit' ? 1 : 0;
|
|
const champ = Number(r.p_win);
|
|
const proj = pe.projectStrikeouts({
|
|
pitcher: prof, lineupKRate: lineupK, archetype: arch,
|
|
role: 'starter', line: Number(r.line),
|
|
});
|
|
// The SAME model with the lineup term switched off, so the term's
|
|
// contribution is isolated rather than inferred.
|
|
const projNo = pe.projectStrikeouts({
|
|
pitcher: prof, lineupKRate: null, archetype: arch,
|
|
role: 'starter', line: Number(r.line),
|
|
});
|
|
rows.push({
|
|
won, champ, residual: won - champ,
|
|
pitch: proj ? (under ? 1 - proj.p_over_line : proj.p_over_line) : null,
|
|
pitch_nolineup: projNo ? (under ? 1 - projNo.p_over_line : projNo.p_over_line) : null,
|
|
lineup_applied: !!lineupK,
|
|
archetype: arch,
|
|
archetype_flame: arch == null ? null : (arch === 'FLAME' ? 1 : 0),
|
|
pitcher_whiff_pct: knownRate(prof.whiff_pct),
|
|
pitcher_k_pct: knownRate(prof.k_pct),
|
|
pitcher_chase_pct: knownRate(prof.chase_pct),
|
|
pitcher_gb_pct: knownRate(prof.gb_pct),
|
|
pitcher_arm_angle: knownRate(prof.arm_angle),
|
|
opposing_lineup_k_rate: lineupK,
|
|
});
|
|
}
|
|
|
|
const TESTS = SOLO.length + INTERACTIONS.length;
|
|
const complete = (keys) => rows.filter((r) => keys.every((k) => knownNumber(r[k]) !== null));
|
|
|
|
const solo = {};
|
|
for (const f of SOLO) {
|
|
const rs = complete([f]);
|
|
solo[f] = {
|
|
n: rs.length,
|
|
vs_outcome: cv.validateFactor(rs.map((r) => r[f]), rs.map((r) => r.won), TESTS),
|
|
vs_counter_residual: cv.validateFactor(rs.map((r) => r[f]), rs.map((r) => r.residual), TESTS),
|
|
};
|
|
}
|
|
|
|
const interactions = {};
|
|
for (const ix of INTERACTIONS) {
|
|
const rs = complete(ix.components);
|
|
if (rs.length < 20) { interactions[ix.key] = { mechanism: ix.mechanism, n: rs.length, verdict: 'UNTESTABLE — no common sample' }; continue; }
|
|
const I = rs.map(ix.build); const Y = rs.map((r) => r.residual);
|
|
const ctrl = ix.components.map((k) => rs.map((r) => r[k]));
|
|
const gate = cv.validateFactor(I, Y, TESTS);
|
|
const incr = partialCorr(I, Y, ctrl);
|
|
const parts = ix.components.map((k) => ({ feature: k, r: r4(cv.pearson(rs.map((r) => r[k]), Y).r) }));
|
|
const best = Math.max(...parts.map((p) => Math.abs(p.r ?? 0)));
|
|
interactions[ix.key] = {
|
|
mechanism: ix.mechanism, components: ix.components, n: rs.length,
|
|
raw_r_vs_residual: gate.pearson_r,
|
|
gate: { validated: gate.validated, reason: gate.reason, underpowered: !!gate.underpowered, rows_needed: gate.rows_needed ?? null },
|
|
component_solo_r: parts, best_component_abs_r: r4(best),
|
|
INCREMENTAL_partial_r: r4(incr),
|
|
adds_over_components: incr !== null && Math.abs(incr) > best,
|
|
verdict: incr === null ? 'UNTESTABLE — collinear controls'
|
|
: (gate.validated && Math.abs(incr) >= 0.15) ? 'PASSES-AND-ADDS'
|
|
: gate.validated ? 'PASSES-BUT-REDUNDANT'
|
|
: rs.length < 500 ? 'UNDERPOWERED — n below the gate' : 'FAILS',
|
|
};
|
|
}
|
|
|
|
// ── WITHIN-ARCHETYPE: the order's sharper hypothesis ────────────────────
|
|
// The interaction should matter MORE for finesse arms (SCALPEL/SINKER), whose
|
|
// strikeouts need a lineup that will chase or can be beaten, than for power
|
|
// arms (FLAME) whose stuff whiffs regardless of who is standing there. Pooling
|
|
// the two would average a real conditional effect toward zero — which is
|
|
// exactly the failure mode "test within archetype" exists to prevent.
|
|
const strata = {};
|
|
for (const [label, pred] of [
|
|
['FLAME (power)', (r) => r.archetype === 'FLAME'],
|
|
['non-FLAME (finesse/contact)', (r) => r.archetype && r.archetype !== 'FLAME'],
|
|
]) {
|
|
const rs = rows.filter((r) => pred(r)
|
|
&& knownNumber(r.pitcher_whiff_pct) !== null
|
|
&& knownNumber(r.opposing_lineup_k_rate) !== null);
|
|
if (rs.length < 15) { strata[label] = { n: rs.length, verdict: 'UNTESTABLE — stratum too thin' }; continue; }
|
|
const I = rs.map((r) => r.pitcher_whiff_pct * r.opposing_lineup_k_rate);
|
|
const Y = rs.map((r) => r.residual);
|
|
const ctrl = [rs.map((r) => r.pitcher_whiff_pct), rs.map((r) => r.opposing_lineup_k_rate)];
|
|
const incr = partialCorr(I, Y, ctrl);
|
|
const parts = [
|
|
{ feature: 'pitcher_whiff_pct', r: r4(cv.pearson(rs.map((r) => r.pitcher_whiff_pct), Y).r) },
|
|
{ feature: 'opposing_lineup_k_rate', r: r4(cv.pearson(rs.map((r) => r.opposing_lineup_k_rate), Y).r) },
|
|
];
|
|
const best = Math.max(...parts.map((p) => Math.abs(p.r ?? 0)));
|
|
strata[label] = {
|
|
n: rs.length,
|
|
raw_r_vs_residual: r4(cv.pearson(I, Y).r),
|
|
lineup_solo_r: parts[1].r,
|
|
component_solo_r: parts,
|
|
best_component_abs_r: r4(best),
|
|
INCREMENTAL_partial_r: r4(incr),
|
|
adds_over_components: incr !== null && Math.abs(incr) > best,
|
|
gate: cv.validateFactor(I, Y, TESTS),
|
|
verdict: incr === null ? 'UNTESTABLE — collinear'
|
|
: rs.length < 500 ? 'UNDERPOWERED — n below the gate'
|
|
: (Math.abs(incr) >= 0.15 ? 'ADDS' : 'REDUNDANT'),
|
|
};
|
|
}
|
|
|
|
const h2h = rows.filter((r) => r.pitch != null);
|
|
const ys = h2h.map((r) => r.won);
|
|
const bs = bootstrapDiff(h2h, 'pitch', 'champ');
|
|
const bsNoLineup = bootstrapDiff(h2h.filter((r) => r.pitch_nolineup != null), 'pitch_nolineup', 'champ');
|
|
|
|
console.log(JSON.stringify({
|
|
stat: 'strikeouts',
|
|
VALIDITY: `CONTAMINATED / DIRECTIONAL — statcast carries one as-of date (${freezeDate}); statcast_history has no window yet. NOT gate verdicts.`,
|
|
rows_scored: rows.length,
|
|
lineup_coverage: r4(mean(rows.map((r) => (r.lineup_applied ? 1 : 0)))),
|
|
archetype_mix: rows.reduce((a, r) => { const k = r.archetype || 'unclassified'; a[k] = (a[k] || 0) + 1; return a; }, {}),
|
|
gate_spec: cv.VALIDATION_REQUIREMENTS,
|
|
bonferroni_tests: TESTS,
|
|
step1_solo_baseline: solo,
|
|
step3_interactions: interactions,
|
|
step3b_within_archetype_carrier: strata,
|
|
step4_vs_counter: {
|
|
n: h2h.length,
|
|
base_rate: r4(mean(ys)),
|
|
resolution: { pitch_v1: r4(cv.pearson(h2h.map((r) => r.pitch), ys).r), counter: r4(cv.pearson(h2h.map((r) => r.champ), ys).r) },
|
|
brier: { pitch_v1: r4(brier(h2h.map((r) => r.pitch), ys)), counter: r4(brier(h2h.map((r) => r.champ), ys)) },
|
|
delta: bs,
|
|
// Isolating the lineup term: does including it help or hurt?
|
|
without_lineup_term: {
|
|
resolution: r4(cv.pearson(h2h.filter((r) => r.pitch_nolineup != null).map((r) => r.pitch_nolineup),
|
|
h2h.filter((r) => r.pitch_nolineup != null).map((r) => r.won)).r),
|
|
delta_vs_counter: bsNoLineup,
|
|
},
|
|
verdict: !bs ? 'N-BLOCKED — too few rows to bootstrap'
|
|
: (bs.ci_excludes_zero && bs.point > 0) ? 'BEATS THE COUNTER'
|
|
: (bs.ci_excludes_zero && bs.point < 0) ? 'LOSES to the counter' : 'INCONCLUSIVE',
|
|
},
|
|
}, null, 2));
|
|
process.exit(0);
|
|
}
|
|
|
|
main().catch((e) => { console.error(e); process.exit(1); });
|