# Calibration hypothesis log

Open hypotheses about how the SONAR dome signal relates to election outcomes.
Each entry: when, what, evidence, next test, falsification.

---

## 2026-06-28 — General-election inversion (intra-party direction trusted; general direction inverted)

**Hypothesis:** X stance-balance dome direction is correct on intra-party
contests (primaries, runoffs) and anti-correlated with outcome on two-party
general elections.

**Evidence:**
- Intra-party: SC GOP gubernatorial runoff (2026-06-23), live prospective lock
  95d8554d, direction correct (called Wilson, Wilson won), MAE 12.8. n=1.
- General-election inversion: 13 independent 2024 general-election retrospective
  pairs (calibration_pair, after dropping the SC self-mirror) show stable
  consistently-negative k under logit-space LOO (range -0.120 to -0.039, all
  same sign). Negative-k inversion lifts winner-calls from 3/13 raw to 10/13
  compressed. Run via `sonar compression-eval`.
- Mechanism candidate: engaged-partisan X audience on general elections
  over-represents the side that loses. Not specifically validated; pattern
  is what matters.

**Next prospective test:** First two-party general-election lock prospectively
recorded after this date. Candidates: OH gov (2026-11-03), GA gov (2026-11-03),
GA Sen (2026-11-03). Lock methodology field should explicitly distinguish
inverted from raw — convention TBD; engineering note recommends
`stance_balance_impact_weighted_inverted` as the string until a formal flag
exists.

**Falsification conditions:**

The hypothesis predicts (a) raw direction is unreliable on generals AND
(b) sign inversion recovers direction. Both must hold for the hypothesis
to survive. The first three Nov 3 general-election prospective pairs test
both:

- Rejected if RAW direction is reliable (≥ 2 of 3 winners called correctly
  on uninverted dome prediction). The dome works directly; no inversion
  needed; the 2024 retrospective pattern was the artifact.
- Rejected if INVERTED direction is unreliable (≤ 1 of 3 winners called
  correctly on inverted dome prediction). The 2024 retrospective inversion
  was cycle-specific, not stable across cycles.
- Survives only when both raw fails (≤ 1 of 3) and inverted succeeds
  (≥ 2 of 3), with magnitude error on inverted within a reasonable band
  (operator defines "reasonable" at lock-time based on what AZ + any
  intervening pairs produced; do not pre-commit a threshold here).

**Confidence:** moderate on the retrospective pattern (stable across 13 LOO
folds is real), low on out-of-sample generalization (one cycle, one platform,
no holdout). Do NOT apply to live locks without forward validation.

---

## (template for future entries)

## YYYY-MM-DD — Title

**Hypothesis:**

**Evidence:**

**Next prospective test:**

**Falsification conditions:**

**Confidence:**

---

## 2026-06-29 — Infrastructure state update (session 30 marathon)

This is an infrastructure note under the general-election inversion hypothesis above. The hypothesis
itself is unchanged; what changed is the system's ability to record, surface, and score the test
cleanly. As of session 30's marathon build:

- **Contest.race_type** is a required, declared field on all 8 registered contests (PR
  contest-race-type-field, 2026-06-28) — intra_party for SC/AZ/OK, general for OH/GA/BR.
- **lock-prediction-from-dome** appends a `_raw_pending_inversion_test` suffix to the locked
  `methodology_version` on general-election markets (records the RAW dome reading, hash-distinct), and
  gained `--methodology-override` to lock an inverted prediction explicitly (PR 3).
