'use strict'; /** * factorFreeze — record WHAT THE FACTOR READ, at the moment it read it. * * Spec: specs/read-integrity-harness.md §10 * * ── WHY ────────────────────────────────────────────────────────────────── * A4 established that a re-audit joining today's context to a past grade is * contamination, and fixed it with an as-of cutoff. But an as-of join still * RECONSTRUCTS: it asks "what would the context have been" rather than "what did * the grader actually see". Those differ whenever a context table was refreshed * late, backfilled, or corrected. The only way a row becomes independently * checkable is if it carries its own inputs. * * ── INPUTS, NEVER OUTPUTS ──────────────────────────────────────────────── * This freezes the RAW values the factor consumed — spray shares, the opposing * team's positional OAA, the pitcher's hard-hit rate, the platoon split counts, * handedness — and deliberately NOT the resulting multiplier. A stored * multiplier is unfalsifiable: it can only be compared to itself. Stored inputs * can be re-run through `hitsFactors` and the answer checked, which is what * makes an audit an audit. `recompute()` below exists precisely so that check is * one call. * * ── IT RECORDS ABSENCE AS ABSENCE ──────────────────────────────────────── * Measured on 596 real graded hits props (2026-08-10): `positionOaa`, `throws` * and `pitcherHardHit` resolved on ZERO of them, because nothing in the pipeline * ever sets `prop.opponent` or `prop.opposing_pitcher` — the two join keys the * resolver needs. All three factors were skipped on 596/596 and the multiplier * was exactly 1 every time. * * So today this vector mostly freezes nulls, and that is the correct behaviour: * it is the evidence that the factors are wired but inert. `available` records * what COULD have been joined from the row, so a later order can measure what * plumbing those keys would change before changing it. `available` is never fed * to a factor — reading it here would silently alter the served grade. */ const { knownNumber } = require('../../utils/known'); /** Version the shape, so a later change is distinguishable from a null. */ const FREEZE_VERSION = 'hits-factors@1'; const num = (v) => knownNumber(v); /** The spray shares a defence read consumes — raw, not the multiplier. */ function sprayInputs(spray) { if (!spray) return null; return { as_of: spray.as_of_date || null, pull_gb: num(spray.pull_gb), straight_gb: num(spray.straight_gb), oppo_gb: num(spray.oppo_gb), pull_air: num(spray.pull_air), straight_air: num(spray.straight_air), oppo_air: num(spray.oppo_air), }; } /** Platoon split counts — the raw PAs and hits, so severity is re-derivable. */ function platoonInputs(splits) { if (!splits) return null; const side = (s) => (s ? { pa: num(s.pa), ab: num(s.atBats), hits: num(s.hits) } : null); return { vl: side(splits.vl), vr: side(splits.vr) }; } /** * Build the frozen vector from the SAME context object the factor was handed. * * @param {object} ctx the resolved factor context (exactly what hitsFactors got) * @param {object} prop the graded prop, for the join keys as they stood */ /** * FIX A6 — the SHADOW would-fire block. * * With the join keys resolved, the factors CAN compute. This records what they * WOULD have applied, namespaced under `would_fire`, and it is never added to * the served forecast. It is evidence for a later, gated decision — not a grade. * * `multiplier` is stored here (unlike the inputs rule) because the shadow inputs * are stored ALONGSIDE it under `shadow_inputs`, so it stays recomputable and * checkable. A number you cannot re-derive is the thing that rule forbids. */ function shadowBlock(shadowCtx, applied, keys) { if (!shadowCtx || !applied) return null; return { keys: { opponent: (keys && keys.opponent) || null, opposing_pitcher: (keys && keys.opposing_pitcher) || null, source: (keys && keys.source) || null, refused: (keys && keys.refused) || null, }, multiplier: applied.multiplier, factors_fired: applied.factors_fired, per_factor: applied.applied.map((a) => ({ factor: a.factor, multiplier: a.multiplier })), skipped: applied.skipped.map((sk) => sk.factor), shadow_inputs: { position_oaa: shadowCtx.positionOaa || null, throws: shadowCtx.throws || null, pitcher_hard_hit: knownNumber(shadowCtx.pitcherHardHit), }, }; } function freeze(ctx, prop = {}, shadow = null) { if (!ctx) return null; return { v: FREEZE_VERSION, as_of: ctx.as_of || null, // ── the join keys, as the RESOLVER saw them ── // Null here is the finding, not an omission: nothing sets these, so every // opponent-side and pitcher-side input below is null as a consequence. join: { opponent: prop.opponent || prop.opp_team || null, opposing_pitcher: prop.opposing_pitcher || null, }, // ── the raw inputs each factor consumed ── spray: sprayInputs(ctx.spray), position_oaa: ctx.positionOaa || null, bats: ctx.bats || null, throws: ctx.throws || null, pitcher_hard_hit: num(ctx.pitcherHardHit), platoon: platoonInputs(ctx.platoonSplits), // Carried from the graded row, never invented (A4). archetype: ctx.archetype || null, // ── WHAT WAS AVAILABLE BUT NOT USED ── // Recorded so a later order can measure the effect of plumbing the join keys // BEFORE it changes a served number. Never read by a factor. available: { team: prop.team || null, game_id: prop.game_id || null, game_date: prop.game_date || null, }, // SHADOW ONLY (A6). Never read by the served grade. would_fire: shadow || null, }; } /** * Re-run the factors from a FROZEN vector. * * This is the whole point of freezing inputs rather than a multiplier: an audit * can recompute and compare. Returns the same shape `hitsFactorMultiplier` does. */ function recompute(frozen, deps = {}) { if (!frozen) return null; const hf = deps.hitsFactors || require('./hitsFactors'); return hf.hitsFactorMultiplier({ spray: frozen.spray, positionOaa: frozen.position_oaa, bats: frozen.bats, throws: frozen.throws, pitcherHardHit: frozen.pitcher_hard_hit, platoonSplits: frozen.platoon ? { vl: frozen.platoon.vl ? { pa: frozen.platoon.vl.pa, atBats: frozen.platoon.vl.ab, hits: frozen.platoon.vl.hits } : null, vr: frozen.platoon.vr ? { pa: frozen.platoon.vr.pa, atBats: frozen.platoon.vr.ab, hits: frozen.platoon.vr.hits } : null, } : null, }); } module.exports = { freeze, recompute, shadowBlock, FREEZE_VERSION, sprayInputs, platoonInputs };