- **calibration_pair.context** now carries `race_type` at seed time for live contests (PR 4);
  existing historical rows are backfilled by a script (PR 7) — dry-run verified against prod (15 pairs
  classify cleanly), NOT yet applied (operator's call).
- **sonar status** surfaces the current dome reading per market with the race_type warning, plus a
  7-day leader-share trajectory hint (PRs 1, 6). status._race_class (cycle_id substring heuristic) now
  only applies to historical rows with no recorded race_type and no live Contest; live/seeded data
  uses authoritative metadata (PRs 2, 4).
- **sonar dome-history** shows the trajectory of dome readings per market (PR 5). Spec correction: it
  reads the EXISTING `dome_daily_snapshot` table (full analytics bundle per day) — no new table or
  migration was needed.

The infrastructure to test the inversion hypothesis on the Nov 3 2026 general-election prospective
pairs is now in place. When the first general-election market is locked from the dome, the lock will
carry methodology `..._raw_pending_inversion_test`. The operator may also record a parallel inverted
lock at the same moment with `--methodology-override=stance_balance_impact_weighted_inverted`, to
score both directions independently against the certified result. Whether to do both is the
operator's call; the system supports either.

---

## 2026-07-09 — Logit-space magnitude compression on intra-party contests (fitted on SC, n=1)

**Hypothesis:** On INTRA-PARTY contests (primaries, runoffs) the raw impact-weighted stance-balance
dome reading gets the DIRECTION right but OVERSHOOTS the margin — the engaged-partisan X audience
amplifies the leader's share. Shrinking the predicted log-odds margin by a single positive factor
`k < 1` (logit-space magnitude compression, no intercept; `apply_compression` in
`sonar/calibration/compression_eval.py`) should reduce margin error WITHOUT flipping the winner. This
is the intra-party counterpart to the general-election DIRECTION-inversion hypothesis above, and is a
DISTINCT claim: a positive-k magnitude shrink on intra-party races, not a negative-k sign flip on
generals.

**Evidence:**
- Fitted on the single certified intra-party prospective pair: SC GOP runoff (2026-06-23, lock
  95d8554d). Raw Wilson 81.3 / Evette 18.7; certified 68.5 / 31.5. Closed-form least-squares
  `k = 0.5286` (the raw log-odds margin overshoots actual by ~1.9x).
- **n = 1, STATED:** with one pair the fit is exact on its own training point — applying `k` back to
  SC recovers 68.5 / 31.5 exactly — so this is a hypothesis to test prospectively, NOT validated
  calibration. It is echoed nowhere client-facing and never touches the lock.
- Direction is preserved: `k > 0` compression shrinks toward 50/50 and cannot cross it, so it cannot
  change a winner call.

**Next prospective test:** us_arizona GOP gov primary, **2026-07-21** (lock 72bda729, raw Biggs 91.9 /
Schweikert 8.1). The pre-registered shadow — computed NOW, before the result, stored write-once in
`calibration/shadow_adjustments.json` labeled `unvalidated_shadow` — is **Biggs 78.3 / Schweikert
21.7** (`k = 0.5286`). For reference the NPI polling benchmark is 72.7 / 27.3, so the shadow sits
between the raw overshoot and the poll. On 2026-07-21, `sonar score-cycle us_arizona
us_az_gov_r_primary_2026` reports raw-vs-actual AND shadow-vs-actual side by side.

**Falsification conditions (symmetric — pre-registered before the result):** with raw = 91.9, shadow =
78.3, let `actual` = the two-way-normalized certified Biggs share.
- **REJECTED — overshrink** if `actual >= 85` (above the raw/shadow midpoint): the raw was ~right and
  the compression pulled the number too far toward the middle (`shadow_mae > raw_mae`). `k` is too
  small / the overshoot premise is wrong.
- **REJECTED — undershrink or wrong form** if `actual <= 71` (at or below the shadow AND the poll):
  the true margin is even more compressed than `k = 0.5286` produced, so this fitted `k` is wrong even
  if the compression DIRECTION is right (`shadow_mae >= raw_mae` also rejects outright — the shadow was
  no closer than raw).
- **SURVIVES** only if `71 < actual < 85` (near the shadow) AND the winner is still called AND
  `shadow_mae < raw_mae`. A single prospective cycle cannot validate a transform — survival means
  "keep testing on the next intra-party lock," not "adopt."

**Confidence:** low. n=1 fit, one platform, one pending out-of-sample test. The shadow is unvalidated
by construction; it never touches the lock, `locks.log`, `certified_results`, or any client-facing
number until it has beaten raw across enough scored cycles to earn calibration status — per the
standing constraint that all client-facing calibration claims derive solely from the recorded ledger.

---

## 2026-07-09 — Pre-registration addendum: shadow promotion rule + AZ interpretation policy

This section is pre-registered (written before the 2026-07-21 AZ result exists) and anchored together
with `calibration/shadow_adjustments.json` via OpenTimestamps — see `calibration/ANCHORS.md` for the
`.ots` proof files and the SHA-256 digests they attest. It governs how the logit-space magnitude
compression shadow (entry above) may ever become a live adjustment, and how each possible AZ outcome
is read. Both are committed BLIND to the result so the reading cannot be chosen after the fact.

### Shadow promotion rule (pre-committed, mechanical — no operator discretion)

A shadow transform in `calibration/shadow_adjustments.json` (label `unvalidated_shadow`) is promoted
to a **live adjustment** — one that may be echoed alongside the raw headline — **only** after it has
cleared this bar, and promotion is then automatic (not an operator judgment call):

> **3 prospective, in-regime certified pairs**, each of which the shadow **wins on margin error**
> **without flipping a correct winner call.**

Definitions, pinned so the rule is mechanical:

- **prospective** — the pair's lock was recorded before its certified result existed (a retrospective
  fit like the SC training point does NOT count; the SC pair the shadow was fitted on is excluded).
- **in-regime** — the pair's `Contest.race_type` matches the transform's fitting regime. This
  compression shadow was fitted on an intra-party (runoff) pair, so its regime is
  `intra_party` (primaries + runoffs). A general-election pair does not count toward promoting an
  intra-party shadow, and vice-versa.
- **wins on margin error** — `shadow_margin_error < raw_margin_error` on that pair, where
  `margin_error = |predicted_two_way_margin − certified_two_way_margin|` and
  `margin = share_a − share_b`. Ties (`>=`) do NOT count as a win.
- **without flipping a correct winner call** — on any pair where **raw** called the winner correctly,
  the **shadow** must also call the winner correctly. (For positive-`k` compression this is structurally
  guaranteed — a shrink toward 50/50 cannot cross it — but the clause is stated generally so it also
  governs any future transform that could move a call.)

Anything short of 3 qualifying pairs — fewer than 3 recorded, or any recorded in-regime pair the
shadow does NOT win, or any winner-flip — keeps the transform at `unvalidated_shadow`. A failing pair
does not reset a counter to zero, but it also does not count toward the 3; promotion requires 3
**wins**, and a run that includes losses is evidence against the transform that the operator must weigh
before continuing to test it. Promotion, when the 3rd qualifying win is recorded, is mechanical: the
label flips and the adjustment becomes echo-eligible; it still never overwrites the raw headline (both
numbers are always shown) and still never touches a lock, `locks.log`, or `certified_results`.

### AZ interpretation policy, pre-written for all four outcomes (2026-07-21)

Let `actual` = the two-way-normalized certified **Andy Biggs** share. Raw = 91.9, shadow = 78.3, NPI
poll = 72.7. The reading of the AZ cycle is fixed now, blind to the result, by this decision tree:

1. **Everyone misses the winner** — **Schweikert wins** (`actual < 50`, so raw AND shadow both called
   the wrong winner). This is the most serious outcome: it is a **direction** failure on an intra-party
   contest, which is the premise the whole intra-party program rests on (compression only ever claimed
   to fix magnitude, not direction, so a magnitude transform cannot rescue this). Reading: **open a
   pool-composition inquiry** before any further intra-party lock — run the item-5 diagnostics
   (N_decided trajectory, confirmation-method breakdown, top-3 account concentration, LOO top-account
   ablation) to ask whether the decided pool was captured by a few heavy or unrepresentative accounts.
   The compression hypothesis is moot for this cycle (no margin credit for a wrong direction). Published
   at the same volume as any hit.

   If **Biggs wins** (`actual > 50`), branch on magnitude:

2. **Raw beats shadow** — `actual >= 85` (above the raw/shadow midpoint). Reading: **compression
   weakened.** The raw overshoot premise did not hold this cycle; the compression pulled the number too
   far toward the middle (`shadow_mae > raw_mae`). This pair is a **loss** for the shadow — it does NOT
   count toward the 3 promotion pairs, and `k = 0.5286` is too small or the overshoot premise is wrong.
3. **Shadow beats raw** — `71 < actual < 85` (near the shadow) with `shadow_mae < raw_mae` and the
   winner still called. Reading: **compression supported this cycle.** This pair counts as **1 of the 3**
   in-regime prospective wins the promotion rule requires. **Promotion is still gated** — one win is not
   three; the shadow stays `unvalidated_shadow` and testing continues on the next intra-party lock.
4. **NPI beats both** — `actual <= 71` (at or below the poll, so the polling benchmark is closer than
   both SONAR raw and SONAR shadow). Reading: **first recorded polling loss** — a cycle where the public
   poll beat the dome. It is **published at equal volume to a win** (misses publish exactly like hits;
   this is the standing honesty constraint). Mechanistically the true margin was even more compressed
   than `k = 0.5286` produced, so this fitted `k` is wrong even if the compression direction is right;
   the shadow does NOT earn a promotion pair (`shadow_mae >= raw_mae` in the tie region also denies it).

Boundary note: the bands are closed as written (`>= 85`, `<= 71`) matching the compression entry's
falsification conditions above; the narrow open interval `71 < actual < 85` is the only region that
credits the shadow. `score-cycle` prints raw-vs-actual and shadow-vs-actual side by side on scoring
day, so which branch fired is read directly off the recorded numbers, not asserted.

---

## 2026-07-09 — Pre-registered lock policy: T-7 density auto-lock (no operator discretion)

Pre-registered (anchored with this file) to remove operator discretion over *when* a market locks, so
lock timing cannot be chosen against the eventual result. Mirrored operationally in
`docs/OPERATIONS.md` §4 (Calibration workflow).

**Policy.** A market **locks automatically at T-7** (7 days before its election date) **if and only if
its headline decided pool clears the density threshold below on that date.** No earlier, no later, no
judgment call: at T-7 the pool either clears or it does not.

**Density threshold — derived from existing gate constants (not invented for this policy):**

- **Cap-feasibility floor: `>= 21` decided ACCOUNTS.** This is
  `analytics.min_decided_accounts_for_ceiling(0.05) == 21` — the smallest decided-account count at
  which the 5% anti-concentration share cap can actually bind. Below it the cap silently no-ops and a
  single account can hold ~100% of a thin pool (`cap_infeasible`), so the headline is not defensible.
- **AND confidence tier `>= moderate`: `>= 50` confirmed accounts** in the headline pool. This is
  `support_score_series.CONFIDENCE_MODERATE_MIN == 50` — the "low" tier (10-49) is too thin to stake a
  prospective lock on. (`CONFIDENCE_HIGH_MIN == 200` is not required — moderate is the lock floor.)

Both conditions are read off the same headline the series/score use, so "clears the threshold" is a
mechanical check (see the item-5 pool diagnostics: `n_decided_accounts` and `cap_infeasible`), not an
assessment. A market that does NOT clear by T-7 is **not** auto-locked; it may still be carried as
inconclusive or, after its result, recorded accuracy-only — but it earns no prospective calibration
pair. This closes the "lock only the markets that happen to look good near the result" degree of
freedom.

**Scope + status.** This governs *timing/eligibility only*; it does not change the lock *methodology*
(general-election contests still carry the `_raw_pending_inversion_test` suffix per the inversion
hypothesis above). The already-recorded locks (SC 95d8554d, AZ 72bda729) are grandfathered — the
policy binds prospective locks from this date. Implementation of an automatic T-7 scheduler is a
tracked build item; until it lands the operator applies this threshold by hand at T-7, with NO
discretion to deviate (the point of anchoring it is that deviation is now visibly a policy breach).

---
