# -*- coding: utf-8 -*-
"""
make_pages.py — generates web/2026-battleground-predictions.html from the register.

Written 2026-08-23. The Phase 3 review found the page still said "sixteen predictions"
after the register had moved to nineteen. Counts, the confidence mean and the scorecard
are now COMPUTED FROM THE LIST BELOW, never typed. Same principle as money_analysis.py:
a number that lives only in prose goes stale without telling you.

Run: python3 scripts/make_pages.py
"""
import glob, json, os, re, sys, html

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import figures  # noqa: E402

# ─────────────────────────────────────────────────────────────────────────────
#  No number in this file may be a measurement.
#
#  Round-two review found that every published figure here was a string literal —
#  the third recurrence of the prose-number failure, and A5's own basis number was
#  typed in on the day the rule forbidding it was written. Measurements now arrive
#  as [[key|spec]] placeholders resolved from figures.py at build time, and fmt()
#  raises on an unknown key rather than emitting it.
#
#  Three kinds of number are still allowed to be literal, and only these:
#    THRESHOLDS   — 0.60, 0.20, 0.05, 1.5. Part of the claim, not a measurement.
#    QUOTATIONS   — 0.82, 0.70, 11.62. What was published, quoted as history.
#    COUNTS FIXED BY THE REGISTER — 59 districts, 115 candidates. Also computed and
#                   cross-checked against figures.py by verify() below.
# ─────────────────────────────────────────────────────────────────────────────

_PH = re.compile(r"\[\[([a-zA-Z0-9_.]+)(?:\|([^\]]+))?\]\]")


_COLOR_SEL = re.compile(r"([^{}@]+)\{[^{}]*(?<![-\w])color\s*:", re.S)
# One invalid selector in a list kills the WHOLE rule in every browser — which is
# how a comment scraped into this generator silently discarded the entire print
# block while the coverage check "verified" it (Phase 3 exit gate, run 2).
def _selector_is_valid(sel):
    """Close-enough CSS selector validation: no token may start with a digit, parens
    must be balanced and only follow a pseudo-class name, and only selector
    characters may appear. Crude regexes failed in BOTH directions here — passing
    comment prose, then rejecting :not([open]) — so this is procedural."""
    sel = sel.strip()
    if not sel or sel[0].isdigit():
        return False
    if sel.count("(") != sel.count(")"):
        return False
    for m in re.finditer(r"\(", sel):
        if not re.search(r":[a-zA-Z-]+$", sel[:m.start()]):
            return False
    for tok in re.split(r"[\s>+~]+", sel):
        if tok and (tok[0].isdigit() or not re.match(r"^[-\w.#:*\[\]='\"()]+$", tok)):
            return False
    return True




def generated_print_overrides(css):
    """Every selector in this page's CSS that sets a text colour gets a print
    override forcing near-black on white. Generated, so a new selector cannot be
    forgotten — the failure mode that shipped 1.12:1 print text twice. Comments are
    stripped BEFORE scanning and every candidate is validated, because one malformed
    "selector" voids the whole rule in the browser."""
    clean = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
    sels = []
    for m in _COLOR_SEL.finditer(clean):
        for one in m.group(1).strip().split(","):
            one = " ".join(one.strip().split("\n")[-1].split())
            if one and _selector_is_valid(one) and one not in sels:
                sels.append(one)
    if not sels:
        raise SystemExit("print-override generator produced zero selectors — refusing")
    body = ",\n      ".join(sels)
    return ("\n    /* GENERATED print overrides — one per colour-setting selector above */\n"
            "    @media print {\n      " + body +
            " { color: #111111 !important; background: transparent !important; }\n    }\n")


def fmt(text, F):
    """Resolve [[key|spec]] against the computed figure dict. Unknown key is fatal."""
    def sub(m):
        k, spec = m.group(1), m.group(2)
        if k not in F:
            raise KeyError(f"make_pages: no computed figure named {k!r} — refusing to publish it")
        v = F[k]
        return format(v, spec) if spec else str(v)
    return _PH.sub(sub, text)

HERE = os.path.dirname(os.path.abspath(__file__))
WEB = os.path.normpath(os.path.join(HERE, "..", "web"))
DATA = "/data/2026-battleground"   # where public_data/ is deployed

# id, group, status, conf, plain, formal, fails, note
# status: "open"   — a live forecast
#         "lost"   — registered on a basis figure since found wrong; frozen; will miss
#         "frozen" — superseded by an honest reprice but left standing and scored
#         "self"   — a bet against something I have already published
# CONCEDED: registered as lost on an ARGUMENT, not on a computed figure. The frozen data
# cannot settle them — A3 needs a chair / ranking-member field the index does not code,
# A4 needs a 2024-cycle FEC extract that is not in the frozen file — so they are conceded
# rather than measured, and the page must not print the two in the same words.
CONCEDED = {"A3", "A4"}
WORD = {0: "None", 1: "One", 2: "Two", 3: "Three", 4: "Four", 5: "Five", 6: "Six"}

P = [
("A5","A","retracted",88,
 "<b>I no longer believe this claim, and I am not allowed to delete it.</b> I said the pattern was mostly a step rather than a slope. Checked properly, the slope moves further than the step does &mdash; across the full span of the longest career on the chart. For anyone with fewer than about [[fit.crossover|.0f]] years, which is everyone here but one, the step is the larger; both are true, and the regression section says which applies to whom.",
 "The tenure&ndash;money association is mostly a step, not a slope: at the October refresh the pooled<a class='gl' href='#g-pooled' aria-label='what &quot;pooled&quot; means' title='what &quot;pooled&quot; means'>?</a> Fisher z exceeds the incumbents-only Fisher z by at least 0.20.",
 "the gap is under 0.20 z-units.",
 "Registered 2026-08-23, retracted in substance the same day. Currently z = [[money.z_pool|.4f]] pooled against [[money.z_inc|.4f]] incumbents-only, a gap of <b>[[money.dz|.4f]]</b>, so it will almost certainly score a hit &mdash; and that is the problem. <b>This claims a size (&ldquo;mostly a step&rdquo;) and tests it with a correlation, which cannot measure size.</b> The fitted answer is above: the step is [[fit.b1_step|.2f]] points, the slope [[fit.b2_slope|.3f]] a year. <b>A hit here is evidence about the weakness of the test I built, not about the claim.</b> It stays scored because my own rule forbids withdrawing a registered prediction, and a rule that binds only until it is inconvenient is not a rule. It is also not independent of A1a&prime;: across [[dep.N]] bootstrap resamples, A5 holds in every resample in which A1a&prime; holds (the converse is not true &mdash; A5 holds in many where A1a&prime; fails), so a hit here adds nothing A1a&prime; has not already said. (Two intermediate errors I made while working this out &mdash; a mis-cut range, then its mis-correction &mdash; are in the source log; the fit above settles it.)"),

("A1a′","A","open",85,
 "Among candidates who have served in Congress, the link between years served and corporate money should still be there in October &mdash; weak, but there.",
 "Incumbents-only Pearson r stays within 0.20 Fisher-z <a class='gl' href='#g-fisher-z' aria-label='what &quot;Fisher z&quot; means' title='what &quot;Fisher z&quot; means'>?</a> units of z = [[money.z_inc|.4f]] &mdash; the z form governs &mdash; which is r in [[[money.r_band_lo|.4f]], [[money.r_band_hi|.4f]]].",
 "|z<sub>Oct</sub> &minus; [[money.z_inc|.4f]]| &gt; 0.20.",
 "Registered 2026-08-23. Basis r = [[money.r_inc|.4f]], n = [[money.n_inc]], p = [[money.p_inc|.3f]], bootstrap 95% CI <a class='gl' href='#g-ci' aria-label='what &quot;bootstrap 95% CI&quot; means' title='what &quot;bootstrap 95% CI&quot; means'>?</a> [[[money.boot_lo|.3f]], [[money.boot_hi|.3f]]]. <b>Corrected 2026-08-23:</b> this card first printed the band as r in [0.12, 0.48]; both of those rounded edges fall <em>outside</em> the rule they were meant to state, so the four-decimal figures are printed instead. <b>Declared so it cannot be claimed as a triumph later:</b> this is an easy prediction and I am pricing it as one. October is the same [[money.n_inc]] people with one more quarter of receipts, not an independent draw. It tests continuity, not the finding."),

("A1b′","A","open",88,
 "The overall figure &mdash; the one that mixes challengers in &mdash; should also hold roughly steady, and stay positive in both parties.",
 "Pooled r stays within 0.20 Fisher-z of z = [[money.z_pool|.4f]] &mdash; the z form governs &mdash; and the sign stays positive within both parties.",
 "the band is missed, <em>or</em> either party&rsquo;s r drops to zero or below.",
 "Registered 2026-08-23. Pooled r = [[money.r_pool|.4f]] (n = [[money.n_rows]]); DEM [[money.r_pool_DEM|.4f]], REP [[money.r_pool_REP|.4f]]. <b>Permanently labelled:</b> this number contains the challenger/incumbent contrast. It is context for the card above, not a finding of its own."),

("A3′","A","open",78,
 "Long-serving members pull more corporate money even when you set the committee chairs aside &mdash; but only a little more. Stripping out the gavels should barely move the number.",
 "Among incumbents, controlling for chair / ranking-member status, tenure&rsquo;s partial correlation <a class='gl' href='#g-partial' aria-label='what &quot;partial correlation&quot; means' title='what &quot;partial correlation&quot; means'>?</a> falls in [0.10, 0.45].",
 "the partial correlation lands outside [0.10, 0.45].",
 "Registered 2026-08-23. One binary control is not an identification strategy<a class='gl' href='#g-identification' aria-label='what &quot;identification strategy&quot; means' title='what &quot;identification strategy&quot; means'>?</a>. Majority-party status, committee assignment, seat safety, district industry mix and total receipts are uncontrolled. No causal claim is made or will be made from this."),

("A4′","A","open",65,
 "The same weak pattern should show up in the 2024 cycle. If it only exists in one cycle it isn&rsquo;t a pattern.",
 "Rebuilt on the 2024 cycle, the incumbents-only correlation falls in [0.10, 0.55].",
 "outside [0.10, 0.55].",
 "Registered 2026-08-23. Largely the same members appear in both cycles, so this is not an independent replication and will not be described as one. The band is wide because I do not know; pretending to a tighter one would be the same mistake twice."),

("A2","A","open",65,
 "This should be a <b>Congress</b> pattern, not a swing-seat one. Check every district in the country and it should look about the same.",
 "Extended to all 435 districts, the incumbents-only correlation lies within 0.20 Fisher-z units of the battleground incumbents-only figure computed on the same October data.",
 "|&Delta;z| &gt; 0.20.",
 "The two samples are nested<a class='gl' href='#g-nested' aria-label='what &quot;nested samples&quot; means' title='what &quot;nested samples&quot; means'>?</a>, so this is not a two-independent-sample comparison and no p-value<a class='gl' href='#g-p' aria-label='what &quot;p-value&quot; means' title='what &quot;p-value&quot; means'>?</a> is claimed. It matters even if it holds, because it changes the headline: &ldquo;battleground incumbents&rdquo; and &ldquo;Congress&rdquo; are different stories."),

("A1a","A","lost",92,
 "I said the link between years served and corporate money would stay strong among candidates who have served. It was never that strong. This one is gone.",
 "Among incumbents only, the Pearson correlation between years of congressional service and corporate/trade-PAC share of receipts is at or above 0.60 at the October FEC refresh.",
 "incumbents-only r &lt; 0.60.",
 "Registered 2026-08-22 at 92% (v1, earlier that day, had priced its parent A1 at 80%; re-priced in v2 with the reason stated there) on a basis figure of 0.70. The frozen data gives <b>[[money.r_inc|.3f]]</b>. <b>Not edited.</b> The 0.82 was a claim about data in hand and gets corrected; this was a forecast, and rewriting a forecast after learning it loses is the exact move this page exists to prevent."),

("A1b","A","lost",88,
 "The same error, applied to the figure that counts everyone rather than just those who have served. I said it would land between 0.70 and 0.90; it is [[money.r_pool|.3f]].",
 "Pooled across all candidates, r falls in [0.70, 0.90] and the sign is positive within both parties.",
 "r outside [0.70, 0.90], or the sign flips in either party.",
 "Registered 2026-08-22 at 88%. The frozen data gives <b>[[money.r_pool|.3f]]</b>. The party-sign conjunct holds; the band does not. Scored as a miss in the same denominator as everything else."),

("A3","A","lost",65,
 "I said the pattern would survive stripping out committee chairs. It can&rsquo;t &mdash; there wasn&rsquo;t enough of it to survive.",
 "Controlling for chair / ranking-member status, tenure&rsquo;s partial correlation with corporate-PAC share stays at or above 0.50.",
 "partial r &lt; 0.50.",
 "Registered 2026-08-22 at 65%. A partial correlation exceeds a zero-order<a class='gl' href='#g-zero-order' aria-label='what &quot;zero-order&quot; means' title='what &quot;zero-order&quot; means'>?</a> correlation of [[money.r_inc|.3f]] only under <em>suppression</em> <a class='gl' href='#g-suppression' aria-label='what &quot;suppression&quot; means' title='what &quot;suppression&quot; means'>?</a> &mdash; the case where a third variable is hiding a stronger relationship than the raw numbers show. Possible, but unlikely. Left frozen. <b>Conceded, not measured:</b> this figure has never been computed and cannot be from the frozen data &mdash; the index codes no chair or ranking-member field, so the control this card names does not exist to control for. What is written above is an argument that it will miss, not a result showing that it did. It is counted as a miss anyway. Its replacement A3&prime; carries the same control and is registered as open, which means the field has to be coded before 31 October or, under the 1 September ruling below, A3&prime; scores a miss."),

("A4","A","lost",70,
 "I said the tenure&ndash;money link would show up in the 2024 election cycle at roughly the strength I had published for 2026. Since that 2026 figure was wrong, this one was too.",
 "Rebuilt on the 2024 cycle, the incumbents-only correlation falls in [0.50, 0.85].",
 "outside [0.50, 0.85].",
 "Registered 2026-08-22 at 70%. Mostly the same members as 2026, so mostly the same problem. Left frozen. <b>Conceded, not measured:</b> the frozen finance file is the 2026 cycle only, so the 2024 rebuild this card is scored on does not exist yet and no figure has been computed. Conceding it is a judgement that it will miss, not a measurement that it did. It is counted as a miss anyway, and the 2024 extract has to be pulled before 31 October or, under the 1 September ruling below, its replacement A4&prime; scores a miss."),

("B1","B","self",80,
 "Having been on television doesn&rsquo;t make a politician write more simply. I published the opposite in August and then disproved it with a control I&rsquo;d written down in advance. This bets the disproof was right.",
 "Across the 59-race sweep, candidates with a professional media background do not differ from those without on social-register reading grade<a class='gl' href='#g-reading-grade' aria-label='what &quot;reading grade&quot; means' title='what &quot;reading grade&quot; means'>?</a>, and the difference is smaller than 1.0 reading grades (an equivalence test <a class='gl' href='#g-tost' aria-label='what &quot;equivalence test&quot; means' title='what &quot;equivalence test&quot; means'>?</a>, margin 1.0 reading grades).",
 "a two-sided difference at p &lt; 0.05 <em>in either direction</em>, or equivalence is not established at the stated margin.",
 "The first version of this card had two faults. It read &ldquo;no difference showed up&rdquo; as &ldquo;there is no difference&rdquo; <a class='gl' href='#g-null' aria-label='what the null and failing to reject mean' title='what the null and failing to reject mean'>?</a> &mdash; which a small sample hands you for free. And its fail condition caught a difference in only one direction, so a difference the other way would have been scored a hit. Both are corrected above."),

("B2","B","open",70,
 "A press release talks <em>about</em> a politician; the quote inside it talks <em>as</em> them. Nobody refers to themselves in the third person inside their own quotation.",
 "Among the first 100 written attributed quotations collected in the sweep, not one contains a third-person self-reference.",
 "<b>any single instance</b>, in any quotation, of any length.",
 "A black-box Beta(1,1) posterior on 23-for-23<a class='gl' href='#g-laplace' aria-label='what &quot;black-box Beta(1,1) and 23-for-23&quot; means' title='what &quot;black-box Beta(1,1) and 23-for-23&quot; means'>?</a> gives only <b>19%</b> for 100 more. I am departing from it because press releases quote their principal in the first person by construction &mdash; a mechanism, not a streak. If this misses, the mechanism argument was wrong, and that is the more interesting failure."),

("B3","B","self",65,
 "I said younger politicians shift their voice more between formal statements and social posts. I now think that was noise.",
 "The under-55 / 55-and-over register-gap<a class='gl' href='#g-register-gap' aria-label='what &quot;register gap&quot; means' title='what &quot;register gap&quot; means'>?</a> split fails to replicate, and the difference is smaller than 1.5 reading grades (an equivalence test <a class='gl' href='#g-tost' aria-label='what &quot;equivalence test&quot; means' title='what &quot;equivalence test&quot; means'>?</a>, margin 1.5 reading grades).",
 "anything other than: the split fails at p &ge; 0.05 two-sided AND equivalence is established at margin 1.5. Inconclusive is a MISS. <em>(Corrected in v4 &mdash; the earlier falsifier<a class='gl' href='#g-falsifier' aria-label='what &quot;falsifier&quot; means' title='what &quot;falsifier&quot; means'>?</a> omitted the equivalence conjunct<a class='gl' href='#g-conjunct' aria-label='what &quot;conjunct&quot; means' title='what &quot;conjunct&quot; means'>?</a> its own claim carried, so an underpowered test could make the claim false while scoring a hit.)</em>",
 "The second bet against my own published work. It has already been downgraded once from a smooth gradient to a coarse split, it collapses when the oldest member is removed, and it disappears under an alternative measure of the same thing."),

("B4","B","open",55,
 "Cabinet secretaries and senators change how they write more sharply than House candidates do &mdash; more formal in a written statement, plainer in a social post. The gap between the two should be at least three and a half school grades of reading difficulty for them, and no more than two and a half for the candidates.",
 "National office-holders&rsquo; written-quote-to-social gap has a median of at least 3.5 reading grades, against a candidate median at or below 2.5.",
 "office-holder median &lt; 3.5, or candidate median &gt; 2.5.",
 "The weakest prediction here and flagged as such: n<a class='gl' href='#g-n' aria-label='what &quot;n&quot; means' title='what &quot;n&quot; means'>?</a> = 4 at registration. <b>Instrument<a class='gl' href='#g-instrument' aria-label='what &quot;instrument&quot; means' title='what &quot;instrument&quot; means'>?</a> defect declared 2026-08-24 (v5, Ruling 2):</b> the frozen measurement script pooled samples with a bare newline join, and [[style.n_unterminated]] of [[style.n_samples]] samples &mdash; [[style.n_unterminated_social]] of them social &mdash; end without terminal punctuation, so sentences merged across boundaries and the candidate register-gap median came out [[style.cand_median_shipped|.2f]]. Corrected, it is <b>[[style.cand_median_fixed|.2f]]</b> &mdash; through the 2.5 threshold. <b>Second instrument defect, declared 2026-08-30:</b> that median pools [[style.n_gap_candidates]] candidates, of whom only <b>[[style.n_gap_compliant]]</b> meet the frozen protocol's own corpus minimums (8 samples per register; 600 words official, 300 social). The other [[style.n_gap_below_minimum]] are cells the protocol says must be reported as insufficient corpus, never as a number &mdash; and here they bound a comparison the protocol scores. Restricted to compliant cells the median is [[style.cand_median_compliant|.2f]]: the verdict does not change and the number does. (An earlier wording of this note applied the social floor to both registers and counted [[style.n_gap_compliant_loose]] compliant &mdash; understating the problem by half; the wording is in the source log.) B4 is scored on the corrected pipeline with both values published, which on today&rsquo;s data makes it an <b>expected miss</b>. The confidence stays 55%: the instrument was wrong, the bet was the bet."),

("C1","C","open",78,
 "Facebook won&rsquo;t let anyone collect posts at scale, so candidates who mainly use Facebook can&rsquo;t be measured at all. Not a gap I can close &mdash; a limit I have to declare.",
 "At least 15% of the 115 candidates have no collectable social register<a class='gl' href='#g-social-register' aria-label='what &quot;social register&quot; means' title='what &quot;social register&quot; means'>?</a> under the frozen protocol.",
 "under 15%.",
 ""),

("C2","C","self",60,
 "The platform I can collect from most easily leans left, so my own data probably over-samples Democrats. If that&rsquo;s true it&rsquo;s a flaw in my work, and it gets the same billing as any other finding.",
 "Among candidates whose social cells<a class='gl' href='#g-cells' aria-label='what &quot;social cells and the minimum&quot; means' title='what &quot;social cells and the minimum&quot; means'>?</a> meet the minimum, the odds ratio <a class='gl' href='#g-odds-ratio' aria-label='what &quot;odds ratio&quot; means' title='what &quot;odds ratio&quot; means'>?</a> of collectability, Democrat versus Republican, is at least 1.30. The roster baseline is [[roster.n_dem]] D to [[roster.n_rep]] R.",
 "OR &lt; 1.30, or it runs the other way.",
 "v1 stated a bare ratio against no baseline. <b>Corrected 2026-08-24 (v5, Ruling 3):</b> the baseline first published was 57:57, &ldquo;exactly 1.00&rdquo;. Kevin Kiley left the Republican Party on 9 March 2026 and filed as an independent; the roster now reads [[roster.n_dem]] D / [[roster.n_rep]] R / [[roster.n_ind]] I, baseline odds 1.018. The party-status flag was in the master coding all along &mdash; the roster column had fossilised the Phase 1 source. Claim, threshold and confidence unchanged."),

("D1","D","open",88,
 "At least two more of these 59 races will change shape before Election Day &mdash; someone withdraws, gets replaced, switches party or suspends. (v1 priced this at 75%; re-priced to 88% in v2 the same day, before any data, with the reason stated there.)",
 "At least two more of the 59 races materially change before Election Day.",
 "fewer than two.",
 "The index&rsquo;s own record already holds four material changes absorbed while it was being built &mdash; a March party switch, two July suspensions, an August replacement nominee. If this holds it is not a finding, it is a maintenance requirement: any published index must timestamp its roster and re-verify at publication."),

("E1","E","open",55,
 "Seven candidates have no age in the index because I couldn&rsquo;t establish one. I think general-election coverage will print two to four of them.",
 "Between two and four of the seven unestablished ages become establishable by Election Day, under the published evidentiary standard.",
 "fewer than two, or more than four.",
 "Those seven have been searched four times over with different tools, all converging on the same names. At least one of the seven has an age listed on data-broker sites. Those aren&rsquo;t sources, so the cells stay blank."),

("E2","E","open",57,
 "One Ohio candidate&rsquo;s birthday falls either just before or just after Election Day, and I cannot tell which, so I cannot say whether the right age is the older or the younger one. The range of possible birth dates splits 182 days to 139 in favour of older, so that is what I am betting.",
 "If the flagged Ohio case resolves, it resolves to the older value.",
 "it resolves to the younger value. If it does not resolve, this is unscoreable &mdash; v1 left that outcome unhandled.",
 "The 182-to-139 split assumes birth dates are uniform across the year. US births are seasonal by roughly &plusmn;5&ndash;8%, which moves this to somewhere in <b>53&ndash;61%</b>. Publishing 57% to the point without that caveat was overprecision. <b>Named 2026-08-30:</b> the flagged case is <b>Carey Coleman (OH-13)</b>. Register v1 named him in its own heading (&ldquo;Coleman&rsquo;s asterisk resolves to 67, not 66&rdquo;); the naming was lost in the v2 card rebuild, which left the subject &mdash; and which four candidates E3 is about &mdash; to be chosen in October, after the answer was visible. Restored here: E2&rsquo;s subject is fixed in the binding spec; E3&rsquo;s four are named in the v1 register and now on the E3 card (the spec carries only E3&rsquo;s scoring map), so both are scoreable by someone other than their author. (An earlier wording of this note blamed &ldquo;every published version&rdquo;, which the timestamped v1 disproves; the wording is in the source log.) The claim, threshold and confidence are unchanged."),

("E3","E","open",70,
 "The other four ages carrying a one-year question mark &mdash; Schultz, Vo, Knickerbocker and Flint, named in the v1 register so the set cannot be chosen after the fact &mdash; stay uncertain. Election coverage tends to reprint the age it already printed.",
 "The other four uncertainty markers do not resolve.",
 "two or more resolve.",
 """<b>Declared 2026-08-30, after an independent review:</b> this falsifier is looser than the claim it polices<a class='gl' href='#g-conjunct' aria-label='what &quot;conjunct&quot; means' title='what &quot;conjunct&quot; means'>?</a>. The claim says none of the four resolve; the fail condition needs
 two. If exactly one resolves, the claim is false and this card is scored a hit anyway. It is
 registered and I do not get to rewrite it, so the defect is printed instead &mdash; and if that
 is how October lands, the hit should be read as worth nothing."""),

("F1","F","open",88,
 "Something I&rsquo;ve already published gets retracted or downgraded in October. This isn&rsquo;t modesty, it&rsquo;s a base rate &mdash; every round of this project so far has retracted something. (v1 priced this at 80%; re-priced to 88% in v2 the same day, before any data.)",
 "The October publication contains at least one item explicitly labelled RETRACTED or DOWNGRADED, referencing a claim published before 2026-09-01.",
 "no such labelled item.",
 "<b>Declared conflict:</b> this outcome is partly under my control. It is scored on the published artifact rather than on my judgment, which is the most externally checkable form available &mdash; but it is not a clean forecast and should not be read as one. The Laplace rule <a class='gl' href='#g-laplace' aria-label='what &quot;the Laplace rule, Beta(1,1) and black-box&quot; means' title='what &quot;the Laplace rule, Beta(1,1) and black-box&quot; means'>?</a> on [[laplace.rounds]]-of-[[laplace.rounds]] prior rounds gives [[laplace.pct|.1f]]%."),

("F2″","F","open",93,
 "Across all these predictions, I expect to turn out more confident than I was right &mdash; and since four of them are already dead, this is close to arithmetic rather than a forecast.",
 "The log-odds<a class='gl' href='#g-logodds' aria-label='what &quot;log-odds&quot; means' title='what &quot;log-odds&quot; means'>?</a> calibration shift &acirc; <a class='gl' href='#g-calibration' aria-label='what &quot;calibration and the shift a-hat&quot; means' title='what &quot;calibration and the shift a-hat&quot; means'>?</a> comes out negative: I am overconfident on average.",
 "&acirc; &ge; 0.",
 "Registered 2026-08-23 as the honest reprice of F2&prime;, which sits at 60% below. Four predictions here are near-certain misses before any data arrives, so overconfidence is close to arithmetically guaranteed. <b>This is not a forecast, it is a consequence.</b> Both are scored. The 33-point gap is the size of the cheap hit I chose to disclose rather than delete."),

("F2′","F","frozen",60,
 "The same claim as the card above &mdash; that I run overconfident &mdash; but priced at 60% before I knew [[C.n_lost_w]] predictions were already lost. It will almost certainly be scored a hit, and cheaply. Left standing anyway.",
 "The log-odds calibration shift &acirc; comes out negative.",
 "&acirc; &ge; 0.",
 "Registered 2026-08-22 at 60%. It will almost certainly hit, and it will hit cheaply. Frozen rather than deleted, because deleting the easy version and keeping the honest one would still leave me choosing which of my own bets counts."),

("G1","G","open",62,
 "Republicans in these races include more veterans than Democrats do &mdash; [[roster.G1.R_pct|.1f]]% against [[roster.G1.D_pct|.1f]]%, or [[roster.G1.R_yes]] of [[roster.G1.R_n]] against [[roster.G1.D_yes]] of [[roster.G1.D_n]] &mdash; and this bets the gap does not clear the usual test of significance<a class='gl' href='#g-significance' aria-label='what &quot;significance, and two-sided&quot; means' title='what &quot;significance, and two-sided&quot; means'>?</a>.",
 "The difference in veteran share between the two parties does not reach significance<a class='gl' href='#g-significance' aria-label='what &quot;significance, and two-sided&quot; means' title='what &quot;significance, and two-sided&quot; means'>?</a> when extended beyond the battleground set (Fisher exact, two-sided, p &gt; 0.05).",
 "p &le; 0.05.",
 "Currently Fisher exact <a class='gl' href='#g-fisher-exact' aria-label='what &quot;the Fisher exact test&quot; means' title='what &quot;the Fisher exact test&quot; means'>?</a> <b>p = [[roster.G1.p|.4f]], odds ratio<a class='gl' href='#g-odds-ratio' aria-label='what &quot;odds ratio&quot; means' title='what &quot;odds ratio&quot; means'>?</a> [[roster.G1.OR|.3f]]</b> &mdash; in plain English [[roster.G1.R_pct|.1f]]% against [[roster.G1.D_pct|.1f]]%, a gap of [[roster.G1.pp_diff|.1f]] percentage points, <em>not</em> the &ldquo;twice as likely&rdquo; the odds ratio sounds like &mdash; on [[roster.G1.R_yes]] of [[roster.G1.R_n]] Republicans against [[roster.G1.D_yes]] of [[roster.G1.D_n]] Democrats. <b>Corrected 2026-08-23:</b> the basis first published was p = 0.186 on denominators of 57 and 57, which counted the three candidates whose service could not be established as non-veterans &mdash; the opposite of what the frozen specification says to do with them. The verdict does not change; the basis figure was wrong, and a wrong basis figure is what started all of this. Registered because a reviewer noticed that military service and legal record &mdash; the two most politically explosive variables the index codes &mdash; carried no predictions at all. It could not prove that was deliberate. Neither can I. Closing it beat arguing about it. <b>Declared 2026-08-30:</b> the specification's population for this card was changed after the register froze, from &ldquo;the extension set&rdquo; to &ldquo;the roster&rdquo; &mdash; which is the set this claim says to go <em>beyond</em>. That makes it a hit on data already in hand. The full declaration is above; the short version is that a hit here is a drafting failure, not a forecast. <b>Declared 2026-08-30, after an independent review:</b> this card scores a hit when the test simply fails to find a difference<a class='gl' href='#g-null' aria-label='what &quot;the null, and failing to reject it&quot; means' title='what &quot;the null, and failing to reject it&quot; means'>?</a> &mdash; which an underpowered test does for free. B1 and B3 were rebuilt with an equivalence conjunct<a class='gl' href='#g-tost' aria-label='what &quot;equivalence test (TOST)&quot; means' title='what &quot;equivalence test (TOST)&quot; means'>?</a> to close exactly this hole; this one was registered before that fix and cannot be rewritten now. Read a hit here as weak evidence, and note that Fisher exact is the most conservative of the exact tests available, which pushes the p-value in the direction I am betting on."),

("G2","G","open",80,
 "Recorded legal matters<a class='gl' href='#g-legal' aria-label='what &quot;recorded legal matter&quot; means' title='what &quot;recorded legal matter&quot; means'>?</a> are split about evenly between the parties in these races &mdash; [[roster.G2.R_yes]] Republicans, [[roster.G2.D_yes]] Democrats &mdash; and should stay that way.",
 "Through the October refresh, the share of candidates carrying any recorded legal matter does not differ significantly by party (Fisher exact, two-sided, p &gt; 0.05).",
 "p &le; 0.05.",
 "Currently Fisher exact<a class='gl' href='#g-fisher-exact' aria-label='what &quot;the Fisher exact test&quot; means' title='what &quot;the Fisher exact test&quot; means'>?</a> <b>p = [[roster.G2.p|.4f]], odds ratio<a class='gl' href='#g-odds-ratio' aria-label='what &quot;odds ratio&quot; means' title='what &quot;odds ratio&quot; means'>?</a> [[roster.G2.OR|.3f]]</b> &mdash; in plain English [[roster.G2.R_pct|.1f]]% against [[roster.G2.D_pct|.1f]]% &mdash; on [[roster.G2.R_yes]] of [[roster.G2.R_n]] Republicans against [[roster.G2.D_yes]] of [[roster.G2.D_n]] Democrats. <b>Basis corrected 2026-08-30:</b> one Republican's legal cell read &ldquo;None found&rdquo; while its own detail documented a settled personal-capacity civil suit &mdash; inconsistent with how comparable civil matters are coded elsewhere in the index. Correcting it made the parties <em>more</em> even and pushed the p-value further into the range this card bets on. A correction that helps me needs saying louder, not quieter. <b>Basis questioned 2026-08-30 on legal review, settled by verification.</b> Two rows &mdash; one from each party &mdash; carried an adverse legal matter cited only to a wiki; both were briefly coded &ldquo;not established&rdquo; and dropped from this basis, then re-sourced to named contemporary outlets and court records the same day, so they are established, carry their findings, and stand in this basis at [[roster.G2.R_yes]] Republicans and [[roster.G2.D_yes]] Democrats. The rule survives them: an adverse coded value the index cannot stand behind is downgraded and excluded &mdash; the answer to an unsupported claim is to source it or drop it, and here it was sourced (to named contemporary outlets; the court records themselves are not linked, and those rows now carry the outlet tier). The full arc is in the source log. <b>Basis corrected again 2026-09-02, twice, and the verdict does not move.</b> First: the binding spec says what counts here is one of four enumerated values &mdash; Conviction, Charge filed, Settlement, Civil judgment &mdash; and the code had been counting anything that was not &ldquo;None found&rdquo;, which swept in one Democrat&rsquo;s <em>pending</em> civil suit. Under the spec&rsquo;s own rule the basis is [[roster.G2.R_yes]] Republicans of [[roster.G2.R_n]] against [[roster.G2.D_yes]] Democrats of [[roster.G2.D_n]], not the [[roster.G2_broad.D_yes]] Democrats the broad count gave and the spec&rsquo;s own printed basis repeated; p&nbsp;=&nbsp;[[roster.G2.p|.2f]] either way. Second: one Democrat whose court record reads &ldquo;waiver guilty, conviction date 2026-04-14&rdquo; on a noise-ordinance count had been coded &ldquo;Charge filed&rdquo; while two Republicans&rsquo; guilty pleas to comparably minor matters were coded &ldquo;Conviction&rdquo;; he is now coded as the record reads, under the same rule applied to them (the obstruction count against him was dismissed by the prosecution and is recorded as such). He was already in the basis, so it does not change the count; it changes a label, in the direction of even-handedness, and it is said here because it is the kind of asymmetry this site exists to catch. <b>Declared 2026-08-30:</b> like G1 above, this card scores a hit when the test merely fails to find a difference, which an underpowered test does for free &mdash; G1's card states the full weakness and it applies here unchanged. Read a hit as weak evidence."),
]

GROUPS = {"A":"A &middot; The tenure&ndash;money relationship","B":"B &middot; How politicians write",
          "C":"C &middot; What can and cannot be collected","D":"D &middot; The index itself",
          "E":"E &middot; The missing ages","F":"F &middot; Scoring the scorer",
          "G":"G &middot; Service record and legal record"}


def plain_label(s):
    """Group heading with the HTML entities turned back into plain words, for aria-label."""
    return (s.replace("&middot;", "-").replace("&ndash;", "-").replace("&amp;", "and"))


def card(p, cls="", li=False):
    pid, grp, st, conf, plain, formal, fails, note = p
    badge = ('<span class="badge badge-lost">Retracted, still scored</span>' if st == "retracted"
             else '<span class="badge badge-lost">Conceded, not measured</span>'
             if st == "lost" and pid in CONCEDED
             else '<span class="badge badge-lost">Already lost</span>' if st == "lost"
             else '<span class="badge badge-frozen">Frozen, underpriced</span>' if st == "frozen"
             else '<span class="badge badge-self">Against my own work</span>' if st == "self"
             else '<span class="badge badge-pending">Pending</span>')
    extra = (" lost" if st in ("lost", "retracted") else " frozen" if st == "frozen"
             else " selfbet" if st == "self" else "")
    o = [f'<div class="pcard{extra}{cls}" id="p-{pid.replace(chr(8242),"prime").replace(chr(8243),"pprime")}">']
    spoken = ("" if pid[-1] not in "\u2032\u2033"
              else '<span class="vh"> revised</span>' if pid[-1] == "\u2032"
              else '<span class="vh"> revised twice</span>')
    o.append(f'<div class="pcard-head"><span class="pcard-id">{pid}{spoken}</span>{badge}'
             f'<span class="conf"><span class="conf-track" aria-hidden="true">'
             f'<span class="conf-fill" style="width:{conf}%"></span></span>'
             f'<span class="conf-num"><span class="vh">Stated confidence </span>{conf}%</span></span></div>')
    o.append(f'<p class="pcard-plain">{plain}</p>')
    o.append(f'<p class="pcard-formal"><span class="lbl">Registered as</span> {formal}</p>')
    o.append(f'<p class="pcard-fail"><strong>Fails if:</strong> {fails}</p>')
    if note:
        o.append(f'<p class="pcard-note">{note}</p>')
    o.append('</div>')
    body = "\n      ".join(o)
    return f"<li>\n      {body}\n      </li>" if li else body


# (A vestigial __main__ block sat here from the first draft of this file. It printed a
# summary and exited 0, shadowing main() at the bottom — so the documented build command
# never built anything and the fmt()/verify() guard never once ran through the documented
# path. Found by three of ten round-three reviewers, independently. Removed 2026-08-23.)


# ─────────────────────────────────────────────────────────────────────────────
#  Page template
# ─────────────────────────────────────────────────────────────────────────────

NAV = """  <nav class="site-nav" aria-label="Primary">
    <a href="/" class="nav-brand"><span class="brand-mark">&#9670;</span>Patrick Neil Bradley</a>
    <button class="nav-toggle" id="navToggle" type="button" aria-label="Menu" aria-expanded="false" aria-controls="navLinks">
      <span></span><span></span><span></span>
    </button>
    <ul class="nav-links" id="navLinks" role="list">
      <li><a href="/about.html">About</a></li>
      <li><a href="/books.html">Books</a></li>
      <li><a href="/investigations.html" aria-current="page">Investigations</a></li>
      <li><a href="/business-leadership.html">Business Leadership</a></li>
      <li><a href="/legends-return.html">Advocacy</a></li>
      <li><a href="/veteran-resources.html">Resources</a></li>
      <li><a href="/reading.html">Reading</a></li>
      <li><a href="/blog.html">Blog</a></li>
      <li><a href="/daily.html">Daily</a></li>
      <li><a href="/contact.html">Contact</a></li>
    </ul>
    <noscript>
      <!-- Without JavaScript the toggle cannot open anything, and below the nav
           breakpoint the panel is display:none -- which left a phone with no route to
           nine of the ten pages on the site. This drops the panel back into the flow,
           always visible, and hides the button that would do nothing. Wrapped, not
           fixed, so it pushes the page down instead of covering it. -->
      <style>
        @media (max-width: 1230px) {
          .nav-toggle { display: none !important; }
          .site-nav { height: auto; flex-wrap: wrap; padding-top: var(--space-3);
                      padding-bottom: var(--space-3); }
          .nav-links { display: flex !important; position: static !important;
                       flex-direction: row; flex-wrap: wrap; width: 100%;
                       max-height: none; box-shadow: none; border-bottom: none;
                       background: none; padding: var(--space-3) 0 0;
                       gap: var(--space-4) var(--space-5); }
        }
      </style>
    </noscript>
  </nav>
"""

# The nav's behaviour, out of the inline attributes it used to live in. Escape belongs on
# the document while the panel is open -- bound to the button, it worked only while focus
# had not yet moved into the panel, which is the one situation where it is not needed.
NAV_JS = """<script>
(function(){
  var t=document.getElementById('navToggle'), n=document.getElementById('navLinks');
  if(!t||!n)return;
  function set(open){
    n.classList.toggle('open',open);
    t.setAttribute('aria-expanded',open?'true':'false');
  }
  t.addEventListener('click',function(){set(!n.classList.contains('open'));});
  /* Escape closes from anywhere while it is open, and returns focus to the control that
     opened it -- otherwise focus is left inside a panel that just disappeared. */
  document.addEventListener('keydown',function(e){
    if(e.key==='Escape'&&n.classList.contains('open')){set(false);t.focus();}
  });
  /* The panel is position:fixed over the page: anything focused underneath it is
     obscured (2.4.11). Moving focus out of the nav closes it. */
  document.addEventListener('focusin',function(e){
    if(n.classList.contains('open')&&!n.contains(e.target)&&e.target!==t)set(false);
  });
  document.addEventListener('click',function(e){
    if(n.classList.contains('open')&&!n.contains(e.target)&&!t.contains(e.target))set(false);
  });
})();
</script>
"""

FOOTER = """  <footer class="site-footer">
    <div class="footer-grid">
      <div>
        <p class="footer-brand">Patrick Neil Bradley</p>
        <p class="footer-tagline">Writer &middot; Investigator &middot; Veteran Advocate &middot; Analyst. Grounded in service. Driven by the record.</p>
      </div>
      <div>
        <p class="footer-nav-title">Work</p>
        <ul class="footer-nav" role="list">
          <li><a href="/books.html">Books</a></li>
          <li><a href="/investigations.html">Investigations</a></li>
          <li><a href="/analytics.html">Analytics</a></li>
          <li><a href="/legends-return.html">Advocacy</a></li>
        </ul>
      </div>
      <div>
        <p class="footer-nav-title">Writing</p>
        <ul class="footer-nav" role="list">
          <li><a href="/reading.html">Reading &amp; Reviews</a></li>
          <li><a href="/about.html">About</a></li>
        </ul>
      </div>
      <div>
        <p class="footer-nav-title">Veterans</p>
        <ul class="footer-nav" role="list">
          <li><a href="/veteran-resources.html">Resources</a></li>
          <li><a href="/legends-return.html">Legends&rsquo; Return</a></li>
          <li><a href="/veteran-resources.html#crisis">Crisis Line</a></li>
        </ul>
      </div>
    </div>
    <p class="footer-legal">&copy; 2026 Patrick Neil Bradley &middot; on this page and its data bundle: code MIT, text and data CC BY 4.0.</p>
  </footer>
"""

CSS = """
    /* Layer on the site system — tokens from /styles.css, no overrides of chrome. */
    body { background: var(--bg) !important; color: var(--text) !important; }
    main#main { padding-top: 72px; }

    .pred-wrap { max-width: 62rem; margin: 0 auto; padding: 0 var(--space-5) var(--space-8); }
    .pred-head { padding: var(--space-8) 0 var(--space-6); border-bottom: 1px solid var(--border); }
    .pred-head h1 {
      font-family: var(--serif); font-size: var(--text-3xl); line-height: 1.08;
      color: var(--text); margin: 0 0 var(--space-4); max-width: 20ch;
    }
    .pred-head .lede { font-size: var(--text-lg); line-height: 1.7; color: var(--text-muted); max-width: 62ch; }
    .pred-head .lede b { color: var(--text); font-weight: 600; }
    .pred-meta {
      font-family: var(--mono); font-size: var(--text-xs); color: var(--text-dim);
      display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-6); margin-top: var(--space-5);
    }

    .pred-wrap h2 {
      font-family: var(--serif); font-size: var(--text-2xl); line-height: 1.2;
      color: var(--text); margin: var(--space-8) 0 var(--space-5);
    }
    .pred-wrap h3 {
      font-family: var(--mono); font-size: var(--text-xs); letter-spacing: .16em;
      text-transform: uppercase; color: var(--honor); font-weight: 500;
      margin: var(--space-7) 0 var(--space-4);
      padding-bottom: var(--space-2); border-bottom: 1px solid var(--border);
    }
    .pred-wrap p { color: var(--text-muted); line-height: 1.85; margin: 0 0 var(--space-5); max-width: 66ch; }
    .pred-wrap blockquote {
      margin: var(--space-6) 0; padding-left: var(--space-5);
      border-left: 2px solid var(--accent);
      font-family: var(--serif); font-size: var(--text-lg); line-height: 1.6;
      color: var(--text); font-style: italic; max-width: 60ch;
    }
    .pred-wrap a { color: var(--honor); }

    /* ── Prediction card — .pillar geometry: elevated bg, 1px border, square corners ── */
    .pcard {
      background: var(--bg-elevated); border: 1px solid var(--border);
      padding: var(--space-6); margin: 0 0 var(--space-4);
      transition: border-color var(--t-base, .25s);
    }
    .pcard:hover { border-color: var(--border-strong); }
    .pcard.lost    { border-left: 3px solid #E06B6B; }
    .pcard.frozen  { border-left: 3px solid var(--text-dim); }
    .pcard.selfbet { border-left: 3px solid var(--honor); }

    .pcard-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); margin-bottom: var(--space-4); }
    .pcard-id { font-family: var(--mono); font-size: var(--text-sm); letter-spacing: .14em; color: var(--text-muted); }
    .badge {
      font-family: var(--mono); font-size: var(--text-xs); letter-spacing: .12em;
      text-transform: uppercase; padding: 3px 8px; border: 1px solid currentColor; white-space: nowrap;
    }
    .badge-pending { color: var(--honor); }
    .badge-self    { color: var(--honor); }
    .badge-lost    { color: #E06B6B; }
    .badge-frozen  { color: var(--text-dim); }
    .badge-hit     { color: #58A6FF; }
    .badge-miss    { color: #E06B6B; }

    .conf { margin-left: auto; display: flex; align-items: center; gap: var(--space-3); }
    /* The track carries a 1px #8A95A5 outline (5.39:1 on the elevated ground) so the meter's
       full extent is visible; the fill alone against --navy-700 was 1.18:1 and failed 1.4.11.
       The bar itself is aria-hidden because .conf-num states the same number in text. */
    .conf-track { width: 92px; height: 8px; background: var(--navy-700);
      border: 1px solid var(--border-strong); position: relative; overflow: hidden; }
    .conf-fill { position: absolute; left: 0; top: 0; bottom: 0; background: var(--honor); }
    .conf-num { font-family: var(--mono); font-size: var(--text-xs); color: var(--text-muted); min-width: 3ch; }

    /* Visually hidden, still announced. */
    .vh { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px;
      overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }

    /* THE INVERSION: plain English carries claim weight, the formal statement sits beneath it. */
    .pcard-plain {
      font-family: var(--sans); font-size: var(--text-base); font-weight: 500;
      color: var(--text) !important; line-height: 1.65;
      margin: 0 0 var(--space-4) !important; max-width: 62ch;
    }
    .pcard-plain b { color: var(--honor); font-weight: 600; }
    .pcard-formal {
      font-size: var(--text-sm) !important; line-height: 1.75;
      color: var(--text-muted) !important; margin: 0 0 var(--space-3) !important;
      padding-left: var(--space-4); border-left: 1px solid var(--navy-600); max-width: 66ch;
    }
    .pcard-formal .lbl {
      display: block; font-family: var(--mono); font-size: var(--text-xs);
      letter-spacing: .14em; text-transform: uppercase; color: var(--text-dim); margin-bottom: 4px;
    }
    .pcard-fail {
      font-family: var(--mono); font-size: var(--text-xs); line-height: 1.7;
      color: var(--text-muted) !important; margin: 0 !important; padding-left: var(--space-4);
    }
    .pcard-fail strong { font-family: var(--sans); color: #E06B6B; letter-spacing: .04em; }
    .pcard-note {
      font-size: var(--text-sm); line-height: 1.8; color: var(--text-dim) !important;
      margin: var(--space-4) 0 0 !important; padding-top: var(--space-4);
      border-top: 1px solid var(--border);
    }
    .pcard-note b { color: var(--text-muted); font-weight: 500; }

    /* ── Figures ── */
    .fig { margin: var(--space-7) 0; }
    .fig svg { width: 100%; height: auto; display: block; overflow: visible; }
    .fig-scroll:focus-visible { outline: 2px solid var(--honor); outline-offset: 3px; }
    .fig figcaption {
      font-family: var(--mono); font-size: var(--text-xs); color: var(--text-dim);
      line-height: 1.7; margin-top: var(--space-4);
      padding-top: var(--space-3); border-top: 1px solid var(--border); max-width: 68ch;
    }
    .fig-scroll { overflow-x: auto; }

    /* ── Collapsible register ── */
    .register { border: 1px solid var(--border); background: var(--bg-elevated); margin: var(--space-5) 0; }
    .register > summary {
      cursor: pointer; list-style: none; padding: var(--space-5) var(--space-6);
      display: flex; justify-content: space-between; align-items: center; gap: var(--space-4);
      font-family: var(--mono); font-size: var(--text-sm); color: var(--text); letter-spacing: .06em;
    }
    .register > summary::-webkit-details-marker { display: none; }
    .register > summary::after { content: "+"; font-size: 1.25rem; line-height: 1; color: var(--honor); flex: none; }
    .register[open] > summary::after { content: "\\2212"; }
    .register > summary:hover { color: var(--honor); }
    .register .reg-count { color: var(--text-dim); font-size: var(--text-xs); margin-left: auto; }
    .register-body { padding: 0 var(--space-6) var(--space-6); border-top: 1px solid var(--border); }
    .register-body h3:first-of-type { margin-top: var(--space-6); }
    .register-body .pcard { background: var(--navy-900); }

    /* Content links were gold on muted paper — 1.38:1 against surrounding text, with
       no underline and no non-colour cue. That is colour alone (1.4.1, Level A), and
       for a reader with reduced colour discrimination the links were invisible. */
    /* th was missing from this list, and the verification table puts every one of its
       36 digest links in a <th scope="row"> -- so the single largest block of content
       links on the page was gold-only, which is the exact 1.4.1 failure the rule above
       exists to fix, sitting inside the fix. Found by an independent reviewer.
       a11y_audit.py now asserts that no content link falls outside this selector. */
    .pred-wrap p a, .pred-wrap li a, .pred-wrap td a, .pred-wrap th a, .pred-wrap dd a,
    .pred-wrap figcaption a, .follow-up-note a, .pred-foot a, .pcard-note a {
      text-decoration: underline; text-decoration-thickness: 1px;
      text-underline-offset: 2px; text-decoration-color: var(--navy-600); }
    .pred-wrap p a:hover, .pred-wrap li a:hover, .pred-wrap td a:hover,
    .pred-wrap th a:hover, .pred-wrap dd a:hover, .pred-wrap figcaption a:hover, .follow-up-note a:hover,
    .pred-foot a:hover, .pcard-note a:hover,
    .pred-wrap p a:focus-visible, .pred-wrap li a:focus-visible,
    .pred-wrap td a:focus-visible, .pred-wrap th a:focus-visible,
    .pred-wrap dd a:focus-visible {
      text-decoration-thickness: 2px; text-decoration-color: var(--honor); }
    /* the ? is an icon, not a text link — it keeps its ring and takes no underline */
    a.gl { text-decoration: none !important; }

    /* the ? that links a term to its definition — a real link, never a tooltip alone */
    /* The visible circle stays small; the TAP TARGET does not. Round four measured the
       old one at about 11px square against the 24px WCAG 2.5.8 asks for, which quietly
       voided the whole glossary for anyone reading on a phone. */
    a.gl { position: relative; display: inline-block; font-family: var(--mono);
      font-size: .72em; color: var(--honor); text-decoration: none;
      border: 1px solid var(--border-strong); border-radius: 50%;
      width: 1.35em; height: 1.35em; line-height: 1.3em;
      text-align: center; vertical-align: super; margin-left: 2px; }
    a.gl::after { content: ""; position: absolute; left: 50%; top: 50%;
      width: 24px; height: 24px; transform: translate(-50%, -50%); }
    a.gl:hover, a.gl:focus-visible { border-color: var(--honor); }

    /* ── Glossary ── */
    /* the box is open by default and every ? lands on a dt, so both need to clear
       the fixed header, or the jump puts the term underneath the nav bar. */
    #glossary { scroll-margin-top: 88px; }
    .glossary { margin: 0; }
    .gloss-back { font-size: var(--text-xs); margin: var(--space-5) 0 0 !important; }
    .gloss-back a { color: var(--text-dim); }
    .gloss-back a:hover, .gloss-back a:focus-visible { color: var(--honor); }
    .gloss-group { font-family: var(--mono); font-size: var(--text-xs); letter-spacing: .14em;
      text-transform: uppercase; color: var(--text-dim); margin: var(--space-6) 0 0;
      padding-top: var(--space-5); border-top: 1px solid var(--border); }
    /* round-four review: this used to set the target to plain text colour, which made
       the one term you jumped to the ONLY one not gold. Backwards. */
    .glossary dt:target { color: var(--honor); text-decoration: underline;
      text-underline-offset: 4px; }
    .glossary dt:target + dd { border-left-color: var(--honor); border-left-width: 3px;
      padding-left: calc(var(--space-4) - 2px); }
    .glossary dt { font-family: var(--mono); font-size: var(--text-sm); color: var(--honor);
      letter-spacing: .04em; margin-top: var(--space-5); scroll-margin-top: 88px; }
    .glossary dd { margin: var(--space-2) 0 0; padding-left: var(--space-4);
      border-left: 1px solid var(--navy-600); color: var(--text-muted);
      font-size: var(--text-sm); line-height: 1.75; max-width: 62ch; }

    /* ── Caveat / risk callout ── */
    .caveat { border-left: 3px solid var(--accent); background: var(--bg-elevated);
      padding: var(--space-5) var(--space-6); margin: var(--space-6) 0; }
    .caveat p { margin-bottom: var(--space-4) !important; }
    .caveat p:last-child { margin-bottom: 0 !important; }
    .caveat-label { font-family: var(--mono); font-size: var(--text-xs); letter-spacing: .14em;
      text-transform: uppercase; color: var(--accent-text); display: block; margin-bottom: var(--space-3); }

    /* ── Verify block ── */
    .verify { border: 1px solid var(--border); background: var(--bg-elevated);
      padding: var(--space-6); margin: var(--space-6) 0; }
    .verify table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: var(--text-xs); }
    .verify th, .verify td { text-align: left; padding: var(--space-3) var(--space-3) var(--space-3) 0;
      border-bottom: 1px solid var(--border); vertical-align: top; }
    .verify th { color: var(--text-dim); letter-spacing: .12em; text-transform: uppercase; font-weight: 500; }
    .verify td { color: var(--text-muted); }
    /* file descriptions in the digest table. A CLASS, not an inline style: inline
       colour styles are unreachable by the generated print overrides, which is how
       these printed at 3.03:1 (Phase 3 exit gate, finding 1). */
    .file-desc { color: var(--text-dim); }
    /* Unqualified: a 64-character digest outside .verify was forcing +342px of
       whole-page horizontal scroll at 320px (WCAG 1.4.10). The descendant selector
       this replaces could not reach the two withheld hashes in body copy. */
    .hash { color: var(--text-dim); word-break: break-all; overflow-wrap: anywhere; }
    .verify-scroll { overflow-x: auto; }

    .pred-foot { margin-top: var(--space-8); padding-top: var(--space-5);
      border-top: 1px solid var(--border);
      font-size: var(--text-sm); line-height: 1.8; color: var(--text-dim) !important; max-width: 70ch; }

    /* Prediction groups are lists: a screen reader should say "list, 4 items". */
    .pred-list { list-style: none; margin: 0; padding: 0; }
    .pred-list > li { margin: 0; }

    .register > summary:focus-visible { outline: 2px solid var(--honor); outline-offset: -2px; }

    @media (max-width: 720px) { .conf { margin-left: 0; width: 100%; } }

    /* Printed, a closed <details> silently drops most of the register. Force it open.
       The colour overrides below this block are GENERATED at build time from this
       page's own CSS — every selector that sets a colour gets a print override, so
       coverage is structural. The hand-written list this replaces missed the other
       page's table cells and printed them white on white at 1.12:1. */
    @media print {
      body, main#main { background: #FFFFFF !important; color: #111111 !important; }
      .pcard, .caveat, .verify, .register, .register-body .pcard { background: #FFFFFF !important;
        border-color: #999999 !important; }
      .pcard-fail strong, .caveat-label { color: #8B0000 !important; }
      a { color: #111111 !important; text-decoration: underline; }
      .conf-track, .site-nav, .site-footer, .skip-link { display: none !important; }
      /* the chart: CSS fill/stroke beat presentational attributes, so the SVG prints dark on white */
      .fig svg rect.bg { fill: #FFFFFF !important; }
      .fig svg text { fill: #111111 !important; }
      /* Round-six review: this flattened gridlines, the step bracket, the riser and
         the trend line to one grey, so the printed chart lost the step-vs-slope
         distinction it exists to show. Gridlines only; the gold marks keep their
         identity in a darker ink. */
      .fig svg line[stroke="#5A6B85"] { stroke: #999999 !important; }
      .fig svg line[stroke="#C8A24A"], .fig svg line[stroke="#C9C4B8"] { stroke: #7a5a10 !important; }
      .fig svg circle[fill="#C8A24A"] { fill: #7a5a10 !important; stroke: #FFFFFF !important; }
      .fig svg circle[fill="#8A95A5"] { fill: #444444 !important; stroke: #FFFFFF !important; }
      /* The Kaptur ring is stroked #E06B6B, not #B83232 — this selector matched
         nothing and the ring printed at 3.24:1 on white. */
      .fig svg circle[stroke="#E06B6B"] { stroke: #8B0000 !important; }
      .register > summary::after { content: ""; }
      .register-body { display: block !important; }
      .register[open] .register-body, .register:not([open]) .register-body { display: block !important; }
      .fig svg { min-width: 0 !important; }
    }
"""


def head(title, desc, slug, og_title, og_desc, extra_css=""):
    return f"""<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>{title}</title>
  <meta name="description" content="{desc}" />
  <meta name="theme-color" content="#0A1628" />

  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;0,9..144,700;1,9..144,400&amp;family=Inter:wght@400;500;600;700&amp;family=JetBrains+Mono:wght@400;500&amp;display=swap" />

  <link rel="stylesheet" href="/styles.css" />

  <link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
  <link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32.png" />
  <link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16.png" />
  <link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png" />

  <meta property="og:type" content="article" />
  <meta property="og:site_name" content="PatrickNeilBradley.com" />
  <meta property="og:title" content="{og_title}" />
  <meta property="og:description" content="{og_desc}" />
  <meta property="og:url" content="https://patrickneilbradley.com/investigations/{slug}" />
  <meta property="og:image" content="https://patrickneilbradley.com/assets/og-image.png" />
  <meta property="og:image:width" content="1200" />
  <meta property="og:image:height" content="630" />
  <meta name="twitter:card" content="summary_large_image" />
  <meta name="twitter:title" content="{og_title}" />
  <meta name="twitter:description" content="{og_desc}" />
  <meta name="twitter:image" content="https://patrickneilbradley.com/assets/og-image.png" />
  <link rel="canonical" href="https://patrickneilbradley.com/investigations/{slug}" />

  <style>{CSS}{extra_css}  </style>
</head>
<body>

  <a href="#main" class="skip-link">Skip to main content</a>

{NAV}
  <main id="main">
"""


MANIFEST = os.path.normpath(os.path.join(HERE, "..", "public_data", "MANIFEST.sha256"))
FIG = os.path.normpath(os.path.join(WEB, "fig_step_not_slope.svg"))

def _ots_desc(commit, extra=""):
    """The description of a .ots attestation row, COMPUTED from ANCHORS.txt at build time.

    Used to be a typed string. 'Still pending at the calendars, not yet in a block' sat on the
    COMMITMENT-2 row for a day after that commitment anchored in block 964709 — a stale typed
    literal on a page whose whole subject is stale typed literals, found by the final
    pre-publication reviewer. Now the row reads the same file the gate reads."""
    a = os.path.join(HERE, "..", "timestamp", "ANCHORS.txt")
    if not os.path.exists(a):
        a = os.path.join(HERE, "ANCHORS.txt")
    for line in open(a, encoding="utf-8"):
        m = re.match(r"^" + re.escape(commit) + r"\s*\|\s*(\w+)\s*\|\s*(\S+)\s*\|\s*(\S+)\s*\|\s*(\S+)",
                     line)
        if m:
            if m.group(1) == "anchored":
                t = m.group(4).replace("T", " ").replace("Z", " UTC")
                return ("Its attestation — anchored in Bitcoin block %s, mined %s. Verify with: "
                        "ots verify %s.ots%s" % (m.group(2), t, commit, extra))
            return ("Its attestation — pending at the calendars, not yet in a block; this build "
                    "is a DEV build until it confirms.%s" % extra)
    return "Its attestation.%s" % extra


VERIFY_ROWS = [
    ("PREDICTIONS_registered_2026-08-22.md", "v1 as first registered. 18 predictions, 16 published. Never edited."),
    ("PREDICTIONS_v2_2026-08-22.md",         "v2, after four blind reviews. 19 scored. Never edited."),
    ("PREDICTIONS_v3_2026-08-23.md",         "v3, after the A-block defect. 25 scored. Never edited."),
    ("PREDICTIONS_v4_2026-08-23.md",         "v4, after the second review round. The four bad bets frozen, corrections named. Never edited."),
    ("PREDICTIONS_v5_2026-08-24.md",         "v5, after ten blind reviews. The universe, instrument and baseline rulings. Binding."),
    ("DEFECT_A_BLOCK_2026-08-23.md",         "The defect report: what reproduced, what didn't, and why. ERRATUM (2026-09-02, the file is frozen and cannot be edited): its table headed 'reproduce to two decimals' contains two rows that do not — the Democratic incumbent mean, published 13.2%, is shown against 12.22%, which is the prior-service figure, not the FEC-code figure of [[august.mean_inc_feccode_DEM|.2f]]% that matches; and the challenger mean 0.3% against 0.26%. The challenger figure v4 corrected; the incumbent figure v5 corrected, in its vintage caveat; the table's heading overstated both."),
    ("ERRATUM_EVIDENTIARY_STANDARD_2026-08-24.md", "Erratum: the rulebook's Tijerina example went stale; the rule it illustrates did not."),
    ("LICENSE.md",                           "Code MIT, data and text CC BY 4.0, FEC source data public domain."),
    ("ANALYSIS_SPEC_v1.md",                  "The first analysis specification. Bound nineteen predictions; superseded. Never edited."),
    ("ANALYSIS_SPEC_v2_2026-08-30.md",       "The second specification; superseded the same day it was issued, and never edited. Independent review found it froze one figure its own register had corrected, misdated its own case-law precedent, and changed five rules from v1 without a changelog. All three failures are enumerated in v3."),
    ("ANALYSIS_SPEC_v3_2026-08-30.md",       "THE BINDING SPECIFICATION: population, estimator, exclusions and a total scoring map for every one of the 25. Changes no scoring map, threshold, population or confidence from v2 — it corrects v2's false statements and carries the two changelogs this series always owed (what v2 silently changed from v1, and every v2-to-v3 delta). Frozen and separately timestamped (COMMITMENT-4)."),
    ("EVIDENTIARY_STANDARD_v1.1.md",         "The coding rulebook the index was built under."),
    ("STYLE_PROTOCOL.md",                    "The writing-measurement protocol, with its own changelog."),
    ("districts_59.csv",                     "The 59 districts and the selection rule. The most politically loaded choice here."),
    ("roster_115.csv",                       "All 115 candidates."),
    ("money_analysis.py",                    "The A-block estimator. Every number in section A comes from running this."),
    ("make_figures.py",                      "The chart above, generated from the same data."),
    ("COMMITMENT.txt",                       "The frozen record and its digests, as submitted for timestamping. Anchored 2026-08-30 — later than the dates inside the documents, which is stated in the file."),
    ("COMMITMENT-2.txt",                     "A second commitment, for a record document published after the first. Editing a timestamped file destroys its attestation, so a later addition gets a new commitment rather than an amended one."),
    ("COMMITMENT-2.txt.ots",                 _ots_desc("COMMITMENT-2.txt")),
    ("COMMITMENT-3.txt",                     "The CODE commitment: every script that computes or generates anything here, frozen as at this release. Added 2026-08-30 after a red-teamer overrode one figure inside figures.py — four lines — and turned a declared loss into a hit with every other check still passing, because the scripts were excluded from the first commitment as living code."),
    ("COMMITMENT-3.txt.ots",                 _ots_desc("COMMITMENT-3.txt", " The code anchor re-pins at every release.")),
    ("COMMITMENT-4.txt",                     "The spec-v3 commitment: the binding scoring specification, frozen the day it was issued. A new commitment rather than an edit to an old one, because a frozen file is never edited and a commitment is never regenerated — either act destroys the attestation."),
    ("COMMITMENT-4.txt.ots",                 _ots_desc("COMMITMENT-4.txt")),
    ("ANCHORS.txt",                          "What each proof is REQUIRED to prove: the exact Bitcoin block, its hash and its time. Without it the gate accepts any valid proof, including one made moments ago over rewritten content."),
    ("ots_verify.py",                        "Walks an OpenTimestamps proof from a file's digest to the merkle root of the Bitcoin block it names, and checks that root against two independent explorers. About a hundred lines, standard library only. This is what makes the commitment gate more than a reader of its own input."),
    ("COMMITMENT.txt.ots",                   _ots_desc("COMMITMENT.txt", " Submitted to four independent Bitcoin calendars.")),
    ("VALIDATION_RESULTS.md",                "The blind inter-coder replication ([[val.agree]] of [[val.n]], alpha [[val.alpha|.2f]]) and the occupational split, including the Republican-leaning claim it refuted. Published 2026-08-30 after a reviewer found both were cited on the page and available nowhere."),
    ("privacy_gate.py",                      "A tripwire for one pattern: a two-word name near private-life language in the Source Log and the page generator. NOT a guarantee &mdash; it names five classes it cannot see, in its own text. Added 2026-08-30 after a reviewer found four private individuals in the Source Log."),
    ("faith_gate.py",                        "Refuses a faith cell filled on grounds C4 excludes. It found one the reviewers had only called soft, and a second when a red-teamer removed the blanket veto that had been letting one admissible-looking word disarm all four exclusion rules."),
    ("tier_gate.py",                         "Refuses a tier 1 or 2 claim whose citations are all tertiary, self-published, or absent. Rewritten 2026-08-30 after a red-teamer walked past it with a semicolon: it used to split the citation field on ';' and count every fragment as a source, which certified six rows on the candidate's own campaign site. The declared count is computed here, not typed."),
    ("register_gate.py",                     "Cross-checks all 25 confidences AND every threshold on every card against the five frozen register versions. Extended 2026-08-30: it used to check confidences only, so a threshold could be rewritten freely; and a card it failed to parse counted as agreement, which one leading space was enough to arrange."),
    ("commitment_gate.py",                   "Walks each OpenTimestamps proof from the file's own digest to the Bitcoin block it names, and checks that block's merkle root against two independent explorers that must agree. The anchor it must reach is pinned in ANCHORS.txt, so a proof re-made over rewritten content lands elsewhere and fails. Rewritten 2026-08-30: it used to READ the digest out of the proof's header, and a red-teamer forged that with one sed."),
    ("style_analysis.py",                    "The writing measure: Flesch-Kincaid as the B-block predictions are scored on, including the terminal-punctuation fix declared on B4."),
    ("style_corpus_all.csv",                 "The frozen writing corpus every reading-grade figure is computed from. Added 2026-08-30 after a reviewer found the bundle would not run without it."),
    ("figures.py",                           "Every number on both pages except the A-block estimator's own: the reconstruction table, the calibration intervals, the leverage diagnostics. Named in the text; now published with it."),
    ("make_pages.py",                         "The page generator. It refuses to build if a figure is missing, a digest is stale, an anchor is dead, a retracted diagnostic reappears in any rendering, a decimal appears in prose that no figure resolves to, a confidence stated in a sentence is not in the register, or a headline changes without its URL. It does NOT catch every typed integer: a bare number under 100 reads as a count. That gap is real and is why the figures cited to published records are required to be wired to them."),
    ("nominees.py",                           "The 59 districts and their nominees, as coded."),
    ("phase2.py",                             "Ages, tenure and the Source Log."),
    ("finance_nominees.csv",                  "The frozen FEC extract every money figure is computed from."),
    ("a11y_audit.py",                        "The contrast audit. It resolves every colour rule through the site tokens and measures it."),
    ("A_BLOCK_OUTPUT_2026-08-23.txt",        "What that script prints on the frozen data. Corrected 2026-08-30: this row used to say \"what it printed on 2026-08-23\". The file is regenerated at every build, so it is today's run on August's frozen inputs \u2014 which proves the estimator is deterministic, not that a past state was preserved."),
    ("withheld_hashes.txt",                  "Hashes of the two withheld predictions. The withholding itself failed — both texts appear in the published v1; see the correction above."),
]


def load_hashes():
    """Digest table values, hashed DIRECTLY from the files at render time.

    The previous version read MANIFEST.sha256 — a text file that could be, and twice
    was, older than the files it described. Round three shipped two stale digests that
    way, on the page instructing readers that a mismatched digest means tampering.
    Hashing the files themselves makes a stale table structurally impossible; the
    manifest is then written AFTER the pages, by build_all.py, as the last artifact."""
    import hashlib
    # Flat-bundle fallback. Every other script got one; this was the only holdout,
    # so a reader could not run the script whose guarantee the page advertises.
    bundle = os.path.normpath(os.path.join(HERE, "..", "public_data"))
    if not os.path.isdir(bundle):
        bundle = HERE
    h = {}
    for fn, _desc in VERIFY_ROWS:
        p = os.path.join(bundle, fn)
        if not os.path.exists(p):
            raise SystemExit(f"digest table: {fn} is named on the page but absent from the bundle")
        h[fn] = hashlib.sha256(open(p, "rb").read()).hexdigest()
    return h


PRINT_FIX = """<script>
/* Printed, a collapsed <details> silently drops 18 of 25 predictions and the whole
   glossary, and the CSS also removes the marker that would show anything was missing.
   The stylesheet attempt at this is a no-op: modern engines hide the content through
   ::details-content, which author CSS on the light-DOM child cannot reach. A reviewer
   proved it. Remember what was closed, open everything to print, restore afterwards. */
(function(){var c=[],open=false;\n/* Chromium fires BOTH the matchMedia('print') change AND beforeprint. The first version\n   of this reset c=[] on every pre(), so the second call re-scanned a document whose\n   details were already open, collected nothing, and left post() with nothing to close:\n   every collapsed section stayed open after printing. Latch it. */\nfunction pre(){if(open)return;open=true;c=[];\ndocument.querySelectorAll('details:not([open])').forEach(function(d){c.push(d);d.open=true;});}\nfunction post(){if(!open)return;open=false;c.forEach(function(d){d.open=false;});c=[];}\nif(window.matchMedia){var m=window.matchMedia('print');\nif(m.addEventListener)m.addEventListener("change",function(q){q.matches?pre():post();});}\nwindow.addEventListener('beforeprint',pre);window.addEventListener('afterprint',post);})();
</script>
"""

GATES = sorted(os.path.basename(g) for g in
               glob.glob(os.path.join(HERE, "*_gate.py")))


def counts():
    """Counts the page states in prose. They were typed. Eleven times in this project a
    number has gone stale in a sentence while its input moved; this is the same class of
    defect and gets the same treatment -- derived, then resolved by fmt(), which raises
    on an unknown key."""
    lost = [x for x in P if x[2] == "lost"]
    meas = [x for x in lost if x[0] not in CONCEDED]
    conc = [x for x in lost if x[0] in CONCEDED]
    return {"n_lost": len(lost), "n_lost_w": WORD[len(lost)].lower(),
            "n_lost_W": WORD[len(lost)], "n_meas": len(meas),
            "n_meas_w": WORD[len(meas)].lower(), "n_meas_W": WORD[len(meas)],
            "n_conc": len(conc), "n_conc_w": WORD[len(conc)].lower(),
            "n_conc_W": WORD[len(conc)],
            "meas_ids": ", ".join(x[0] for x in meas),
            "conc_ids": " and ".join(x[0] for x in conc),
            # The page said "six build gates". There are five, and the number was typed --
            # in the paragraph about what the apparatus does and does not secure. Counted
            # from the directory, so adding or removing one moves the sentence.
            "n_gates": len(GATES), "n_gates_w": WORD[len(GATES)].lower(),
            # The anchor state, computed from ANCHORS.txt at build time. A red-teamer found
            # the page claiming "all four commitments are confirmed" as a typed literal while
            # the code commitment sat pending -- the exact typed-claim-drifting-from-source
            # defect the project exists to catch, on the page's own anchor. Derived now.
            "anchor_state": _anchor_state()}


def _anchor_state():
    """A sentence describing how many commitments are anchored, read from ANCHORS.txt."""
    a = os.path.join(HERE, "..", "timestamp", "ANCHORS.txt")
    if not os.path.exists(a):
        a = os.path.join(HERE, "ANCHORS.txt")
    anch, pend = 0, 0
    for line in open(a, encoding="utf-8"):
        m = re.match(r"^(COMMITMENT[0-9-]*\.txt)\s*\|\s*(\w+)", line)
        if m:
            if m.group(2) == "anchored":
                anch += 1
            elif m.group(2) == "pending":
                pend += 1
    tot = anch + pend
    if pend == 0:
        return "All %d commitments are confirmed on the Bitcoin blockchain." % tot
    return ("%d of the %d commitments are confirmed on the Bitcoin blockchain; %d "
            "(the code commitment, which re-anchors at each release) %s pending, and this "
            "build is a labelled DEV build until it confirms." % (
                anch, tot, pend, "is" if pend == 1 else "are"))


def build_page():
    n = len(P)
    lost = [p for p in P if p[2] == "lost"]
    retr = [p for p in P if p[2] == "retracted"]
    selfb = [p for p in P if p[2] == "self"]
    mean = sum(p[3] for p in P) / n
    H = load_hashes()
    svg = open(FIG, encoding="utf-8").read()

    slug = "2026-battleground-predictions.html"
    o = [head(
        f"Twenty-Five Bets, {WORD[len(lost)]} Lost and {WORD[len(retr)]} Retracted &mdash; Patrick Neil Bradley",
        f"Candidates who have served in Congress take a far larger share of their money from corporate and trade PACs than candidates who never have. How much the share keeps climbing with seniority is the part I got wrong. {n} falsifiable predictions about the 2026 House battleground, {len(lost)} already lost and {len(retr)} retracted, all scored on 31 October.",
        slug,
        f"Twenty-Five Bets, {WORD[len(lost)]} Lost and {WORD[len(retr)]} Retracted",
        f"I priced four of these predictions on a number that turned out to be wrong. They are still here, unedited, at the confidence I gave them. All {n} get scored on 31 October.",
        extra_css=generated_print_overrides(CSS))]
    a = o.append

    a('  <article class="pred-wrap">')

    # ── head ────────────────────────────────────────────────────────────────
    a('''    <div class="pred-head">
    <p class="eyebrow">Registered in advance &middot; 2026 House Battleground Index</p>
    <h1>Twenty-Five Bets, ''' + WORD[len(lost)] + ''' Lost and ''' + WORD[len(retr)] + ''' Retracted</h1>

    <p class="lede">
      Candidates in these 59 races who have never held the seat take <b>[[money.mean_non|.2f]]%</b>
      of their money from corporate and trade PACs<a class='gl' href='#g-pac' aria-label='what &quot;PAC, and corporate and trade&quot; means' title='what &quot;PAC, and corporate and trade&quot; means'>?</a>. Candidates who have served in Congress take <b>[[money.mean_inc|.2f]]%</b>.
      That gap &mdash; whatever small movement the measurement window adds to the exact two
      figures &mdash; is not in dispute. What I published about the rest of it &mdash; how much further the
      figure climbs the longer someone stays &mdash; was wrong, and I found out by trying to write it
      down as a fact. <b>What is true instead: the money arrives with the seat, not with the
      seniority.</b> Getting to Congress at all is worth about <b>9 points</b> of corporate-PAC
      share; each further year served may add only about <b>a third of a point</b>, and even that
      is uncertain. My August
      headline was about that small second number, and I had it far too high. The fitted
      figures, and how I got there, are below.
    </p>

    <p>
      Getting there cost me five registered predictions. [[C.n_lost_W]] are already lost
      &mdash; [[C.n_meas_w]] measured against the frozen data, [[C.n_conc_w]] conceded on
      reasoning I could not compute &mdash; and a fifth is retracted; because the test I wrote
      for it was weak, that fifth will probably still be scored a hit. All five are below,
      unedited, at the confidence I gave them.
    </p>

    <p class="pred-meta">
      <span>v1 registered 2026-08-22</span>
      <span>v5 binding, 2026-08-24</span>
      <span>Page last revised 2026-09-02</span>
      <span>''' + f"{n} predictions &middot; {len(lost)} already lost" + '''</span>
      <span>Scored 2026-10-31</span>
    </p>
    </div>

    <blockquote>
      The test of a method isn&rsquo;t whether it produces impressive claims. It&rsquo;s whether
      it can take one away from you.
    </blockquote>
''')

    # ── the finding ─────────────────────────────────────────────────────────
    a('    <h2>The finding, after it was checked</h2>\n')
    a('''    <p>
      This project keeps a verified record of 115 candidates across the 59 most competitive
      U.S. House districts &mdash; occupation, military service, legal record, campaign finance
      and communication style &mdash; built under a written rulebook frozen before the last
      twenty candidates were coded<a class='gl' href='#g-coding' aria-label='what &quot;coding, coder, coded&quot; means' title='what &quot;coding, coder, coded&quot; means'>?</a>, and tested by a separate blind coding session &mdash; an AI session, not a second person &mdash; given only the rulebook.
      Blank means &ldquo;no credible source located.&rdquo; It never means &ldquo;assumed none.&rdquo;
    </p>

    <p>
      One naming rule, stated once: the specification&rsquo;s word for anyone with prior House or
      Senate service is &ldquo;incumbent&rdquo; (tenure &gt; 0) &mdash; and that group includes one
      former member running again and one sitting incumbent whose FEC<a class='gl' href='#g-fec' aria-label='what &quot;FEC&quot; means' title='what &quot;FEC&quot; means'>?</a> record still codes her as a
      challenger, so this page says &ldquo;candidates who have served&rdquo; where it means that
      population, and never calls them sitting members.
    </p>

    <p>
      Its strongest result was about money and time in office. The version I published in August
      said corporate money climbs steadily with seniority, at a correlation<a class='gl' href='#g-pearson' aria-label='what &quot;correlation (Pearson r)&quot; means' title='what &quot;correlation (Pearson r)&quot; means'>?</a> of 0.82 across all
      candidates and 0.70 among incumbents by FEC code &mdash; not the same population as the
      48 with prior service, a difference the companion page walks through. Recomputed from the frozen data under the frozen
      specification, those are <b>[[money.r_pool|.3f]]</b> and <b>[[money.r_inc|.3f]]</b>. What is
      left is real but much weaker than I said:
    </p>
''')
    a('  <figure class="fig">')
    a('  <div class="fig-scroll" tabindex="0" role="group" aria-label="Scrollable chart: corporate and trade PAC share against years in Congress">')
    a("    " + svg.replace("\n", "\n    "))
    a('  </div>')
    a('''    <figcaption>
      [[money.n_rows]] of the 115 candidates &mdash; [[missing.n]] have no row in the frozen
      finance file, for reasons the roster note below spells out &mdash; showing corporate and trade PAC share of receipts against years of
      congressional service, FEC bulk data frozen 2026-08-21 ([[vintage.n_at_modal]] of [[vintage.n_files]] committees reporting through [[vintage.modal]]; coverage runs [[vintage.earliest]] to [[vintage.latest]]). The [[money.n_non]] who have never
      served average [[money.mean_non|.2f]]%; the [[money.n_inc]] candidates who have served in Congress average
      [[money.mean_inc|.2f]]%. Among those [[money.n_inc]] the upward trend is
      r&nbsp;=&nbsp;[[money.r_inc|.2f]], with the points scattering [[money.scatter|.2f]] percentage
      points either side of the line &mdash; which is why the trend looks weak even though the line
      itself rises [[money.climb|.1f]] points across the range. [[money.top_name]],
      [[money.top_tenure|.0f]] years and [[money.top_share|.1f]]%, is the single highest-leverage
      point<a class='gl' href='#g-leverage' aria-label='what &quot;leverage, and influence&quot; means' title='what &quot;leverage, and influence&quot; means'>?</a>; she is circled and kept in, because removing an inconvenient point is how you get a
      number that doesn&rsquo;t reproduce. Deleting her takes the incumbents-only correlation from [[money.r_inc|.3f]] to
      <b>[[money.r_inc_drop_lev|.3f]]</b> &mdash; printed because naming an outlier and keeping it
      proves nothing unless you also print what keeping it costs. Nothing here is scored with her
      removed. Generated by <code>make_figures.py</code>, published below.
    </figcaption>
  </figure>
''')
    a('''    <h2 id="fit">Step or slope: fitted, at last</h2>

    <p>
      My first replacement headline said the pattern was <em>mostly a
      step<a class='gl' href='#g-step' aria-label='what &quot;step&quot; means' title='what &quot;step&quot; means'>?</a>, not a slope<a class='gl' href='#g-slope' aria-label='what &quot;slope, and the fitted line&quot; means' title='what &quot;slope, and the fitted line&quot; means'>?</a></em> &mdash; that arriving in Congress moves the money and staying
      longer barely adds to it. I then argued about that for three days using correlations,
      got it wrong, corrected it, got the correction wrong, corrected that, and wrote
      &ldquo;I do not currently know&rdquo;. <b>An editor pointed out that this is not an open
      question.</b> It is a two-line regression<a class='gl' href='#g-regression' aria-label='what &quot;regression, and what these two coefficients mean&quot; means' title='what &quot;regression, and what these two coefficients mean&quot; means'>?</a> on data that had been frozen for a week: a
      correlation is one number and cannot separate two effects, so I should have fitted the
      two. Here it is.
    </p>

    <div class="tbl-scroll"><table>
      <caption class="vh">Corporate and trade PAC share regressed on having served and on years served</caption>
      <thead><tr><th scope="col">Effect</th><th scope="col">Estimate</th>
        <th scope="col">95% interval</th><th scope="col">p</th></tr></thead>
      <tbody>
        <tr><th scope="row">The step &mdash; arriving in Congress at all</th>
          <td><b>[[fit.b1_step|.2f]]</b> points</td>
          <td>[[fit.step_hc3_lo|.2f]] to [[fit.step_hc3_hi|.2f]]</td>
          <td>[[fit.p1_hc3|.4f]]</td></tr>
        <tr><th scope="row">The slope &mdash; each further year served</th>
          <td><b>[[fit.b2_slope|.3f]]</b> points a year</td>
          <td>[[fit.slope_hc3_lo|.3f]] to [[fit.slope_hc3_hi|.3f]]</td>
          <td>[[fit.p2_hc3|.2f]]</td></tr>
      </tbody>
    </table>
    <p class="note">Standard errors are heteroskedasticity-robust (HC3), which lets each
      observation carry its own residual spread instead of assuming one spread for all. The
      classical formula gives a narrower slope interval ([[fit.slope_lo|.3f]] to
      [[fit.slope_hi|.3f]]) at p&nbsp;=&nbsp;[[fit.p2|.4f]]. The two disagree about the slope
      for a reason that is stated in the paragraph below, because it is a person, not a
      formula. The step is significant on either (robust p&nbsp;=&nbsp;[[fit.p1_hc3|.4f]]).</p></div>

    <p>
      <b>The answer is both, and the step is much the larger of the two for almost everybody.</b>
      Arriving in Congress is associated with [[fit.b1_step|.2f]] percentage points<a class='gl' href='#g-points' aria-label='what &quot;percentage points&quot; means' title='what &quot;percentage points&quot; means'>?</a> of corporate
      and trade PAC share. Each further year adds about [[fit.b2_slope|.3f]] of a point &mdash;
      small, and <b>how sure you can be of it depends almost entirely on one person.</b> On the
      robust formula the slope&rsquo;s interval ([[fit.slope_hc3_lo|.3f]] to
      [[fit.slope_hc3_hi|.3f]]) includes zero, at p&nbsp;=&nbsp;[[fit.p2_hc3|.2f]]. But
      decompose that uncertainty by candidate and [[fit.slope_hc3_share_top|.0%]] of it is
      [[fit.drop_name]] alone &mdash; the longest career on the chart, sitting far below the
      line &mdash; while the [[money.n_non]] never-served candidates,
      all at zero years, contribute exactly [[fit.slope_hc3_share_never|.0%]], because a person
      with no tenure carries no information about a per-year rate. Refit without her, on the
      same robust formula, and the slope is [[fit.b2_slope_drop|.3f]] a year, interval
      [[fit.slope_drop_hc3_lo|.3f]] to [[fit.slope_drop_hc3_hi|.3f]],
      p&nbsp;=&nbsp;[[fit.p2_drop_hc3|.4f]]. A pairs bootstrap over everyone, her included,
      gives [[fit.slope_boot_lo|.3f]] to [[fit.slope_boot_hi|.3f]] and lands above zero
      [[fit.slope_boot_frac_pos|.0%]] of the time. So the honest reading is <em>probably
      positive; whether it is &ldquo;significant&rdquo; is decided by one 44-year career</em>,
      and she stays in, because this page keeps her in everywhere else and does not get to
      drop her here. <b>Corrected 2026-09-01, and again 2026-09-02:</b> an earlier build called the slope
      &ldquo;measurable&rdquo; on the classical p&nbsp;=&nbsp;[[fit.p2|.4f]]; the first
      correction replaced that with the robust figures but blamed the wide interval on the
      &ldquo;floor of zeros&rdquo; among the never-served, which a reviewer showed is wrong
      &mdash; the zeros contribute nothing to it; she does. This is the second correction. The
      step was never in doubt on any formula. The
      seniority effect only catches up with the arrival effect after
      <b>[[fit.crossover|.0f]] years</b> of service, and <b>[[fit.n_above_crossover]]</b> of the
      [[fit.n]] candidates in the frozen file has served that long. So for 111 of 112 people
      here, &ldquo;the pattern&rdquo; means the step. My August headline was about the slope.
      Together the two explain [[fit.r2|.0%]] of the variation.
    </p>

    <p>
      <b>What this does not say.</b> No causal claim is made or will be made from it. There are
      no controls for committee position, majority status, seat safety, district industry mix or
      total receipts, and a cross-section of [[fit.n]] candidates in one cycle cannot separate
      &ldquo;serving attracts corporate money&rdquo; from &ldquo;candidates who attract corporate
      money win and stay&rdquo;. It describes a shape. It does not explain it.
      Removing [[fit.drop_name]], the highest-leverage point<a class='gl' href='#g-leverage' aria-label='what &quot;leverage, and influence&quot; means' title='what &quot;leverage, and influence&quot; means'>?</a>, moves the step to
      [[fit.b1_step_drop|.2f]] and the slope to [[fit.b2_slope_drop|.3f]] &mdash; she is holding
      the slope down, and this is printed for the same reason her point is circled and kept in.
      <b>This model is not registered and is not scored.</b> It was fitted on 30 August, after
      the register froze, so it cannot be a prediction and is not being presented as one. A5
      below still stands, still retracted, still scored on the test I actually wrote.
    </p>

    <p>
      The correlation is low because the scatter<a class='gl' href='#g-scatter' aria-label='what &quot;scatter (residual spread)&quot; means' title='what &quot;scatter (residual spread)&quot; means'>?</a> is [[money.scatter|.2f]] points, not
      because the gradient is small. That is what a correlation could never have told me, and
      what two coefficients say in one line.
    </p>
''')

    # ── why this page ───────────────────────────────────────────────────────
    a('''    <p>
      How that number fell from 0.82 to what it is now &mdash; and how I spent a day claiming
      the cause could not be determined when the answer sat in my own records &mdash; is the
      subject of a companion piece:
      <a href="/investigations/the-headline-i-never-re-ran.html">The Headline I Never
      Re-Ran</a>. It is the follow-up, not the introduction. Everything you
      need in order to judge these predictions is on this page.
    </p>

    <h2>Why the bets are still here</h2>
''')
    a('''    <p>
      A prediction made <em>after</em> you have seen the data is a description wearing a costume,
      and from the outside the two look identical. The only fix is to write the claim down first,
      in public, with a date and a number on it. So each card below carries a threshold, an exact
      condition that would make it false, and a stated confidence. On 31 October each one gets
      marked hit or miss on this page, with the actual value beside it, and nothing above gets
      rewritten.
    </p>

    <p>
      Which brings us to the [[C.n_lost_w]] I have already lost. The 0.82 was never a forecast &mdash; it was
      a claim about data I already had, so it gets corrected. <b>And the honest word for what
      was wrong with it is </b><em>stale</em><b>, not false.</b> The reconstruction on the
      companion page recovers all seven August figures exactly by restricting to the
      [[august.n]] candidates coded when they were computed &mdash; so 0.82 was true of the
      people it was computed over, and stopped being true as twenty more arrived and nobody
      re-ran it. Declared 2026-08-30 because the distinction cuts against me: calling it
      &ldquo;false&rdquo; sounds worse and lets the four dead bets read as bad forecasts. They
      are not bad forecasts. They are one vintage decision, counted four times, and the register
      is harsher on me than the facts are. The predictions built on top of it <em>are</em> forecasts, and a forecast you
      rewrite after learning it loses is not a forecast. So A1a, A1b, A3 and A4 stand word for word
      at 92%, 88%, 65% and 70%, and they will be marked missed in the same table and the same
      denominator as everything else. The corrected versions are registered separately and scored
      too. The real penalty is specific and computed: freezing the four dead bets rather than
      deleting them costs [[score.brier_cost_of_freezing|.4f]] Brier &mdash; and no more than
      that: it is the cost if every remaining prediction hits, and it shrinks as they miss, so
      it is a ceiling, and the two scores that follow are ceilings on the same assumption. <b>With the base, which I had not printed:</b> delete the four and the score is
      [[score.brier_if_deleted|.4f]]; freeze them and it is [[score.brier_frozen|.4f]]. So the
      penalty is not a rounding item, it is roughly double. Lower is better on this scale.
      An earlier version of this paragraph claimed the mistake was &ldquo;charged twice&rdquo;;
      v4 retracted that as arithmetically backwards, and it stays retracted.
    </p>
''')

    # ── the four lost ───────────────────────────────────────────────────────
    _lost = [x for x in P if x[2] == "lost"]
    _meas = [x for x in _lost if x[0] not in CONCEDED]
    _conc = [x for x in _lost if x[0] in CONCEDED]
    a(f'    <h2 id="lost">The {WORD[len(_lost)].lower()} I have already lost</h2>\n')
    a(f'''    <p>
      Priced on 22 August against figures &mdash; 0.82 across everybody, 0.70 among those who
      had served &mdash; that the data does not support. Frozen here at the confidence I gave
      them. <b>{WORD[len(_meas)]} of the {WORD[len(_lost)].lower()} are measured;
      {WORD[len(_conc)].lower()} are conceded.</b> {", ".join(x[0] for x in _meas)} are
      computed against the frozen data and land outside the bands they registered, and the
      figures are on their cards. {" and ".join(x[0] for x in _conc)} are not computed and
      cannot be from this data &mdash; the index codes no chair or ranking-member field, and
      the frozen finance extract is the 2026 cycle only. I concede them because the figure
      they were priced against was wrong and I do not expect them to survive, which is an
      argument, not a result. They are badged &ldquo;conceded, not measured&rdquo; and they
      count as misses in every total on this page, because conceding a forecast I would
      otherwise have to score is not a way to avoid scoring it &mdash; a reviewer noticed the
      page reported an argument in the same words as a measurement.
    </p>
''')
    a(f'    <ul class="pred-list" role="list" aria-label="The {len(_lost)} predictions already lost">')
    for p in _lost:
        a("      " + card(p, li=True))
    a('    </ul>')
    a("")

    # ── the replacement + live headline cards ───────────────────────────────
    a('''    <h2 id="universe">The ruling that decides [[rule.n_flips]] of the verdicts</h2>

    <div class="caveat">
      <span class="caveat-label">Registered 24 August, before October can see the answer</span>
      <p>
        Three of the [[roster.n_districts]] districts &mdash; OH-09, TX-28, TX-34 &mdash; sit outside
        the selection rule the frozen specification prints. That rule, stated here because it is
        the most loaded choice in the whole index and it had been left in a spreadsheet: a district
        qualifies if the 2024 presidential margin was 10 points or less, or the 2024 House margin was 10 points or less &mdash; <em>or less</em>, not <em>under</em>: two districts, NY-01 and OH-15, sit at exactly 10.0 and are in on that boundary.
        These three clear neither &mdash; presidential margins [[offrule.OH_09|.1f]], [[offrule.TX_28|.1f]]
        and [[offrule.TX_34|.1f]] points, with House margins not comparable after redistricting, so neither leg of the
        rule is satisfied. They were added as marquee races; the flag that was supposed to accompany
        them was never published. That matters because the choice of universe<a class='gl' href='#g-universe' aria-label='what &quot;universe&quot; means' title='what &quot;universe&quot; means'>?</a> now
        <em>chooses verdicts</em>: under the strict rule the pooled correlation is
        [[rule.r_pool|.3f]] instead of [[money.r_pool|.3f]] and the incumbents-only figure is
        [[rule.r_inc|.3f]] instead of [[money.r_inc|.3f]] &mdash; enough to flip A1b from a
        near-certain miss to a hit, A1a&prime; from a hit to a miss, and A1b&prime; from a hit
        to a miss, its band running to [[rule.a1bp_hi|.4f]] against a rule-compliant
        [[rule.r_pool|.3f]]. <b>Corrected 2026-08-30:</b> until today this paragraph said the
        ruling decided <em>two</em> verdicts and named only the first two. A1b&prime; was
        computed by no script and named in no document. Counting it, the universe I chose is
        one prediction better for me than the alternative rather than the wash it was presented
        as &mdash; which is the same failure the glossary already confesses about the Fisher-z
        bands: computing some and printing fewer.
      </p>
      <p>
        So the ruling is made now, blind: <b>every money prediction is scored on the as-published
        59-district universe</b> &mdash; the one the bets were priced against &mdash; and the
        rule-compliant figures are computed by the same script and published beside every verdict,
        unscored and labelled. Nobody, me included, gets to pick a universe in October after seeing
        which one flatters.
      </p>
    </div>
''')

    a('''    <div class="caveat">
      <span class="caveat-label">Who scores this &mdash; the thing all of the above does not fix</span>
      <p>
        Everything on this page secures the <em>inputs</em>. The register is frozen, the data is
        frozen, the digests are printed, the record is timestamped by a third party, and
        [[C.n_gates_w]] build gates refuse to ship a page that drifts from any of it. <b>None of that touches the
        verdicts.</b> On 31 October I mark 25 predictions hit or miss, on my own site, with my own
        scripts. There is no external adjudicator, no pre-committed referee, and no arbitration
        if someone disagrees with a call. The scoring maps are written down and frozen precisely
        to make that judgment as small as possible &mdash; but small is not none, and the person
        checking whether I applied them is me.
      </p>
      <p>
        The honest mitigation available to a reader is that every scored figure recomputes from
        the published bundle, so a disputed verdict can be checked by running the same script
        against the same rule. The honest limitation is that nothing compels me to accept the
        answer. Stated here, in advance, because a reviewer asked who scores this and the page
        had no answer anywhere.
      </p>
    </div>

    <div class="caveat">
      <span class="caveat-label">The specification moved after the register froze &mdash; declared 2026-08-30</span>
      <p>
        The register froze on 24 August. The document that decides every verdict &mdash; the
        analysis specification &mdash; was reissued on <b>30 August, six days later</b>, and it
        changed five scoring rules that its own summary does not mention. An independent reviewer
        found this by diffing the two versions. I did not disclose it because I had not noticed
        it, which is worse. <b>Four of the five run in my favour or cost me nothing; the fifth, D1&rsquo;s narrowed event list, runs against me</b> &mdash; fewer things count as a change, so the threshold is harder to reach &mdash; and the spec says so.
        None is reverted: reverting after seeing the consequences is the move this page exists to
        refuse. They are printed instead, and priced.
      </p>
      <p>
        <b>G1 is the serious one, and it turns a bet into a foregone conclusion.</b> The
        registered claim says the veteran-share difference stays non-significant
        &ldquo;<em>when extended beyond the battleground set</em>.&rdquo; Version 1 of the
        specification scored it on &ldquo;the extension set.&rdquo; Version 2 scores it on
        &ldquo;the roster&rdquo; &mdash; which is the battleground set, the very thing the claim
        says to go beyond. On the roster the answer is already in hand:
        <b>p = [[roster.G1.p|.4f]]</b>, a hit. Under any real extension it misses; at twice the
        sample the same rates give p &asymp; 0.03. So a prediction I priced at 62% became a
        near-certain hit by a definition written after the freeze. <b>It is scored as the
        specification now says, and the hit is worth nothing.</b> Read it as a defect in my
        drafting, not as a forecast that came in.
      </p>
      <p>
        The other four. <b>D1</b>: the qualifying events dropped from six to four (death and
        disqualification removed) and the window changed from &ldquo;dated between 2026-08-22 and
        2026-11-03&rdquo; to &ldquo;after 2026-08-21&rdquo; &mdash; the same start day, worded differently, no
        end date, and &ldquo;dated&rdquo; deleted, so it no longer says whether the date is when
        the change happened or when I found out. <b>E1</b>: &ldquo;an age meeting rule G4 of the
        evidentiary standard&rdquo; became &ldquo;meeting the frozen evidentiary standard, not
        appearing on a data broker&rdquo; &mdash; a named rule replaced by a gesture, on the card
        I most control. <b>F2</b>: the commitment to publish the <em>Murphy decomposition</em>
        was dropped. That is the only listed statistic that separates whether my confidences are
        <em>unbiased</em> from whether they carry any <em>information</em>, and with four dead
        bets at high confidence it is the one that would look worst. <b>I am reinstating it:</b>
        the decomposition will be published in October whatever it says. <b>&sect;5.5</b>:
        &ldquo;no prediction is withdrawn after 2026-08-22&rdquo; became &ldquo;after
        registration,&rdquo; which retroactively makes an earlier retirement a violation of a
        rule written later.
      </p>
      <p>
        <b>One thing to know when you read the specification:</b> its thresholds are frozen
        literals &mdash; they are the claim &mdash; but its <em>basis</em> figures are
        measurements labelled &ldquo;as at issue&rdquo;, meaning as at 30 August. Where a coding
        correction has moved one since, the specification keeps the older number and this page
        carries the newer one. G2 is the live example: the binding v3 spec prints the basis it had
        at issue, [[roster.G2_broad.R_yes]] Republicans to [[roster.G2_broad.D_yes]] Democrats, and the card now prints
        [[roster.G2.R_yes]] to [[roster.G2.D_yes]], because on 2 September the spec&rsquo;s own enumeration of what counts was
        applied to the count and it excludes a pending suit the broad count had included; the
        verdict is the same at p&nbsp;=&nbsp;[[roster.G2.p|.2f]]. That is the design,
        not a contradiction &mdash; but two independent reviewers read it as one, so it is worth
        saying here rather than leaving in a header.
      </p>

      <p>
        Nothing here is being corrected in place. Both specification versions are published,
        hashed, and anchored in the timestamp above, so the diff that produced this paragraph can
        be run by anyone.
      </p>
    </div>

''')
    a('    <h2 id="headline">The three that carry the most weight</h2>\n')
    a('''    <p>
      The replacement finding, a bet against something else I have already published, and a bet
      about how often I expect to be wrong.
    </p>
''')
    a('    <ul class="pred-list" role="list" aria-label="The three predictions carrying the most weight">')
    for pid in ("A5", "B1", "F2″"):
        a("      " + card(next(x for x in P if x[0] == pid), li=True))
    a('    </ul>')
    a("")

    # ── full register ───────────────────────────────────────────────────────
    shown = {"A5", "B1", "F2″"} | {x[0] for x in P if x[2] == "lost"}
    rest = [x for x in P if x[0] not in shown]
    gloss = ('''    <details class="register" id="glossary" open>
      <summary><span>What the words mean</span><span class="reg-count">plain definitions</span></summary>
      <div class="register-body">
      <p class="gloss-back">
        <a href="#main">&#8617; back to the top of the page</a> &middot; or use your
        browser&rsquo;s back button to return to the exact card you came from.
      </p>
      <p>
        This box is open on purpose: every &ldquo;?&rdquo; on the page jumps straight to a
        definition inside it, and a box you had to open first would break that jump. These are
        definitions and nothing else. Where the honest definition is less flattering to this
        page than the usual one, the honest one is here, and it is marked.
      </p>

      <h3 class="gloss-group">Words about this register</h3>
      <dl class="glossary">
        <dt id="g-register">register</dt><dd>Used on its own, on this page, it always means one
          thing: the list of predictions, written down and dated before the
          <em>October</em> data existed. Some of them, the money ones, were written around figures
          already measured in August &mdash; that is stated on the cards, and it is why several
          of them are called easy there. <b>Corrected 2026-09-02:</b> this entry used to say the
          stated confidence had never changed. That is false against the frozen v2 register,
          which says on its own first page that nine predictions were re-priced on 22 August,
          most of them upward, after a reviewer found the easy ones priced below their true
          odds in a pattern that would have flattered the scorecard &mdash; A1 from 80% to 92%
          and 88% on its two halves, D1 from 75% to 88%, F1 from 80% to 88%, among them. That
          re-pricing happened once, the same day, before any October data existed, with its
          reasons written in v2; <em>since v2 no stated confidence has changed</em>, the build
          checks every card against the frozen register, and nothing has ever been deleted.
          Claims and fail conditions <em>were</em> amended, in versions
          two through five, and every amendment is on the card that carries it &mdash; B1 and
          B3 both say so. Since v5 froze on 24 August nothing has been rewritten; corrections
          since are added below the claim, dated, never over it. Five versions exist and all five are published. Where the word
          instead means <em>how formal someone&rsquo;s writing is</em>, it never appears alone
          &mdash; it is always <em>social register</em>, <em>register gap</em> or
          <em>social cells</em>, all defined further down.</dd>
        <dt id="g-universe">universe</dt><dd>Which people or races a number is computed over.
          Most arguments about a statistic on this page are arguments about its universe rather
          than about its arithmetic: the same pooled correlation is [[money.r_pool|.3f]] on the
          59 districts as published and [[rule.r_pool|.3f]] on the districts that actually meet
          the selection rule. That is why the universe is ruled on in writing, in advance.</dd>
        <dt id="g-pooled">pooled</dt><dd>Everybody together &mdash; the [[money.n_inc]] candidates
          who have served in Congress and the [[money.n_non]] who never have, in one calculation.
          <em>Incumbents-only</em> means the [[money.n_inc]] alone. Nearly every pair of numbers on
          this page that looks like a contradiction is one of each: pooled
          [[money.r_pool|.3f]] against incumbents-only [[money.r_inc|.3f]] is not a disagreement,
          it is two different populations.</dd>
        <dt id="g-n">n</dt><dd>How many cases a number was computed from. <em>n&nbsp;=&nbsp;4</em>
          means four. It is the first thing to look at on any card here, because most of the weak
          predictions on this page are weak for that reason alone.</dd>
        <dt id="g-coding">coding, coder, coded</dt><dd>Nothing to do with programming. Coding is
          reading the sources for one candidate and recording what they say against a written
          rulebook &mdash; occupation, service, legal record &mdash; so that two coders following
          the same rulebook would write down the same thing. A separate, blind AI coding session
          &mdash; not a second person &mdash; was given the rulebook and nothing else. <b>The number, with its base:</b> agreement was
          [[val.agree]] of [[val.n]] sampled decisions &mdash; about ten candidates, not the whole index &mdash;
          and it was measured against version 1.0 of the rulebook, not the 1.1 in force. Faith
          was the weakest field by a distance, carrying three of the four disagreements.
          <b>Corrected 2026-09-02:</b> this entry used to say the
          cases where the two codings differed were not published. They are: all four are listed
          by candidate and field, with both codings, in VALIDATION_RESULTS.md in the bundle. What
          is <em>not</em> published is the blind coder&rsquo;s full 50-decision sheet, so the 46
          agreements are the part you take on the summary.</dd>
        <dt id="g-falsifier">falsifier</dt><dd>The written condition that turns a prediction into
          a miss, fixed before the answer can be known. Every card carries one, under
          <b>Fails if</b>.</dd>
        <dt id="g-conjunct">conjunct</dt><dd>One of the conditions joined by &ldquo;and&rdquo;
          inside a single claim. If a claim has two conjuncts, both have to hold for it to be
          true &mdash; so a falsifier that tests only one of them is easier than the claim it is
          meant to police. One card here still has it &mdash; E3, whose claim is that none of
          four things happen and whose fail condition needs two &mdash; and that card says so.</dd>
        <dt id="g-instrument">instrument</dt><dd>Two different things on this page, and the
          sentence around it tells you which. (1) The measurement script &mdash; the code that
          turns filings or text into a number. (2) This whole scoring exercise, considered as a
          way of measuring how well calibrated I am; in that second sense it is not a real
          instrument yet, because two dozen predictions cannot separate a good forecaster from a
          lucky one.</dd>
        <dt id="g-null">the null, and failing to reject it</dt><dd>The null is the assumption that
          there is no difference. When a test does not find one, the only thing it can report is
          that it <em>failed to reject</em> the null &mdash; &ldquo;no difference showed
          up&rdquo;, not &ldquo;there is no difference&rdquo;. A small or noisy sample fails to
          reject almost anything, so treating that failure as proof of sameness pays a reward for
          weak evidence. The equivalence test below is the fix. <b>Not all of this page is
          fixed:</b> B1 and B3 carry an equivalence conjunct, and G1 and G2 do not &mdash; they
          score a hit on a test that merely failed to find something. B1 and B3 were fixed while the register was still
          open; G1 and G2 were not, and the register froze on 24 August, so they cannot be
          fixed now. The defect is declared on each card instead. (E3 has the same
          shape of problem for a different reason and no test at all; its card explains itself.)</dd>
        <dt id="g-significance">significance, and two-sided</dt><dd>Calling a result
          <em>significant</em> here means only that its p-value fell below 0.05. It is not a claim
          that the difference is large or that it matters. <em>Two-sided</em> means the test counts
          a difference in either direction, so a prediction cannot quietly be scored on whichever
          half of the possibilities suits me.</dd>
        <dt id="g-identification">identification strategy</dt><dd>The full argument for why a
          measured association is the thing you say it is, rather than something else moving both
          numbers at once. Holding one yes-or-no variable constant is not one, and this page says
          so on the card where it does exactly that.</dd>
      </dl>

      <h3 class="gloss-group">Words about scoring my own confidence</h3>
      <dl class="glossary">
        <dt id="g-brier">Brier score</dt><dd>A score for stated confidences, running from 0 to 1,
          where lower is better and 0 would be perfect. Say 90% and be right and it costs 0.01;
          say 90% and be wrong and it costs 0.81. The rule it replaced could be improved by
          shading every number downward without changing a belief; this one cannot, because it
          rewards saying what you actually think. <b>The honest qualifier, added 2026-08-30:</b>
          that only holds if what you think is right. A forecaster who knows he is overconfident
          &mdash; and F2&Prime; on this page bets 93% that I am &mdash; would score better by
          shading toward 50%, because he would be moving toward the truth rather than away from
          it. So the rule stops a bare gaming move and does not stop that one. It is a reason to
          read &acirc; beside the Brier, not instead of it.</dd>
        <dt id="g-calibration">calibration, and the shift &acirc;</dt><dd>Calibration is whether
          &ldquo;80% sure&rdquo; comes true about 80% of the time. &acirc; is a single number for
          which way I lean: negative means I was more confident than I was right. It is fitted on
          [[score.n_fit]] of the 25 &mdash; F2&prime; and F2&Prime; are left out, because they are
          predictions <em>about</em> &acirc;, and a statistic that included them would be scoring
          itself. It measures the lean only, not whether the predictions were any use.</dd>
        <dt id="g-logodds">log-odds</dt><dd>Confidence rescaled so that 50% sits at zero and every
          doubling of the odds is the same-sized step wherever you start from. Confidences are
          compared this way so that the distance from 90% to 95% is not treated as smaller than
          the distance from 50% to 55%.</dd>
        <dt id="g-laplace">Laplace rule, Beta(1,1), black-box</dt><dd>Standard ways to turn
          &ldquo;it has happened every time so far&rdquo; into a probability for next time without
          assuming the streak holds. <em>Beta(1,1)</em> is the flat starting assumption that
          treats every underlying rate as equally likely before you look, and <em>black-box</em>
          means the number is computed from the bare count alone, ignoring everything I know about
          why the run happened. <b>Always ask how far ahead.</b> The same 23-out-of-23 record gives
          96% for the very next case and 19% for the next hundred in a row, and the second number
          is the one printed on B2.</dd>
      </dl>

      <h3 class="gloss-group">Words about the money and the record</h3>
      <dl class="glossary">
        <dt id="g-pac">PAC, and &ldquo;corporate and trade&rdquo;</dt><dd>A political action
          committee is an organisation that pools donations and gives them to campaigns.
          <em>Corporate and trade</em> is read off the committee type in the federal filings
          rather than judged case by case: company committees, industry-association committees.
          Labour committees, party committees, leadership PACs and independent expenditures are
          excluded. <b>One judgment call, since the filing codes do not make it for me:</b> the
          corporate bucket includes committees of corporations without capital stock &mdash;
          mutuals, non-profit hospitals and the like &mdash; which are not what most readers
          picture. The exact codes are in the specification so the choice can be reversed by
          anyone who disagrees.</dd>
        <dt id="g-fec">FEC</dt><dd>The Federal Election Commission, the agency campaigns file
          their finance reports with. <em>FEC bulk data</em> is the raw downloadable form of
          those filings. Where this page says the FEC codes someone as a challenger, that is the
          filing&rsquo;s own status field &mdash; not my classification, and occasionally not
          what the person&rsquo;s career would lead you to expect.</dd>
        <dt id="g-legal">recorded legal matter</dt><dd>Anything appearing in a public court or
          regulatory record: a charge, a suit, a finding &mdash; <em>and its outcome</em>. It is
          deliberately broad and deliberately not a synonym for wrongdoing. A dismissal and an
          acquittal are recorded legal matters too, and wherever a charge is printed here the
          outcome is printed with it. <b>One boundary the definition does not state and the
          practice draws:</b> litigation a candidate was party to only in an official capacity
          &mdash; sued as mayor, as county judge, as a state officer &mdash; is recorded in the
          row&rsquo;s notes, not in the <code>legal</code> column that G2 is scored on, unless
          it ended in an admission or a settlement by the person. That keeps G2 a count of
          personal matters; it is a choice, it is applied to both parties, and G2 comes out the
          same either way at p&nbsp;=&nbsp;[[roster.G2.p|.2f]]. <b>And the floor under
          the whole column:</b> &ldquo;None found&rdquo; means nothing surfaced in a general-press
          search. Court dockets were consulted for a handful of rows that already had a matter
          in the press; [[roster.n_no_docket_search]] of the 115 rows say in their own detail
          that no docket search was performed. So the count G2 is scored on is a press-surfaced
          floor, not a court-record census, and press depth is greater for sitting members,
          who are most of the rows counted.</dd>
      </dl>

      <h3 class="gloss-group">Words about the writing measure</h3>
      <dl class="glossary">
        <dt id="g-reading-grade">reading grade</dt><dd>Roughly the US school year at which the
          writing becomes easy to read. Grade 8 is a newspaper; grade 14 is a legal filing.
          <b>Watch for levels versus differences,</b> the same trap as percentage points: a
          margin of &ldquo;1.0 reading grades&rdquo; means one school year of <em>difference</em>
          between two groups, not writing pitched at first grade. It is computed from sentence
          length and syllable count &mdash; syllables set most of the level, but
          <b>sentence length is what a missing full stop moves</b>
          &mdash; which is why a missing full stop is not a cosmetic problem here. Samples that
          ran together across a missing full stop are exactly what moved B4&rsquo;s median from
          [[style.cand_median_shipped|.2f]] to [[style.cand_median_fixed|.2f]], through the
          threshold it was registered against.</dd>
        <dt id="g-social-register">social register</dt><dd>How formal a politician&rsquo;s writing
          is in their own social posts, measured in reading grades. Nothing to do with the
          register of predictions above, and nothing to do with any social directory.</dd>
        <dt id="g-register-gap">register gap</dt><dd>How far a politician&rsquo;s writing moves
          between a formal quotation and a social post, in reading grades. A gap of 3 is about
          three school years of difference between the two.</dd>
        <dt id="g-cells">social cells, and the minimum</dt><dd>A cell is one candidate&rsquo;s
          collected sample of one kind of writing. A cell holding fewer samples than the frozen
          protocol requires is reported as insufficient corpus and that candidate drops out of
          that measure &mdash; because a reading grade computed from four posts is a number, but it
          is not a measurement. What does <em>not</em> happen is the whole prediction quietly
          becoming unscoreable: if too few candidates clear the minimum, the writing predictions are
          scored as misses, so attrition costs me rather than excusing me.</dd>
      </dl>

      <h3 class="gloss-group">Words about the statistics</h3>
      <dl class="glossary">
        <dt id="g-pearson">correlation (Pearson r)</dt><dd>One number from &minus;1 to +1 for how closely
          points follow a straight line <em>that tilts</em>. The tilt is not optional: points
          packed tightly along a flat line score zero, not one. Around 0.3 is a loose tendency;
          above 0.7 is a tight one. <b>Two things it is not.</b> It is not a measure of size: r is unit-free,
          so a relationship can move a great distance and still score low if the points scatter
          widely around the line &mdash; which is the case here, and reaching for a correlation to
          make a claim about size is the specific mistake this project made twice. And it says
          nothing about cause.</dd>
        <dt id="g-step">step</dt><dd>A jump between two groups: everyone on one side of a
          line sits at about one value, everyone on the other side at about another, and the
          distance between the two is the step. Here it is the gap between candidates who have
          never served in Congress and candidates who have &mdash; [[money.step|.2f]] percentage
          points. A step says arriving is what matters and the years afterward do not.
          <b>Step or slope was the open question on this page until it was fitted</b>: the regression
          section answers it &mdash; both, with the step far the larger for almost everyone &mdash; and the
          claim that it was mostly a step is registered below as A5 and retracted, because it
          was tested with a tool that could not answer it.</dd>
        <dt id="g-regression">regression, and what these two coefficients mean</dt><dd>A
          correlation is one number and answers one question: how tightly do two things move
          together. A <em>regression</em> fits several effects at once and gives each its own
          number, so it can answer &ldquo;how much of this is arriving, and how much is
          staying?&rdquo; &mdash; which a correlation structurally cannot. Here two things are
          fitted: an on/off term for having served in Congress at all, and a count of years
          served. The first is the <em>step</em>, the second the <em>slope</em>. Each comes with
          an interval, which is the range the data cannot rule out; when that range excludes
          zero, the effect is distinguishable from nothing. <b>Two numbers on this page are both
          called the step, and they are not the same:</b> the plain difference between the two
          group averages is [[money.step|.2f]] points ([[money.mean_inc|.2f]]% against
          [[money.mean_non|.2f]]%), while the fitted step is [[fit.b1_step|.2f]] &mdash; the
          jump at <em>zero</em> years served. The gap between them is the slope working on the
          [[money.tenure_mean|.1f]] years the served average: [[fit.b1_step|.2f]] plus
          [[fit.b2_slope|.3f]] &times; [[money.tenure_mean|.1f]] is roughly the raw
          [[money.step|.2f]]. Neither is wrong; they answer different questions, and the raw
          gap folds some seniority into what it calls arrival. It took an editor rather than a
          statistician to point out that this was the tool the question needed, after three days
          of arguing about it with a correlation. <b>Fitting is not explaining.</b> Nothing about
          which way the arrow points follows from any of it.</dd>
        <dt id="g-slope">slope, and the fitted line</dt><dd>The <em>fitted line</em> is the single
          straight line drawn through a cloud of points to sit as close to all of them as it can.
          Its <em>slope</em> is how steeply it rises: here [[money.slope|.3f]] percentage points
          of corporate money for each additional year in Congress, fitted through the
          [[money.n_inc]] who have served rather than through all [[money.n_rows]] points on the
          chart. Multiply it by the span those [[money.n_inc]] actually cover &mdash;
          [[money.tenure_min|.1f]] to [[money.tenure_max|.1f]] years &mdash; and you get how far
          the line climbs. A slope says every extra
          year adds something. <b>Step or slope was the open question on this page</b>; the regression
          section settles it as both, the step much the larger, with the slope&rsquo;s
          significance hanging on one career.</dd>
        <dt id="g-scatter">scatter (residual spread)</dt><dd>How far the points typically sit from
          the line, in the units of the thing being measured. Here it is
          [[money.scatter|.2f]] percentage points, and it is most of the reason the correlation
          is low: the line does rise, but individual candidates land nowhere near it. Not all of
          the reason &mdash; deleting one point on this page lifts the correlation by half again
          while barely touching the scatter, so how much tilt there is matters too. A correlation
          answers &ldquo;how tight&rdquo;; this answers &ldquo;how far off&rdquo;, and for a reader
          deciding whether a pattern is any use, the second question is usually the one that
          matters.</dd>
        <dt id="g-points">percentage points</dt><dd>The gap between two percentages, as opposed to
          a percentage of a percentage. Going from [[money.mean_non|.2f]]% to [[money.mean_inc|.2f]]% is a
          rise of [[money.step|.2f]] <em>percentage points</em>; calling it a rise of
          [[money.pct_rise|,.0f]]% would be arithmetically true and useless. Every money <em>difference</em> on this page is in percentage points. The levels
          themselves &mdash; &ldquo;[[money.mean_inc|.2f]]%&rdquo; &mdash; are ordinary
          percentages, of a candidate&rsquo;s total receipts.</dd>
        <dt id="g-zero-inflation">zero-inflation</dt><dd>What happens when a large share of the
          cases sit at exactly zero rather than being spread out. Here
          <b>[[money.n_zero]] of [[money.n_rows]]</b> candidates take no corporate money at all
          &mdash; [[money.pct_zero|.0f]]% &mdash; and every one of them is someone who has never
          served. A correlation computed across that pile plus everybody else can be produced
          largely by the gap between the pile and the rest while looking like a steady trend, which
          is why the pooled and incumbents-only figures are always printed as a pair. <b>Corrected
          2026-08-30:</b> this entry first printed [[money.n_non]], which is how many candidates
          have never <em>served</em> &mdash; their years in Congress are zero, their money is not.
          [[money.n_non]] is a real computed figure, correctly used elsewhere on both pages;
          the error was substituting the count of one thing for the count of another, which
          overstated the pile by [[money.zero_overstate_pct|.0f]]%. Not a number typed into a
          sentence, then &mdash; a number computed and then pointed at the wrong noun, which is
          the harder version to catch and the reason a build check now compares the two.</dd>
        <dt id="g-spearman">rank correlation (Spearman)</dt><dd>The same idea as the ordinary
          correlation but using only the order of the values, not their size. It survives outliers
          that would drag the ordinary kind around. Where the two disagree, look for one of two
          causes: a handful of extreme points, or a step in the data. On this page both are at work at
          once, and the arithmetic splits them. Among those who have served, removing the one
          far-out career shrinks the gap between the two measures from [[money.sp_gap_inc|.3f]] to
          [[money.sp_gap_inc_drop|.3f]] &mdash; nearly all of it was that one person. Across
          everybody the same removal takes it from [[money.sp_gap_pool|.3f]] to
          [[money.sp_gap_pool_drop|.3f]] &mdash; about half of it was that person and about half is
          the pile at zero. <b>Corrected 2026-08-30:</b> this entry first said the pooled gap barely
          moved and put all of it down to the pile. Half of it is one person.</dd>
        <dt id="g-zero-order">zero-order</dt><dd>A correlation with nothing held constant: the raw
          two-variable number, before any control is introduced.</dd>
        <dt id="g-partial">partial correlation</dt><dd>The correlation that remains once a third
          factor is held constant &mdash; here, whether someone chairs or is ranking member on a
          committee.</dd>
        <dt id="g-suppression">suppression</dt><dd>The case where holding a third factor constant
          makes a relationship look <em>stronger</em> instead of weaker. A3 needs the partial
          figure to land well above the raw [[money.r_inc|.3f]], which is why the card treats the
          number going up as the surprising outcome. <b>The honest qualifier:</b> a partial
          correlation is not <em>always</em> smaller than the raw one, and a small rise can fall
          out of the arithmetic of the formula without anything interesting behind it. So the word
          is not the alarm it sounds like. It is a rise of the size A3 asks for &mdash; from
          [[money.r_inc|.3f]] to 0.50 &mdash; that would need a real third variable doing real
          work.</dd>
        <dt id="g-fisher-z">Fisher z</dt><dd>A rescaling of a correlation so that the same-sized
          change means the same thing at 0.3 as it does at 0.9. Bands are set in z because a band
          of fixed width in plain correlation would mean something different depending on where it
          was centred, and I did not want to be choosing that. <b>What it costs, and this is the whole of it:</b>
          a z-band is not symmetric once you convert it back. A1a&prime;&rsquo;s allows
          [[money.r_band_down|.4f]] of downward movement against [[money.r_band_up|.4f]] of upward
          &mdash; [[money.a1ap_ratio|.2f]] to one. A1b&prime;&rsquo;s allows
          [[money.a1bp_down|.4f]] against [[money.a1bp_up|.4f]]. <b>All four bands, 2026-08-30, because two
          rounds of this went wrong.</b> A3&prime;&rsquo;s [0.10, 0.45], set by hand rather than by
          transform, allows [[money.a3p_down|.4f]] down against [[money.a3p_up|.4f]] up &mdash;
          [[money.a3p_ratio|.2f]] to one, the most lopsided here, and in the direction the mechanics
          say the number should go. A4&prime;&rsquo;s [0.10, 0.55] allows [[money.a4p_down|.4f]]
          down against [[money.a4p_up|.4f]] up, the only band on the page leaning the
          <em>other</em> way. Ranked by plain distance from 1.00, [[money.band_least_name]]&rsquo;s band is
          the least lopsided ([[money.band_least_dist|.3f]] from even) and [[money.band_most_name]]&rsquo;s
          the most ([[money.band_most_dist|.3f]]) &mdash; and [[money.band_most_name]]&rsquo;s is hand-set, in
          the direction the mechanics favour. (On a log scale A1a&prime;&rsquo;s comes out
          narrowly least; the plain distance is the one this sentence names, so it is the one
          computed.) <b>Corrected 2026-08-30:</b> I first declared only
          the smallest asymmetry, and two successive wordings of this entry then misdescribed the
          ranking (both are in the source log). Computing four and printing three is exactly how
          selective disclosure happens, so all four are printed. <b>Corrected 2026-09-02:</b> a third
          wording said A1a&prime; was least lopsided &ldquo;by distance from 1.00&rdquo;, which is
          true on a log scale and false on the plain distance it named; the ranking above is now
          computed on that plain distance and printed with the distances, not recalled.</dd>
        <dt id="g-nested">nested samples</dt><dd>Two samples where one sits inside the other
          &mdash; battleground incumbents inside all incumbents, or incumbents inside everybody.
          They are not independent draws, so tests built for two separate groups do not apply and
          no p-value is claimed from comparing them.</dd>
        <dt id="g-leverage">leverage, and influence</dt><dd>Two different things, often confused.
          <em>Leverage</em> is how far a point sits from the middle of the horizontal axis.
          <em>Influence</em> is how much the answer changes when you delete the point. High
          leverage does not by itself mean high influence &mdash; a far-out point sitting right on
          the line leaves the slope alone. It does not leave a <em>correlation</em> alone, though,
          and correlations are what this page bets on: dropping such a point in would push the
          incumbents-only figure up, not hold it still. So leverage is a warning to go and look,
          never a verdict on its own. [[money.lev_name]] is the extreme case of both here: leverage
          [[money.lev_h|.2f]] against [[money.lev_h_next|.2f]] for the next-highest, and deleting
          her moves the incumbents-only correlation from [[money.r_inc|.3f]] to
          <b>[[money.r_inc_drop_lev|.3f]]</b> and the slope from [[money.slope|.3f]] to
          <b>[[money.slope_refit|.3f]]</b> points a year &mdash; the slope is the bigger cost of
          the two, and it is the one the step-versus-slope argument turns on. <b>And the part that costs me most, added
          2026-08-30:</b> deleting her would push the incumbents-only figure to
          [[money.r_inc_drop_lev|.3f]] against A1a&prime;&rsquo;s band top of
          [[money.r_band_hi|.4f]], and the pooled figure to [[money.r_pool_drop_lev|.3f]] against
          A1b&prime;&rsquo;s top of [[money.a1bp_hi|.4f]] &mdash; <em>both live bands broken by the
          removal of one person</em>. That is the number a hostile reader wants and it was the one
          I had not printed. All of it is published, because naming an outlier and keeping it
          proves nothing unless you also print what keeping it costs. She stays in;
          nothing on this page is scored with her removed.</dd>
        <dt id="g-p">p-value</dt><dd>How often a result <em>at least this extreme</em> would turn
          up by chance if there were nothing there. The &ldquo;at least&rdquo; is not a nicety:
          the tests used here add up every outcome as far from the middle as the one observed, and
          a reader who computes the chance of the exact observed result alone will not get the
          number printed. Below 0.05 is the customary line for &ldquo;probably not chance&rdquo;.
          It is not the chance that the claim is true, and it says nothing about how big the effect
          is.</dd>
        <dt id="g-fisher-exact">Fisher exact test</dt><dd>A p-value for a small two-by-two table,
          computed exactly rather than approximated &mdash; the usual choice when the counts are in
          the dozens. <b>Declared, because it runs in my favour:</b> this test is conservative. Its
          p-values come out systematically larger than those of the other exact tests available, and
          both cards here that use it (G1 and G2) score a <em>hit</em> when the p-value stays above
          0.05. I am using the test most likely to hand me the outcome I bet on. It is a defensible
          choice and it is still a choice.</dd>
        <dt id="g-odds-ratio">odds ratio</dt><dd>A ratio of <em>odds</em>, not of chances. If 8
          people in a group of 20 have some trait, the odds are 8 to 12; an odds ratio of 2.0 means
          the other group&rsquo;s odds are twice that. <b>It is always further from 1.0 than the
          everyday &ldquo;X times as likely&rdquo; figure</b> &mdash; not only when a trait is
          common, though the gap grows as it becomes common. On G1 the odds ratio is
          [[roster.G1.OR|.2f]] while the plain-English figure is
          <b>[[roster.G1.RR|.2f]]&times;</b>, and the plainest form of all is
          [[roster.G1.R_pct|.1f]]% against [[roster.G1.D_pct|.1f]]%, which is a gap of
          [[roster.G1.pp_diff|.1f]] percentage points. Read [[roster.G1.OR|.2f]] as if it were the
          everyday multiplier and you overstate that multiplier by
          [[roster.G1.or_over_rr_pct|.0f]]%. <b>Where it matters most:</b> C2 does
          not merely report an odds ratio, it is <em>scored</em> on one &mdash; at least 1.30. On a
          quantity as common as whether a candidate&rsquo;s posts can be collected at all, an odds
          ratio of 1.30 is a difference in rate of about five points, not thirty.</dd>
        <dt id="g-tost">equivalence test (TOST)</dt><dd>An ordinary test can only fail to find a
          difference; an equivalence test can go the other way and rule out any difference larger
          than a stated size &mdash; at the usual error rate, not with certainty. It is how a
          prediction of &ldquo;no effect&rdquo; is made falsifiable instead of unfalsifiable, and
          it needs that size fixed in advance, which is why every such card prints its margin.
          <b>The awkward part:</b> it answers a different question from the ordinary test, so both
          can fire at once &mdash; a difference can be real and small at the same time. On B1 that
          combination is a deserved miss, because the card claims both that there is no difference
          and that any difference is under a grade; a real one falsifies the first half. It is
          worth knowing that the two halves can disagree, and worth not pretending the disagreement
          would be unfair.</dd>
        <dt id="g-ci">bootstrap, and the 95% CI</dt><dd>Re-sample the same data thousands of times,
          recompute the number on each re-sample, and keep the middle 95% of the answers. The
          honest reading is about the procedure and not about this one interval: run it on fresh
          samples and 95% of the intervals it produces would contain the true value. It is
          <em>not</em> a 95% chance that the true value lies inside this particular range &mdash;
          the looser version of that sentence is the one you will usually see, and it is wrong.
          <b>And the 95% is nominal:</b> this is the plain middle-95% form, whose real coverage
          runs below its label on skewed, bounded quantities like a correlation. Read the width,
          which is wide, rather than the label. <b>Not every interval here is a bootstrap,
          and the slope has four:</b> the one on the correlation is a bootstrap. The tenure
          slope of [[fit.b2_slope|.3f]] a year is the SAME coefficient whether it is fitted on
          incumbents alone or in the two-term regression (never-served candidates have zero
          tenure, so only the served can estimate it), but its interval depends on which
          formula is used, and this page prints more than one. Together, so they can be compared:
          the incumbents-only textbook interval is [[money.slope_lo|.3f]] to
          [[money.slope_hi|.3f]]; the pooled textbook interval is [[fit.slope_lo|.3f]] to
          [[fit.slope_hi|.3f]]; the heteroskedasticity-robust (HC3) interval is
          [[fit.slope_hc3_lo|.3f]] to [[fit.slope_hc3_hi|.3f]]; and a pairs bootstrap gives
          [[fit.slope_boot_lo|.3f]] to [[fit.slope_boot_hi|.3f]], wider and lopsided, as a
          bootstrap on this data should be. The textbook forms are symmetric by construction and
          assume one residual spread for everyone; the robust form lets each observation carry
          its own. They disagree here for one reason, and it is not the challengers at zero
          (they have no tenure and so say nothing about a per-year rate): it is that
          [[fit.slope_hc3_share_top|.0%]] of the robust variance comes from
          [[fit.drop_name]], one high-leverage point with a large residual. The robust interval
          includes zero with her in; the bootstrap, which resamples her in and out, does not;
          and without her the robust interval is [[fit.slope_drop_hc3_lo|.3f]] to
          [[fit.slope_drop_hc3_hi|.3f]]. None of the four is &ldquo;the honest one&rdquo;;
          printing all four, and saying which single observation moves them, is. That is why
          the regression section says the slope is probably positive and that one career
          decides how sure you get to be.</dd>
      </dl>
      </div>
    </details>
''')
    a('    <h2 id="register">The full register</h2>\n')
    a(f'''    <p>
      The remaining {len(rest)} predictions in the register<a class='gl' href='#g-register' aria-label='what &quot;register, used on its own&quot; means' title='what &quot;register, used on its own&quot; means'>?</a>, grouped by what they test. Everything registered on 22 August was
      written to the same rules on the same day; everything added on 23 August was written against
      the corrected baseline and is marked as such on the card.
    </p>

    <details class="register" open>
      <summary><span>The other {len(rest)} bets</span><span class="reg-count">open &mdash; click to collapse</span></summary>
      <div class="register-body">
''')
    for g, gname in GROUPS.items():
        grp = [x for x in rest if x[1] == g]
        if not grp:
            continue
        a(f'      <h3>{gname}</h3>')
        a(f'      <ul class="pred-list" role="list" aria-label="{plain_label(gname)}, {len(grp)} predictions">')
        for p in grp:
            a("      " + card(p, li=True))
        a('      </ul>')
    a('      </div>\n    </details>\n')
    a(gloss)

    # ── scoring ─────────────────────────────────────────────────────────────
    a('    <h2>How this gets scored</h2>\n')
    a(f'''    <p>
      On <b>31 October 2026</b> &mdash; a fixed date, not &ldquo;mid-October&rdquo; &mdash; each card
      gets a result badge, <span class="badge badge-hit">Hit</span> or
      <span class="badge badge-miss">Miss</span>, and the actual value printed beside it. The claim,
      the threshold and the confidence stay exactly as written.
    </p>

    <p>
      Calibration is scored with a <b>Brier score</b> <a class='gl' href='#g-brier' aria-label='what &quot;the Brier score&quot; means' title='what &quot;the Brier score&quot; means'>?</a>, which is proper: shading your stated
      confidences downward costs you. The first version of this page used a rule that did the
      opposite &mdash; a reviewer showed that knocking ten points off every number raised the
      &ldquo;well calibrated&rdquo; pass rate sharply, with no change in belief. <b>Declared
      2026-08-30:</b> this sentence used to print that pass rate moving from 0.484 to 0.857.
      Neither figure is computed by any script here &mdash; they came from a reviewer's own
      working &mdash; and the build was quietly exempting both from the rule that every printed
      number must be recomputable. An exemption the reader is not told about is the same defect
      as a number in prose. The figures are removed rather than dressed up; the argument they
      supported does not depend on them, and the rule they broke was replaced. That was
      the one calibration statistic that pays you to understate.
    </p>

    <div class="caveat">
      <span class="caveat-label">What this round cannot tell you, stated before the data arrives</span>
      <p>
        {n} predictions cannot measure calibration, and a good-looking chart would only imply
        otherwise &mdash; which is why the calibration plot that used to sit here has been removed.
        Working the arithmetic out exactly &mdash; and being careful what it is an interval
        <em>on</em>, because nothing has been scored yet, so this is the spread of hit rates a
        perfectly calibrated version of me would produce, not a range for my real accuracy &mdash;
        it runs
        <b>[[score.ci_lo_pct|.1f]]% to [[score.ci_hi_pct|.1f]]%</b>. Because four predictions are
        already known to be misses, those four are set to zero and the other [[score.n_free]] left
        at the confidences I gave them, which shifts the range down &mdash;
        <b>[[score.ci_free_lo_pct|.1f]]% to [[score.ci_free_hi_pct|.1f]]%</b> &mdash; and the best
        score available to me is <b>[[score.max_attainable_pct|.1f]]%</b>.
      </p>

      <p>
        <b>And that interval is too narrow, because it assumes these [[score.n_scored]]
        predictions are independent &mdash; which this page spends five cards proving they are
        not.</b> A5 holds in [[dep.n_both]] of [[dep.n_a1ap]] resamples in which A1a&prime; holds &mdash; every
        one. <b>Corrected 2026-08-30:</b> this sentence read &ldquo;20,000 of 20,000&rdquo;, which
        was the number of resamples DRAWN, not the number in which A1a&prime; held; A1a&prime; holds
        in [[dep.n_a1ap]] of [[dep.N]]. The implication is unchanged at 100%, and the figure was
        still wrong, and it was wrong because it was typed rather than computed. It is now computed
        by <code>figures.py</code> like everything else. A2 is nested
        inside A1a&prime;. All four dead bets trace to one event, the roster completing. B1 to B4
        share a single scoreability floor and fail together. C1 and C2 come from one uncollected
        sweep. F2&prime; and F2&Prime; are arithmetic on the other 23. Group them into the
        [[score.n_clusters]] things they actually measure, let each cluster resolve as one draw
        carrying its members, and the range becomes
        <b>[[score.ci_clustered_lo_pct|.1f]]% to [[score.ci_clustered_hi_pct|.1f]]%</b>. The
        truth is somewhere between that and the figure above; it is not the figure above.
        <b>Declared 2026-08-30, after three independent reviewers raised it separately:</b> the
        limitation I had volunteered was the sample size, and the sample size is the weaker of
        the two problems. Twenty-five dependent predictions are not twenty-five observations, and
        fifty of them would not fix it.
      </p>

      <p>
        <b>And the free hits are not counted anywhere, which is the same failure pointed the
        other way.</b> Four predictions are pre-declared dead and they are in the headline, the
        dateline, the lede and a section of their own. <b>[[score.n_free_hits]] more are
        pre-declared worthless wins</b> &mdash; A5 (&ldquo;it will almost certainly be scored a
        hit &mdash; and that is the problem&rdquo;), G1 (a definition changed after the freeze),
        F2&prime; and F2&Prime; (arithmetic on the other 23), and E3 (a fail condition looser
        than its own claim) &mdash; carrying a mean stated confidence of
        [[score.free_hit_conf|.1f]]%, and they sit in the same denominator. Each is confessed on
        its own card and the total appears nowhere. Counting the losses loudly and the free wins
        quietly is exactly what the glossary calls out one level down: computing four and
        printing three. Declared 2026-08-30, after a reviewer aggregated them and I had not.
      </p>

      <p>
        B4 is an expected miss
        too and is <em>not</em> zeroed here, because its outcome still turns on what October
        collects, where those four are dead against data already in hand &mdash; if B4 lands the
        way I expect, both figures come out lower than the ones printed. This round cannot
        distinguish a well-calibrated forecaster from a badly overconfident one. The instrument
        becomes real somewhere north of fifty predictions. This is round one of counting.
      </p>
      <p>
        <b>Corrected 2026-08-23.</b> This paragraph previously said the interval ran &ldquo;roughly
        47% to 87%&rdquo;. That figure was computed for an earlier nineteen-prediction version of
        the register and carried onto a page of [[score.n_scored]] without being recomputed &mdash;
        the same failure as the correlation, on the page about the correlation. Both numbers here
        are now produced by <code>figures.py</code> at build time and cannot go stale.
      </p>
      <p>
        Two other rules are fixed now rather than later. The writing predictions are scoreable only
        if at least 90 of the 115 candidates yield 5 or more collectable posts &mdash; and
        <b>failing that floor counts as a miss, not a deletion</b>, because attrition is exactly what
        C1 and C2 predict, and letting it delete predictions would let the denominator be chosen on a
        variable correlated with the outcome. <b>Ruled 2026-09-01, the same way:</b> three A-block
        cards depend on data that still has to be collected before 31 October &mdash; A3&prime;
        needs a field coded, A2 the all-435 join, A4&prime; the 2024 extract pulled. The binding
        specification did not say what they score if that work is never done, and a reviewer
        pointed out that silence would let me choose the denominator after seeing the outcome. So
        it is fixed now, against me: <b>a prediction whose data I fail to collect scores a miss, not
        a deletion.</b> This ruling lives on this page and in the page generator, which is inside
        the code commitment; it is anchored when that commitment next confirms, and until then
        it rests on this dated sentence. With that, the only unscoreable outcome anywhere on this page is
        E2 failing to resolve, which is specified on its card.
      </p>
    </div>

    <div class="caveat">
      <span class="caveat-label">Said up front, not in a footnote</span>
      <p>
        <b>The count has changed three times and here is the whole arithmetic.</b> Eighteen
        predictions were registered on 22 August; two were withheld from scoring, for
        reasons set out below, so sixteen were published; a rebuild after review added three
        more, making nineteen; six more were registered on 23 August after the money finding was found to be
        wrong, giving the [[score.n_scored]] scored here. Nothing was deleted at any step. Every
        version is published unedited with its hash at the foot of this page.
      </p>
      <p>
        Two of the original eighteen are withheld from scoring. One predicts whether a specific
        named candidate actively contests his race &mdash; that is commentary about a person, not a
        test of a method. The other depended on access to a government file I may never get, so it
        could not be fairly scored either way. <b>And here a promise failed:</b> this page used to
        say both were published only as hashes, &ldquo;so the count verifies without disclosing the
        content.&rdquo; That was false for both &mdash; their full texts are printed in the v1
        register, a file this page links, hashes and tells you to download. v1 is preserved verbatim,
        so the disclosure cannot be unmade and will not be scrubbed; what changes is the claim. The
        withholding failed, the failure is logged, the retired prediction stays retired, and the
        other is still scored privately &mdash; privately meaning the verdict, not the text, which is
        already public by my own error.
      </p>
      <p>
        Two other numbers move around this page and both are correct. The roster is
        <b>[[roster.n_roster]] candidates</b>, but the money chart plots <b>[[money.n_rows]]</b>:
        [[missing.n]] candidates ([[missing.names]]) have no row in the frozen finance file &mdash;
        one has no federal committee at all, and two have committees whose rows the August bulk
        files misfiled, a mismatch documented in the master coding and expected to self-heal at the
        October refresh. And
        <b>[[money.n_non]]</b> candidates have never served in Congress, of whom
        the FEC codes <b>[[money.n_chal]]</b> as challengers and <b>[[money.n_open]]</b> as
        open-seat candidates. One further thing the chart does not show: at least one plotted
        candidate had suspended his campaign before the roster froze and is expected to withdraw
        formally before the general. He stays in every count here, because the roster is frozen
        and removing people after the fact is how a denominator gets chosen &mdash; but a reader
        counting live candidates should know the number is a frozen roster, not a current one. That [[money.n_chal]] is not the [[money.n_inc]] this page uses
        everywhere else for the candidates who <em>have</em> served: the two are equal by
        coincidence and count different people.
      </p>
    </div>
''')

    # ── verify ──────────────────────────────────────────────────────────────
    # The apparatus block — what anchors the record, what a reader cannot check, and the
    # right-of-reply statement. It used to sit directly under "The finding" heading, so a
    # reader met 35KB of machinery before any result: the editor's 5:1 diagnosis, in page
    # order. Moved here, beside scoring and verification, where a reader who wants it looks
    # for it. Nothing was cut in the move.
    a("""    <p>
      <b>What anchors the dates, and what does not.</b> The frozen record &mdash; every
      register version, the specifications, the rulebooks, the frozen data files &mdash; is
      hashed into commitment files and timestamped onto the Bitcoin blockchain through four
      independent OpenTimestamps calendars. One thing is NOT in the frozen record and should be
      said plainly: the coded index itself (the master coding and the roster it produces, which
      G1, G2, C2, E1 and E3 are scored on) lives in the <em>code</em> commitment, which
      re-anchors at each release, and it was amended after the register froze, in the
      <em>scored</em> legal column four times: two on 30 August (Tijerina&rsquo;s cell
      corrected, declared on the G2 card; Brooks&rsquo;s recoded from a judgment to a pending
      suit on a re-read of the outlet&rsquo;s own wording, declared in the source log and here),
      two on 2 September, both on the G2 card (Leonard recoded to match the rule applied to
      two Republicans; the G2 count made to follow the spec&rsquo;s enumeration, which drops
      Brooks&rsquo;s pending suit from the basis) &mdash; G2&rsquo;s verdict unmoved by any of
      them at p&nbsp;=&nbsp;[[roster.G2.p|.2f]]; and in labels and free text no prediction is
      scored on (the 30 August re-tiering of 30 rows, the Costa/Perry same-day round-trip
      through &ldquo;not established,&rdquo; three tier relabels on 2 September, notes struck
      from [[roster.n_notes_struck]] rows &mdash; all in the sourcing paragraph below). [[C.anchor_state]] The frozen-record commitments
      &mdash; the registers, the specifications, the data &mdash; are anchored in Bitcoin
      blocks 964701, 964709 and 964789 (all 2026-08-30), and none of them may ever anchor to
      a block mined after the freeze; the code commitment re-anchors at each release, and a
      build whose code is not yet anchored is a labelled DEV build, never a published one.
      The binding
      specification is on its third version &mdash; v2 froze a figure its own register had
      corrected, misdated its own precedent, and changed five rules from v1 with no changelog;
      v3 corrects the false statements, changes <em>no</em> scoring map, threshold, population
      or confidence, and carries the changelogs, with v1 and v2 standing verbatim beside it.
      <b>The proof is verified, not read.</b> A hostile reviewer showed that reading the
      proof&rsquo;s own header proves nothing &mdash; it forged it with one command, rewrote a
      registered threshold, and passed the build. A second showed the block-pin alone is
      movable, since the file naming the expected block is editable by the same hand that
      rewrites the content. So the gate walks each proof from the file&rsquo;s digest to the
      merkle root of the named Bitcoin block, checks that root
      against two independent public explorers, requires the block pinned in
      <code>ANCHORS.txt</code>, <em>and</em> requires that block to have been mined on or
      before the freeze &mdash; which the movable pin cannot dodge, because rewritten content
      can only be anchored to a block that postdates the rewrite (a fresh proof over rewritten
      content is still a valid proof
      &mdash; a timestamp establishes a date, and a fresh one establishes none), and reads
      every threshold off every card back into the frozen registers, so the number a reader
      sees cannot drift from the number that was registered. Its attacks, and every other one
      that ever landed, are replayed on every build. <b>What this proves is narrower than it
      sounds.</b> It proves the files had exactly this content on 30 August &mdash; not that
      they existed on the earlier dates written inside them. The register was frozen on
      24 August and anchored six days later; that gap is my fault. What it establishes is the
      property a registered prediction actually needs: the record was fixed by a third party
      before the 31 October scoring date, before any outcome was known. Anyone can verify it
      without trusting this page &mdash; <code>ots verify</code>, or the published
      <code>ots_verify.py</code>, which does the walk in a hundred lines of standard-library
      Python.
    </p>

    <p>
      Five things you cannot check from this page alone, said here rather than left to be noticed.
      <b>The reviewers were AI sessions, not people, and this page used to let you assume
      otherwise.</b> Nearly every correction on this page was found by blind review &mdash;
      rounds of four, four and then about ten, across reproducibility, statistics,
      fact-checking, red-teaming, accessibility, law and editing. <b>Every one of those
      reviews, and the blind inter-coder replication behind the agreement figure, was
      performed by an AI model session that I ran, under a written role brief, with no sight
      of the other sessions&rsquo; work.</b> Where this page says &ldquo;a media lawyer,&rdquo;
      &ldquo;a statistician,&rdquo; &ldquo;a red-teamer,&rdquo; &ldquo;an independent
      coder,&rdquo; it names the brief the session was given, not a credential anyone holds;
      no lawyer, statistician or second human coder has reviewed this page. Blind, adversarial
      and separate they were; independent of me they were not. <b>Corrected 2026-09-02:</b> a
      sixth blind review pointed out that the page&rsquo;s own source log records these as
      &ldquo;adversarial subagent reviews&rdquo; while the page called them &ldquo;an
      independent media lawyer&rdquo; and &ldquo;an independent coder&rdquo; &mdash; which reads
      as human, and was false as read. Where a correction on this page names no finder, a
      review session found it, not me. Their transcripts are not published and nothing in the
      manifest timestamps them; everything they found is checkable &mdash; the scripts run,
      the figures recompute &mdash; but the account of <em>how</em> it was found rests on my
      word.

      <b>The source ladder had [[roster.n_tier_unverified_was]] rows resting on a wiki, and
      closing them is what this update did.</b> Every recorded claim carries a tier: 1 for a
      court or government record, 2 for a named independent outlet &mdash; national or regional, the label reads &ldquo;major outlet&rdquo; and several adverse rows rest on a regional or local paper, a local broadcaster, a university paper or a named contributor at a national outlet, which the ladder&rsquo;s own written wording would call tier 3; the label is looser than the rulebook and this sentence is the disclosure &mdash; down to 4 for a single or partisan
      source. A hostile red-teamer, and then a media lawyer, established that
      [[roster.n_tier_unverified_was]] of the 115 rows claimed tier 1 or 2 while the only
      source actually cited was a wiki &mdash; a tertiary source that may itself cite good
      ones, but does not show them. The worst of them claimed <em>tier 1, a court record,</em>
      on an encyclopedia; one recorded a criminal conviction against a named sitting member
      beside the index&rsquo;s own note that it could not support the claim. That is fixed,
      three ways, and the counts below are computed from the roster so this account cannot
      itself go stale. <b>The [[roster.n_tier_resourced]] rows that carried a finding were
      verified, not softened:</b> both were re-sourced off Wikipedia to named contemporary
      outlets &mdash; a forty-year-old misdemeanor plea to three named
      papers, a pair of court matters to the Patriot-News, CNN and the Washington Post
      (the outlets&rsquo; reporting of the rulings, not the court records themselves, which is
      why those rows now carry the outlet tier rather than the court-record tier)
      &mdash; and they now carry their findings and sit in the legal statistic, where an
      unsupported claim never should have. <b>[[roster.n_tier_govbio]] rows whose only
      independent source is the member&rsquo;s own official House biography</b> &mdash; a
      government record, legitimate for a non-adverse biographical fact and for nothing
      adverse &mdash; are labelled as exactly that. <b>The remaining [[roster.n_tier3_wiki]]
      rows, cited to nothing but a wiki, were downgraded to a tertiary tier:</b> no adverse
      <em>coded value</em> rests on any of them &mdash; a reported matter noted in a
      row&rsquo;s free text with only a wiki behind it is labelled tertiary and enters no
      statistic, and on 2 September such notes were struck from [[roster.n_notes_struck]] rows under that rule (the master coding, phase2.py in the bundle, marks which) &mdash; and the ladder now says tertiary rather than claiming a
      rung it cannot show. That is the [[roster.n_tier_unverified_was]] accounted for
      ([[roster.n_tier_resourced]] verified, [[roster.n_tier_govbio]] to House-bio records,
      [[roster.n_tier3_wiki]] downgraded), and the tighter gate then caught
      [[roster.n_tier_gov_new]] further row it had been passing &mdash; a court record,
      reclassified. <b>Then, on 2 September, the final pre-publication review found three rows
      labelled &ldquo;court record&rdquo; whose citations were news outlets or the
      member&rsquo;s own page and no court record at all:</b> two adverse rows (Perry, Van
      Orden) now carry the outlet tier their reporting supports, and [[roster.n_tier_gov_ownpage]]
      non-adverse row (Fitzpatrick) now says what it rests on, his own House.gov page &mdash;
      and the gate now refuses the court-record label to any row that cites no court or
      government record. That makes [[roster.n_tier_gov]] rows labelled &ldquo;Government record&rdquo; ([[roster.n_tier1_all]] tier-1 rows in all, counting the [[roster.n_tier_court]] that cite a court or enforcement record for an adverse matter). The result
      is <b>[[roster.n_tier_unverified]]</b> rows outstanding,
      and the tier gate now enforces the rule by <em>claim type</em> &mdash; a bare
      biographical fact may rest on a government record, an adverse finding may not, it needs
      a court record or a named outlet on an accepted list &mdash; so a row cannot reintroduce the
      defect without failing the build. <b>What that gate does and does not do:</b> it checks
      that an adverse row cites an accepted host; it does not read the article, so a citation
      to the right outlet about a different matter would pass it. The articles were read by
      hand for the rows that carry a finding; the gate is a tripwire, not that reading. The earlier version of this paragraph, which called all of it
      &ldquo;open work,&rdquo; is preserved in the source log.

      <b>A third party comparison was measured and never published.</b> The index codes three
      variables that can be split by party: military service, recorded legal record, and
      occupational category. Two of them carry predictions and appear on this page. The third
      does not. Under a rubric frozen before it was applied, care-profession careers split
      [[care.D]] Democrats to [[care.R]] Republicans &mdash; Fisher odds
      ratio<a class='gl' href='#g-odds-ratio' aria-label='what &quot;odds ratio&quot; means' title='what &quot;odds ratio&quot; means'>?</a>
      [[care.OR|.2f]], p&nbsp;=&nbsp;[[care.p|.3f]], the largest party asymmetry in any coded
      variable in this project <em>by odds ratio</em>, larger than the veteran gap that opens G1
      on that measure &mdash; though in plain percentage points, the form the glossary calls the
      plainest, it is [[care.D_pct|.1f]]% against [[care.R_pct|.1f]]%, and the veteran gap of
      [[roster.G1.pp_diff|.1f]] points is the wider of the two. It sits at the
      project&rsquo;s own &ldquo;suggestive, not established&rdquo; tier and appears on no
      page. The same run <em>refuted</em> the Republican-leaning half of the same rubric that
      I had expected to hold: uniformed-service careers split [[care.u_R]] Republicans to
      [[care.u_D]] Democrats, odds ratio [[care.u_OR|.2f]] at p&nbsp;=&nbsp;[[care.u_p|.2f]]
      &mdash; nothing. Neither is scored and neither changes a verdict; both are printed here
      because a page whose rule is print-what-you-compute had computed three party splits and
      printed two.

      <b>And those figures were typed literals until this build, and stale.</b> The cross-tab
      was coded when the roster read 57&nbsp;Democrats and 57&nbsp;Republicans; Kevin Kiley was
      afterwards corrected to independent, so the Republican denominator is [[care.R_n]], not
      57. Recomputed: the care odds ratio moves from [[care.frozen_OR|.2f]] to
      [[care.OR|.2f]], and the uniformed half &mdash; the larger of the two moves, and the one
      an earlier wording of this item omitted &mdash; from [[care.u_frozen_OR|.2f]]
      (p&nbsp;=&nbsp;[[care.u_frozen_p|.2f]]) to [[care.u_OR|.2f]]
      (p&nbsp;=&nbsp;[[care.u_p|.2f]]). Neither move changes a finding: the care lean stays
      suggestive-not-established, the uniformed claim stays refuted. The coding itself is
      <em>not</em> redone &mdash; re-coding 113 candidates after seeing the result is the
      failure the blind freeze exists to prevent, and it is what wrecked the uniformed half of
      this same rubric. Frozen coding, scripted arithmetic, declared correction, all four
      figures published.

      <b>And a third of the money data is measured over a different window.</b> The finance file
      is frozen, but only [[vintage.n_at_modal]] of its [[vintage.n_files]] committees report
      through [[vintage.modal]]; the rest run anywhere from [[vintage.earliest]] to
      [[vintage.latest]]. Corporate share is a share <em>of receipts</em>, and receipts
      accumulate, so a candidate measured over a longer window is not being measured on the
      same basis as one measured over a shorter. I do not know how much that moves the
      numbers. It is a live limitation, not a resolved one, and it is the most likely place
      for the next defect.
    </p>

    <p id="reply">
      <b>Nobody in this index was approached for comment, and that is a limitation rather than
      a policy I am pleased with.</b> The index records only matters already documented in
      public sources and does not originate an allegation about anyone; every adverse entry
      carries its own bound and its own outcome in the same cell, and where a subject&rsquo;s
      own response exists in the cited reporting it is recorded there too. That is an
      explanation, not a substitute for asking. Two things follow from saying it plainly.
      A row whose sourcing this index cannot stand behind no longer carries an adverse coded
      value at all: it reads <em>&ldquo;not established&rdquo;</em>, keeps its full detail, and
      is excluded from every computed statistic &mdash; which removed two rows, one from each
      party, on 30 August, for the hours it took to re-source them; both were verified to named
      outlets the same day and stand in the basis now, so at this build no row reads
      &ldquo;not established.&rdquo; And any subject who disputes an entry can say so; a sustained
      challenge is published as an erratum beside the original, never as a silent edit, which
      is the same procedure this project already runs for its own numbers. Raised by a review
      session run under a media-law brief, which found that neither page said any of this.
    </p>

""")
    a('    <h2 id="verify">Check it yourself</h2>\n')
    a(f'''    <p>
      Everything this page asserts about its own rigour is checkable, because the underlying
      material is published. Download <a href="''' + DATA + '''/">the folder</a> and run
      <code>sha256sum -c <a href="''' + DATA + '''/MANIFEST.sha256">MANIFEST.sha256</a></code>; every
      digest below should match. <b>What that does and does not prove:</b> a matching digest shows
      a file is unchanged <em>since I published it</em>, which is worth something and is not the
      same as showing it is the file the numbers came from. For the frozen record &mdash; the
      register versions, the specifications, the rulebooks and the frozen data &mdash; the
      stronger check is <code>ots verify COMMITMENT.txt.ots</code>, which is attested by a third
      party rather than by me.
    </p>

    <p>
      The table below lists the files this page's claims rest on. It is not the whole bundle: the
      manifest also covers this page and its companion, the chart, the stylesheet and the
      reproduction instructions, none of which can usefully print its own digest here. The
      manifest is the complete list.
    </p>

    <div class="verify verify-scroll">
      <table>
        <caption class="vh">The files this page's claims rest on, with their SHA-256 digests. The manifest covers the whole bundle.</caption>
        <thead><tr><th scope="col">File</th><th scope="col">SHA-256</th></tr></thead>
        <tbody>
''')
    for fn, desc in VERIFY_ROWS:
        d = H.get(fn, "")
        if not d:
            raise SystemExit(f"missing hash for {fn} — refusing to publish an unverified table")
        a(f'          <tr><th scope="row"><a href="{DATA}/{fn}">{fn}</a><br />'
          f'<span class="file-desc">{desc}</span></th>'
          f'<td class="hash">{d}</td></tr>')
    a(f'''        </tbody>
      </table>
    </div>

    <p>
      The two withheld predictions are hashed in <code>withheld_hashes.txt</code>: D2
      <code class="hash">bddfe9698eda4d3674a7267aba4da6ce1657b23f04bdcb9602a3383f365822fa</code> and E4
      <code class="hash">ea4e874b6fe95a8fb8d55886b2a478f5058a32a701d5ad1b0a231c19669d1132</code>.
    </p>
''')

    # ── foot ────────────────────────────────────────────────────────────────
    a(f'''    <p class="pred-foot">
      <strong>Method.</strong> 115 candidates across 59 competitive districts &mdash; of which
      [[roster.n_districts_empty]] (NH-01 and NH-02) carry no candidate, because their primaries
      fall after the 2026-08-13 roster freeze, so the coded set spans
      [[roster.n_districts_filled]] districts. Every count on these pages that reads
      &ldquo;of 115&rdquo; is against the 115 actually coded. Coded under a frozen
      written standard and checked by a separate blind AI coding session given only the rulebook &mdash; whose four
      disagreements are published by name in VALIDATION_RESULTS.md, though not the full
      50-decision sheet &mdash; who reproduced
      [[val.agree]] of [[val.n]] sampled coding decisions ([[val.pct|.0f]]%), against rulebook v1.0 — a sample of about ten
      candidates, not the whole index, and faith carried three of the four disagreements. Campaign finance from FEC bulk filings frozen 2026-08-21, most coverage through [[vintage.modal]] (full range [[vintage.earliest]] to [[vintage.latest]]); every figure in section A is produced by <code>money_analysis.py</code>, and any
      section-A number published without a run of that file is a defect and gets logged as one.
      Reading grade is Flesch-Kincaid. Nothing on this page evaluates any person&rsquo;s competence,
      character or fitness for office &mdash; the communication-style measures describe prose, and
      nothing more.
      <br /><br />
      {n} predictions, mean stated confidence {mean:.2f}%, computed rather than estimated. Scored
      2026-10-31. This page will be updated in place; the predictions will not be edited.
    </p>

  </article>
  </main>

{FOOTER}
{NAV_JS}{PRINT_FIX}</body>
</html>
''')
    return "\n".join(o), dict(n=n, lost=len(lost), self=len(selfb), mean=mean)


# ─────────────────────────────────────────────────────────────────────────────
#  Piece one: the story
# ─────────────────────────────────────────────────────────────────────────────

STORY_CSS = """
    .pred-wrap table { width: 100%; border-collapse: collapse; margin: var(--space-6) 0;
      font-size: var(--text-sm); }
    .pred-wrap th, .pred-wrap td { text-align: left; padding: var(--space-3) var(--space-4) var(--space-3) 0;
      border-bottom: 1px solid var(--border); color: var(--text-muted); vertical-align: top; }
    .pred-wrap th { font-family: var(--mono); font-size: var(--text-xs); letter-spacing: .12em;
      text-transform: uppercase; color: var(--text-dim); font-weight: 500; }
    .pred-wrap td b, .pred-wrap td strong { color: var(--text); font-weight: 600; }
    .tbl-scroll { overflow-x: auto; margin: var(--space-6) 0; }
    .pred-wrap ul { color: var(--text-muted); line-height: 1.85; max-width: 66ch;
      margin: 0 0 var(--space-5); padding-left: var(--space-5); }
    .pred-wrap li { margin-bottom: var(--space-3); }
    .pred-wrap li b { color: var(--text); font-weight: 600; }
    .follow-up-note { font-size: var(--text-sm) !important; color: var(--text-dim) !important;
      border-left: 2px solid var(--honor); padding-left: var(--space-4);
      margin: 0 0 var(--space-6) !important; }
    .kicker { font-family: var(--mono); font-size: var(--text-xs); letter-spacing: .14em;
      text-transform: uppercase; color: var(--accent-text); }
"""

STORY_BODY = """  <article class="pred-wrap">

    <div class="pred-head">
    <p class="eyebrow">Method &amp; corrections &middot; 2026 House Battleground Index</p>
    <h1>The Headline I Never Re-Ran</h1>

    <p class="lede">
      I sat down to write one sentence. The strongest finding this project has produced &mdash;
      corporate money rising with years in Congress, at a correlation<a class="gl" href="/investigations/2026-battleground-predictions.html#g-pearson" aria-label="what &quot;correlation (Pearson r)&quot; means" title="what &quot;correlation (Pearson r)&quot; means">?</a> of <b>0.82</b> &mdash; turned
      out to be <b>[[money.r_pool|.3f]]</b> when I computed it from the frozen data for the first
      time &mdash; or <b>[[rule.r_pool|.3f]]</b> if I score it on the districts that actually meet
      my own printed selection rule, which three of the 59 do not. I am confessing against
      [[money.r_pool|.3f]], because that is the universe the bets were priced on and I ruled on
      that in writing on 24 August, before October could move the figures. Both
      belong in the first paragraph: leading with the worse number alone would be its own kind
      of dishonesty. Among those with congressional service, <b>[[money.r_inc|.3f]]</b> on the
      published universe and [[rule.r_inc|.3f]] rule-compliant (I published 0.70 for this in
      August; both of these are what the frozen data actually gives). Here is what went wrong, what it cost, and
      &mdash; after three days of asking the question with the wrong instrument &mdash; what the
      replacement finding turned out to be.
    </p>

    <p class="pred-meta">
      <span>Written 2026-08-23</span>
      <span>Corrected 2026-08-30 &mdash; the step-versus-slope passage, and its own correction; corrected 2026-09-01 and 2026-09-02 &mdash; the slope&rsquo;s inference, twice</span>
      <span>Every current figure below is computed by a script published with this piece; numbers quoted as history are labelled as history</span>
    </p>
    </div>

    <p class="follow-up-note">
      <b>Before any of it: the reviewers in this piece are anonymous.</b> Their reports are not
      published and nothing timestamps them. Every turn in the story below is performed by them
      &mdash; they found the error, they checked the replacement, they caught the correction of
      the correction &mdash; and while everything they found is checkable, because the scripts run
      and the figures recompute, the account of <em>how</em> it was found rests on my word. The
      companion page says the same thing; it belongs here too, because this is the page that
      needs them.
    </p>

    <p class="follow-up-note">
      This is the follow-up to
      <a href="/investigations/2026-battleground-predictions.html">Twenty-Five Bets, Four Lost
      and One Retracted</a>, which holds the predictions themselves and does not
      need this piece to make sense. If you only read one, read that one. Every statistical
      word used here &mdash; correlation, Spearman, universe, confidence interval &mdash; is
      defined in plain English in
      <a href="/investigations/2026-battleground-predictions.html#glossary">the glossary on
      that page</a>, and the &ldquo;?&rdquo; marks below link straight to the right entry.
    </p>

    <p>
      The sentence was supposed to open a page about predictions. Four review sessions had reviewed that page,
      and the complaint that stuck was that hundreds of words on the philosophy of pre-registration
      passed before the first fact appeared. Lead with the finding, the editor said. (The round-one
      review texts were not preserved &mdash; a record-keeping failure in its own right; later review
      rounds are archived in full &mdash; so that sentence is memory, and marked as memory.) So the plan
      was to open with the finding and put the philosophy underneath it, where it belongs.
    </p>

    <p>
      The finding was this. Across the 2026 House battleground, the share of a candidate&rsquo;s money
      that comes from corporate and trade PACs rises with the years they have spent in Congress, at a
      correlation of 0.82. Among candidates with prior congressional service &mdash; restricting to those who have served, which is not
      the same as removing the candidates at zero: [[money.n_zero]] of the [[money.n_rows]] take
      no corporate money at all, and all of them are people who have never served &mdash; it still held at 0.70. I had published that figure in five
      separate documents since August. It was the headline of the whole project.
    </p>

    <p>
      To write the sentence properly I needed the number to two decimals. So I computed it from the
      frozen data files, under the frozen specification, which &mdash; and this is the part that
      matters &mdash; I had never actually done.
    </p>

    <blockquote>It came back [[money.r_pool|.3f]]. And among those with congressional service, [[money.r_inc|.3f]].</blockquote>

    <h2>What I checked before I believed it</h2>

    <p>
      The first assumption when a number moves is that you broke something. So I checked everything
      the original claim had published alongside the correlation. <b>Corrected 2026-08-30:</b> this
      paragraph used to say those figures &ldquo;come from the same rows&rdquo; as the correlation.
      They do not. The correlations below are recomputed on the [[august.n]] candidates coded in
      August; these four averages are computed on all [[money.n_rows]] under August's grouping. So
      they show the money and the coding are intact <em>today</em> &mdash; a real check, and a
      weaker one than the sentence claimed. A reviewer found it, the code comment recorded it, and
      nobody carried it to the page, which is this project's signature failure happening inside its
      own correction.
    </p>

    <div class="tbl-scroll"><table>
      <caption class="vh">The four averages published in August beside the correlation, recomputed under the definitions August used</caption>
      <thead><tr><th scope="col"></th><th scope="col">Published in August</th><th scope="col">Recomputed, August&rsquo;s own definitions</th></tr></thead>
      <tbody>
        <tr><th scope="row">Challenger average corporate share</th><td>0.3%</td><td><b>[[money.mean_chal_feccode|.2f]]%</b></td></tr>
        <tr><th scope="row">Open-seat average</th><td>1.3%</td><td><b>[[money.mean_open_feccode|.2f]]%</b></td></tr>
        <tr><th scope="row">Republican incumbent average</th><td>12.6%</td><td><b>[[august.mean_inc_feccode_REP|.2f]]%</b></td></tr>
        <tr><th scope="row">Democratic incumbent average</th><td>13.2%</td><td><b>[[august.mean_inc_feccode_DEM|.2f]]%</b></td></tr>
      </tbody>
    </table></div>

    <p>
      All four reproduce &mdash; <em>under the definitions August used</em>, which group candidates by
      their FEC incumbency code. The frozen specification later switched to grouping by prior service,
      which moves two candidates between groups and shifts these means slightly. <b>Corrected
      2026-08-30:</b> this sentence used to say both sets of figures were published. They were
      not. Only the August grouping was, and it is the one in which Democrats look worse. Both
      are computed by the published script, so here they are. Under the August FEC-code
      grouping the incumbent averages are <b>[[august.mean_inc_feccode_REP|.2f]]% Republican
      against [[august.mean_inc_feccode_DEM|.2f]]% Democratic</b>. Under the definition that
      actually binds every scored prediction &mdash; prior service &mdash; they are
      <b>[[money.mean_inc_REP|.2f]]% Republican against [[money.mean_inc_DEM|.2f]]%
      Democratic</b>, and <em>the ordering reverses</em>. Neither gap is large and no prediction
      turns on either; what matters is that the page was printing one of two computed figures
      while claiming to print both, on the comparison most likely to be quoted by someone with
      a side. Then I checked the tenure figures by
      hand against the year each member was first elected &mdash; Kaptur 1982, Turner 2002, Costa 2004,
      Cuellar 2004, Perry 2012, Fitzpatrick 2016. All correct.
    </p>

    <p>
      So the money is right and the years are right. Only the number connecting them is wrong.
    </p>

    <div class="tbl-scroll"><table>
      <caption class="vh">The published correlation figures against what the frozen data gives</caption>
      <thead><tr><th scope="col"></th><th scope="col">Published</th><th scope="col">Actual</th></tr></thead>
      <tbody>
        <tr><th scope="row">All candidates</th><td>r = 0.82 (n = 92)</td><td><b>r = [[money.r_pool|.3f]] (n = [[money.n_rows]])</b></td></tr>
        <tr><th scope="row">Members already in Congress &mdash; <em>and the two columns do not define that the same way</em></th><td>r = 0.70 (n = 38), on the FEC incumbency code</td><td><b>r = [[money.r_inc|.3f]] (n = [[money.n_inc]])</b>, on prior service (tenure &gt; 0)</td></tr>
        <tr><th scope="row">The same, within party</th><td>0.81 D / 0.63 R, on the FEC code</td><td><b>[[money.r_inc_DEM|.3f]] D / [[money.r_inc_REP|.3f]] R</b>, on prior service</td></tr>
        <tr><th scope="row">Three longest careers removed</th><td>0.54</td><td><b>[[money.r_trim3|.3f]] or [[money.r_trim3_alt|.3f]]</b> &mdash; two members tie for third-longest, so &ldquo;the three longest&rdquo; is ambiguous; both readings shown</td></tr>
        <tr><th scope="row">Nine mid-decade-redistricting states removed</th><td>&mdash; (never published)</td><td><b>[[money.r_no_redistricted|.3f]] (n = [[money.n_no_redistricted]])</b> &mdash; the script has always computed this and the page had not printed it. It is <em>higher</em> than the headline figure, which is the reason it appears here: a sensitivity that flatters me is the one I am least entitled to leave in the code</td></tr>
      </tbody>
    </table></div>

    <p class="follow-up-note">
      <b>Read the middle two rows carefully, because until 30 August they were mislabelled.</b>
      They compared two different populations under one heading: the August figures were
      computed on the FEC&rsquo;s incumbency code, which is how n came out at 38, and the frozen
      figures are computed on prior congressional service, which gives [[money.n_inc]]. Both
      halves are correct; putting them in one row called &ldquo;prior congressional service
      only&rdquo; was not, and it obscured part of the gap the table exists to show. The
      population change is one of the reasons the number moved, not a detail beside it &mdash;
      it is the same distinction that reverses which party&rsquo;s incumbents average more
      corporate money, three paragraphs above. The like-for-like comparison, August grouping
      against August grouping, is the reconstruction table below, and it reproduces all seven
      August figures exactly.
    </p>

    <h2>Why it happened</h2>

    <p>
      Look at the sample sizes. Ninety-two candidates when the figure was computed; a hundred and
      twelve now. Twenty people arrived after the number was calculated, and the number was never
      calculated again.
    </p>

    <p>
      That is possible for one reason: <b>the figure was never in a saved script.</b> It was computed
      once, in a working session, written down in prose, and then quoted from that prose in every
      document that followed. Prose cannot be re-run. So while the coding of the roster went on
      &mdash; batch after batch of new candidates verified and added &mdash; nothing recomputed,
      nothing failed, and nothing warned me. The number just sat there going quietly out of date
      while I built four predictions on top of it.
    </p>

    <p>
      I know that is what happened because it is the second time. A day earlier I had caught the same thing
      with a different number &mdash; a measure of how often Donald Trump writes in capital letters, the
      single most striking statistic in the project, computed by a script nobody saved. Rebuilt under a
      written-down definition it came out 14.29 per hundred words, not the 11.62 I had published.
      <b>Both of those are history and neither is reproducible here:</b> that measure belongs to an
      earlier piece, its script is not in this bundle, and I am asking you to take the pair on my
      word &mdash; which is precisely the thing the rest of this page says not to do. I

      wrote at the time that the lesson was to save the script. I saved that one. I did not go looking
      for the others.
    </p>

    <p>
      The first version of this piece stopped here and offered two hypotheses &mdash; the new people,
      or a mix-up between the two standard ways of measuring the relationship &mdash; and claimed I
      could not distinguish between them because the raw federal filings were gone. <b>That claim was
      false, and reviewers proved it with my own records.</b> The coding history names exactly which
      candidates were verified in the last two batches. Set those aside, restrict today&rsquo;s
      published data to the [[august.n]] candidates that existed when the figure was computed, and
      every August number comes back:
    </p>

    <div class="tbl-scroll"><table>
      <caption class="vh">The seven August correlation figures, reproduced by restricting to the batches one through eight coding universe</caption>
      <thead><tr><th scope="col"></th><th scope="col">Published in August</th><th scope="col">Batches 1&ndash;8, recomputed today</th></tr></thead>
      <tbody>
        <tr><th scope="row">All candidates, Pearson</th><td>0.82</td><td><b>[[august.r_pool|.4f]]</b></td></tr>
        <tr><th scope="row">All candidates, Spearman</th><td>0.86</td><td><b>[[august.rho_pool|.4f]]</b></td></tr>
        <tr><th scope="row">Served in Congress, Pearson</th><td>0.70</td><td><b>[[august.r_inc|.4f]]</b></td></tr>
        <tr><th scope="row">Served in Congress, Spearman</th><td>0.60</td><td><b>[[august.rho_inc|.4f]]</b></td></tr>
        <tr><th scope="row">Three longest careers removed</th><td>0.54</td><td><b>[[august.r_trim3|.4f]]</b></td></tr>
        <tr><th scope="row">Within party, incumbents by FEC code</th><td>0.81 D / 0.63 R</td><td><b>[[august.r_inc_DEM|.4f]] D / [[august.r_inc_REP|.4f]] R</b></td></tr>
      </tbody>
    </table></div>

    <p>
      Seven for seven, at n = [[august.n]] and [[august.n_inc]] incumbents by FEC code &mdash; the exact
      counts the August record states. So the mechanism is settled: <b>the district list never
      expanded; the coding roster completed.</b> The figure was true of the [[august.n]] candidates coded when
      it was computed and was never recomputed as the last twenty arrived &mdash; among them Marcy
      Kaptur, [[money.top_tenure|.0f]] years of service and [[money.top_share|.1f]]% corporate money,
      the single point that pulls the correlation down hardest. And the second hypothesis, the
      Pearson/Spearman<a class="gl" href="/investigations/2026-battleground-predictions.html#g-spearman" aria-label="what &quot;rank correlation (Spearman)&quot; means" title="what &quot;rank correlation (Spearman)&quot; means">?</a> mix-up, is <b>retracted by name</b>: both statistics were recorded separately
      at the time, and both reproduce.
    </p>

    <p>
      Which leaves the part that stings. On a page about not asserting things you have not checked,
      <b>I asserted an uncertainty I had not checked.</b> The answer was sitting in my own handoff
      record, one restriction away, the whole time I was writing &ldquo;I cannot distinguish.&rdquo;
      The reviewers did not have access to anything I lacked. They just looked.
    </p>

    <h2>What it costs</h2>

    <p>
      Four of the nineteen predictions the 22 August register ended with were priced against a number that
      was wrong.
    </p>

    <ul>
      <li><b>A1a</b> predicted the prior-service correlation would hold at 0.60 or above. I put
        <b>92%</b> on it. The data it was registered against says [[money.r_inc|.3f]].</li>
      <li><b>A1b</b> predicted the overall figure would land between 0.70 and 0.90, at <b>88%</b>.
        It is [[money.r_pool|.3f]].</li>
      <li><b>A3</b> predicted that controlling for committee chairmanships the relationship would
        survive at 0.50 or above, at <b>65%</b>. You cannot get 0.50 out of a relationship that starts
        at [[money.r_inc|.3f]] by controlling for something, except under a specific and unlikely statistical
        quirk.</li>
      <li><b>A4</b> predicted the same pattern in the 2024 cycle between 0.50 and 0.85, at <b>70%</b>
        &mdash; mostly the same members, so mostly the same problem.</li>
    </ul>

    <p>
      All four will miss. <b>I am not changing them.</b>
    </p>

    <p>
      That distinction is the only interesting thing I have to say here, so let me be precise about it.
      The 0.82 was never a prediction. It was a claim about data I already had, and a claim about
      data in hand that no longer holds &mdash; stale, as the predictions page puts it, rather than
      false when made &mdash; is an error; you fix an error. The predictions built on top of it <em>are</em>
      forecasts, and a forecast you rewrite after learning it loses is not a forecast at all. It is the
      exact move this entire project was set up to make impossible.
    </p>

    <p>
      So A1a, A1b, A3 and A4 stand word for word at 92%, 88%, 65% and 70%, and in October they will be
      marked missed, in the same table and the same denominator as everything else. No asterisk, no
      adjusted score printed helpfully alongside the real one. Then, separately, I have registered the
      corrected versions against the number that actually reproduces, and those are scored too. The
      honest arithmetic of what that costs me: freezing the four dead bets rather than deleting them
      is worth [[score.brier_cost_of_freezing|.4f]] on the Brier score &mdash; a real penalty, and
      at most that large &mdash; it is the figure if every one of the remaining predictions
      hits, and it falls as they do not, so it is a ceiling and not a point. And it needs its
      base to mean anything: deleting the four would give a score of
      [[score.brier_if_deleted|.4f]], freezing them gives [[score.brier_frozen|.4f]], so this
      roughly doubles it. Lower is better. An earlier version of
      this sentence called it &ldquo;exactly that large,&rdquo; which was wrong in the direction
      that made the penalty sound bigger. An earlier version also claimed the mistake was charged twice;
      v4 retracted that claim as arithmetically backwards, and it stays retracted.
    </p>

    <p>
      One more consequence. Among those nineteen was a
      prediction that I would turn out overconfident on average. I priced it at 60%. Knowing that four
      predictions are guaranteed misses before any data arrives, overconfidence is now close to
      arithmetically certain &mdash; that 60% has become free money. So I have frozen it and registered
      the honest price beside it: <b>93%</b>. That price is fixed now whatever happens; for what it is worth, the simulation currently puts the chance at [[score.p_ahat_negative|.1%]], which is not a revision and does not replace it. Both predictions are scored. <b>Corrected 2026-08-30:</b> I used to call the gap below an advantage I was declining
      to take. I am not declining it &mdash; both predictions are scored, and the cheap one still
      lands in my denominator. What I declined was hiding it. The thirty-three-point gap is the size
      of the cheap hit I chose to disclose rather than delete.
    </p>

    <h2>The part I got wrong that I would rather have got wrong</h2>

    <p>
      One of the four review sessions was briefed as a statistician (an AI session, like every
      reviewer here &mdash; said plainly on the predictions page), and its main objection was something called
      zero-inflation<a class="gl" href="/investigations/2026-battleground-predictions.html#g-zero-inflation" aria-label="what &quot;zero-inflation&quot; means" title="what &quot;zero-inflation&quot; means">?</a>. [[money.n_non]] of the candidates have never held the seat, so their
      years in Congress are zero and their corporate PAC share is very close to zero too. It argued that
      a large overall correlation could be produced almost entirely by the gap between those people and
      those who have served &mdash; that what looked like a smooth climb might be a single step with a flat
      line on either side. It showed it in a simulation. I accepted the point in principle and made
      prior-service-only the primary measure.
    </p>

    <p>
      It was not arguing in principle. Here is what the data does:
    </p>

    <ul>
      <li>[[money.n_non]] candidates who have never served: average corporate share <b>[[money.mean_non|.2f]]%</b></li>
      <li>[[money.n_inc]] with prior congressional service: average corporate share <b>[[money.mean_inc|.2f]]%</b></li>
      <li>Overall correlation <b>[[money.r_pool|.3f]]</b>; among those with prior service <b>[[money.r_inc|.3f]]</b></li>
    </ul>

    <FIGURE_SLOT />

    <p>
      I wrote at this point, in the first version of this piece: <em>the relationship is mostly a
      step<a class="gl" href="/investigations/2026-battleground-predictions.html#g-step" aria-label="what &quot;step&quot; means" title="what &quot;step&quot; means">?</a>, not a slope<a class="gl" href="/investigations/2026-battleground-predictions.html#g-slope" aria-label="what &quot;slope, and the fitted line&quot; means" title="what &quot;slope, and the fitted line&quot; means">?</a>.</em> That was my replacement headline, and it lasted about a day. Reviewers
      pointed out that it is a claim about <em>size</em> tested with a <em>correlation</em>, and that
      the sizes say the opposite. The step is [[money.step|.2f]] percentage points<a class="gl" href="/investigations/2026-battleground-predictions.html#g-points" aria-label="what &quot;percentage points&quot; means" title="what &quot;percentage points&quot; means">?</a>. The fitted line
      climbs [[money.climb|.2f]] points across the observed range, which makes the slope look like
      the bigger of the two. <b>Checked properly on 2026-08-30, and the check went against me twice.</b>
      Only [[money.n_inc_above_2nd]] of the [[money.n_inc]] sits above
      [[money.tenure_2nd|.1f]] years, so I wrote that the line only outruns the step by being
      stretched out to where one person stands. That was the wrong test: it held the slope fixed
      and shortened the range, which measures nothing. The right test is the one this page
      already runs on that point &mdash; delete it and refit. Do that and the slope rises from
      [[money.slope|.3f]] to <b>[[money.slope_refit|.3f]]</b> points a year and climbs
      [[money.climb_refit|.2f]] points across the shorter range: <em>more</em> than the step, not
      less. [[money.lev_name]] was holding the slope <em>down</em>. So the first arithmetic was
      right, my correction of it was wrong, and both are printed here rather than the surviving
      one alone.
    </p>

    <p>
      The correlation is low because the points scatter<a class="gl" href="/investigations/2026-battleground-predictions.html#g-scatter" aria-label="what &quot;scatter (residual spread)&quot; means" title="what &quot;scatter (residual spread)&quot; means">?</a> [[money.scatter|.2f]]
      points either side of the line, not because the gradient is small.
    </p>

    <p>
      The universe number is in the first paragraph now, and it belongs there. Three of the 59 districts &mdash; OH-09,
      TX-28, TX-34 &mdash; sit outside the selection rule<a class="gl" href="/investigations/2026-battleground-predictions.html#g-universe" aria-label="what &quot;universe&quot; means" title="what &quot;universe&quot; means">?</a> the frozen specification prints, and they
      were added as marquee races without the flag that should have accompanied them. Score the same
      data on the districts that actually satisfy the rule and the pooled correlation is
      [[rule.r_pool|.3f]] rather than [[money.r_pool|.3f]], with [[rule.r_inc|.3f]] rather than
      [[money.r_inc|.3f]] among those who have served &mdash; and under the register&rsquo;s own
      ruling that flips A1b from a miss to a hit. So the honest size of this correction is
      0.82 to [[rule.r_pool|.3f]] as much as it is 0.82 to [[money.r_pool|.3f]]. That universe is
      defensible, it is closer to the 0.82 I published, and it is not the one I am confessing
      against &mdash; because the bets
      were priced on the 59 as published, and the ruling fixing that was made in writing on 24 August,
      before October could move the figures. The full ruling is on the register.
    </p>

    <p>
      So I have now been wrong about the shape of this relationship twice, and not in the same way.
      The first time I reached for a correlation to describe a magnitude. The second time, on
      30 August, I tried to show that the slope only beat the step because the line ran out to one
      long career &mdash; and did it by shortening the range without refitting the line, which tests
      nothing. Refitted, the slope beats the step by more. My original arithmetic survived and my
      correction of it did not.
    </p>

    <p>
      <b>And then a third reviewer pointed out that I had spent three days arguing about a
      question I could have answered in two lines.</b> Step or slope is not a matter of opinion
      to be settled by comparing correlations, which cannot separate two effects because they
      are one number. It is a regression<a class="gl" href="/investigations/2026-battleground-predictions.html#g-regression" aria-label="what &quot;regression, and what these two coefficients mean&quot; means" title="what &quot;regression, and what these two coefficients mean&quot; means">?</a> with two terms, on data that had been frozen for a
      week. Fitted: arriving in Congress at all is worth <b>[[fit.b1_step|.2f]] percentage
      points</b> (95% interval [[fit.step_hc3_lo|.2f]] to [[fit.step_hc3_hi|.2f]], robust
      p&nbsp;=&nbsp;[[fit.p1_hc3|.4f]]); each further year served is worth about
      <b>[[fit.b2_slope|.3f]]</b> of a point, and whether that is &ldquo;significant&rdquo;
      turns on one person: on robust standard errors the slope&rsquo;s interval
      ([[fit.slope_hc3_lo|.3f]] to [[fit.slope_hc3_hi|.3f]]) includes zero
      (p&nbsp;=&nbsp;[[fit.p2_hc3|.2f]]), and [[fit.slope_hc3_share_top|.0%]] of that
      uncertainty is [[fit.drop_name]] alone; without her it is [[fit.b2_slope_drop|.3f]] at
      p&nbsp;=&nbsp;[[fit.p2_drop_hc3|.4f]], and a bootstrap over everyone puts it above zero
      [[fit.slope_boot_frac_pos|.0%]] of the time. The step is real and significant on every
      formula; the slope is probably positive, and one 44-year career decides how sure you get
      to be. <b>Corrected 2026-09-01 and 2026-09-02:</b> this passage first carried the classical
      p&nbsp;=&nbsp;[[fit.p2|.4f]] and called the slope measurable; a first correction blamed
      the wider robust interval on the never-served &ldquo;floor of zeros,&rdquo; which was
      wrong &mdash; those candidates contribute nothing to it. The full account is on the
      predictions page. The step is far the
      larger for almost everyone: seniority only catches up after
      <b>[[fit.crossover|.0f]] years</b>, and [[fit.n_above_crossover]] of the [[fit.n]]
      candidates in the file has served that long.
    </p>

    <p>
      <b>So there is a replacement finding, and it is not the one I published in August.</b> The
      money arrives with the seat, not with the seniority. Years in Congress may add a little to
      it, at a rate that is small, uncertain, and nothing like the story the 0.82 told. To be
      plain about what is and is not new here: that corporate and trade PACs give overwhelmingly
      to sitting members is a decades-old, well-documented pattern, and a reader who knows the
      literature will rightly say the tilt itself is the baseline, not a finding. What this adds
      is the <em>shape</em> of it inside these 59 races &mdash; a step at arrival rather than a
      climb with seniority &mdash; and the fact that my August headline had the shape wrong. No causal claim
      is made from any of it: this is a cross-section of [[fit.n]] candidates in one cycle with
      no controls, and it cannot tell whether serving attracts corporate money or whether
      candidates who attract corporate money are the ones who win and stay. The model is
      published, unregistered and unscored, because it was fitted on 30 August after the
      register froze. <b>That took an editor, not a statistician, and it is the most useful
      thing anyone said to me about this project.</b> Three days of careful argument with the
      wrong instrument is a worse failure than the original mistake, and no amount of
      timestamping repairs it.
    </p>

    <p>
      The retracted claim is <a href="/investigations/2026-battleground-predictions.html#p-A5">still
      on the register as A5</a> and is still scored, because my own rules forbid deleting a
      prediction once it is made. It will almost certainly be marked a hit. That is a fact about how
      weak a test I built, not about whether the claim was true, and the card says so.
    </p>

    <h2>What is actually fixed</h2>

    <p>
      Not the number &mdash; the number was only ever a symptom. What is fixed is that every
      <em>current</em> figure in this piece is computed by scripts published with it &mdash; the
      two historical figures above are not, and say so where they appear: <code>money_analysis.py</code> for the core
      estimator &mdash; the correlations, the confidence interval<a class="gl" href="/investigations/2026-battleground-predictions.html#g-ci" aria-label="what &quot;bootstrap, and the 95% CI&quot; means" title="what &quot;bootstrap, and the 95% CI&quot; means">?</a>, the party breakdown, and a
      hit-or-miss verdict against the two thresholds it was written for, A1a and A1b &mdash; the other twenty-three are scored by hand against the register in October &mdash; and <code>figures.py</code> for
      everything else on this page, the reconstruction table above included. Run them and you get these
      numbers. Run them in October and you get October&rsquo;s.
    </p>

    <p>
      And the rule that goes with it, which is the actual output of this week: <b>a number that lives
      only in prose cannot be re-run, and a number that cannot be re-run will go stale without telling
      you.</b> Any figure from this section that appears in anything I publish without a corresponding
      run of that file is a defect, and it gets logged as one.
    </p>

    <p>
      Twice now I have found a headline statistic that no code could reproduce. Both times I found it by
      trying to use the number for something else, not by auditing. That is luck, and luck is not a
      method.
    </p>

    <p>
      Putting the numbers in files is necessary and it is not sufficient, and I know that because it
      has already failed here. One of the writing predictions on the register was measured by a script
      that was written down, frozen, published and wrong: it joined text samples on a bare line break,
      so wherever a sample ended without a full stop two sentences merged into one, and the median it
      produced &mdash; [[style.cand_median_shipped|.2f]] &mdash; was carried through a threshold the
      corrected figure of [[style.cand_median_fixed|.2f]] sits the other side of. A file makes an error
      findable and repeatable. It does not make it false.
    </p>

    <p>
      So the honest version of the rule is narrower than the one I wanted to end on. A number that
      lives only in prose cannot be checked at all. A number that lives in a file can be, by someone
      who runs it against something else and finds it does not fit. Both times, that someone was
      me, by accident. Neither time was it the method.
    </p>

    <p class="pred-foot">
      <strong>Published with this piece.</strong> The full verification, including everything that did
      reproduce, is <a href="DATA_SLOT/DEFECT_A_BLOCK_2026-08-23.md">the defect report</a>. The
      binding register is <a href="DATA_SLOT/PREDICTIONS_v5_2026-08-24.md">v5</a>; the four superseded
      versions are published unedited beside it, with their hashes, so every correction can be checked
      rather than taken on trust. The estimator is
      <a href="DATA_SLOT/money_analysis.py">money_analysis.py</a> and the chart above is generated by
      <a href="DATA_SLOT/make_figures.py">make_figures.py</a>. Run
      <code>sha256sum -c MANIFEST.sha256</code> against
      <a href="/investigations/2026-battleground-predictions.html#verify">the published digests</a>.
      <br /><br />
      Campaign finance from FEC bulk filings frozen 2026-08-21, most coverage through [[vintage.modal]] (full range [[vintage.earliest]] to [[vintage.latest]]). Nothing here evaluates any
      person&rsquo;s competence, character or fitness for office.
    </p>

  </article>
  </main>
"""


def build_story():
    slug = "the-headline-i-never-re-ran.html"
    svg = open(FIG, encoding="utf-8").read()
    fig = ('  <figure class="fig">\n'
           '  <div class="fig-scroll" tabindex="0" role="group" aria-label="Scrollable chart: '
           'corporate and trade PAC share against years in Congress">\n    ' + svg.replace("\n", "\n    ") +
           '\n  </div>' +
           '\n    <figcaption>\n'
           '      [[money.n_rows]] of the 115 candidates in the index &mdash; [[missing.n]] have no row in the\n'
           '      frozen finance file &mdash; showing corporate and trade PAC share of receipts against years of\n'
           '      congressional service, FEC bulk data frozen 2026-08-21 ([[vintage.n_at_modal]] of [[vintage.n_files]] committees reporting through [[vintage.modal]]; coverage runs [[vintage.earliest]] to [[vintage.latest]]). The [[money.n_non]] who have never served\n'
           '      average [[money.mean_non|.2f]]%; the [[money.n_inc]] candidates who have served average [[money.mean_inc|.2f]]%. Among them the upward trend is\n'
           '      r&nbsp;=&nbsp;[[money.r_inc|.2f]]. [[money.top_name]], [[money.top_tenure|.0f]] years and [[money.top_share|.1f]]%, is the single highest-leverage point<a class="gl" href="/investigations/2026-battleground-predictions.html#g-leverage" aria-label="what &quot;leverage, and influence&quot; means" title="what &quot;leverage, and influence&quot; means">?</a>\n'
           '      in the chart; deleting her moves the incumbents-only correlation from [[money.r_inc|.3f]] to\n'
           '      [[money.r_inc_drop_lev|.3f]], and the slope from [[money.slope|.3f]] to [[money.slope_refit|.3f]].\n'
           '      She is circled and kept in, because removing an inconvenient point is how you get a\n'
           '      number that does not reproduce.\n'
           '    </figcaption>\n  </figure>\n')
    h = head("The Headline I Never Re-Ran &mdash; Patrick Neil Bradley",
             "The strongest finding in the 2026 House battleground index was a correlation of 0.82. Computed from the frozen data for the first time it is [[money.r_pool|.3f]], and [[money.r_inc|.3f]] among those with congressional service. What went wrong, what it costs, and the replacement finding that survived: the money arrives with the seat, not with the seniority.",
             slug,
             "The Headline I Never Re-Ran",
             "I published a correlation of 0.82 five times. Recomputed from the frozen data it is [[money.r_pool|.3f]]. Four predictions were priced on the wrong number; they stay exactly as registered and will be marked missed.")
    h = h.replace("  <style>", "  <style>" + STORY_CSS, 1)
    h = h.replace("  </style>", generated_print_overrides(CSS + STORY_CSS) + "  </style>", 1)
    body = STORY_BODY.replace("    <FIGURE_SLOT />\n", fig).replace("DATA_SLOT", DATA)
    return h + body + "\n" + FOOTER + "\n" + NAV_JS + PRINT_FIX + "</body>\n</html>\n"


# Measurements that must never appear as literals in a built page unless they came
# through fmt(). Each is a value figures.py computes; finding one hardcoded means a
# number was typed instead of computed. Historical quotations are exempt and listed.
QUOTED_HISTORY = {"0.82", "0.70", "0.81", "0.63", "0.54", "11.62", "14.29", "0.186",
                  "70.95", "0.12", "0.48", "47%", "87%",
                  # the August figures the story quotes as history, each labelled as such
                  "0.8231", "0.8587", "0.6963", "0.6004", "0.5412", "0.8095", "0.6263",
                  "2.38", "3.12", "1.55", "0.7116", "0.4993", "0.1857", "1.018", "3.83"}

# REGISTERED THRESHOLDS. These are the only numbers on the page that MUST be literals:
# they are the frozen text of a prediction, and computing them would be the very thing the
# register exists to prevent. They are not exempt from checking -- register_gate.py reads
# every threshold off every card and requires it to appear in one of the five frozen,
# timestamped register versions, so a threshold rewritten here fails the build. Listing
# them here says which numbers are claims rather than measurements.
REGISTERED_THRESHOLD = {
    "0.60", "0.70", "0.90", "0.50", "0.85", "0.20", "0.10", "0.45", "0.55", "0.30",
    "1.30", "2.5", "0.05", "0.01", "0.25", "0.15", "0.40", "0.65", "0.35",
}

# Historical quotations of figures this project has RETRACTED, each printed only inside
# the passage that says it was wrong. check_superseded polices how often they appear.
RETRACTED_QUOTED = {"0.484", "0.857", "7.66", "7.657"}

# Numeric literals that are not measurements: dates, ids, versions, counts of things a
# reader can count on the page, CSS and typography. Enumerated rather than pattern-matched
# so that adding one is a decision someone makes on purpose.
NOT_A_MEASUREMENT = re.compile(
    r"^(?:"
    r"19\d\d|20[0-3]\d"                      # years
    r"|[0-9]{1,2}"                             # small counts; a bare 0-99 is not a finding
    r"|1\.0|2\.0|0\.0|100|115|435|468|300|600|964701"
    r"|[0-9]\.[0-9]"                           # spec section numbers, e.g. 5.5 -- see below
    r")$")
# Where numbers legitimately live as raw text: the digest table, machine-readable
# attributes, code samples, and the timestamp block.
STRIP_FOR_LITERALS = (
    r"<td class=\"hash\">[0-9a-f]{64}</td>",
    r"<code>.*?</code>",
    r"<script>.*?</script>",
    r"<style>.*?</style>",
    r"<svg.*?</svg>",
    r"<[^>]+>",                                 # all attributes: widths, aria, hrefs, ids
)


def check_typed_literals(page, label, F):
    """Every measurement on the page came through fmt(), or is listed.

    SCOPE, stated so a clean run is not mistaken for a guarantee: this finds decimal
    numbers in visible prose. It cannot tell a typed 3.76 from a computed 3.76 -- what it
    can tell is that SOME figure resolved to that string, which is what makes an invented
    one stand out. It does not police integers below 100, dates, or anything inside code,
    attributes or the digest table. It is a tripwire for the pattern that keeps shipping,
    not a certification. Proved against a planted literal before being trusted."""
    SPECS = ("", ".1f", ".2f", ".3f", ".4f", ".0f", ",.0f", ",.1f", ",.2f", ",d",
             ".0%", ".1%", ".2%")

    def renderings(v):
        """Every string a placeholder could turn this value into. The percent specs
        produce '93.6%'; the literal scanner sees '93.6', so both go in."""
        out = set()
        for spec in SPECS:
            try:
                t = format(v, spec) if spec else str(v)
            except (ValueError, TypeError):
                continue
            out.add(t)
            out.add(t.rstrip("%"))
        return out

    resolved = set()
    for v in F.values():
        if isinstance(v, (int, float)) and not isinstance(v, bool):
            resolved |= renderings(v)
        elif isinstance(v, dict):
            for x in v.values():
                if isinstance(x, (int, float)) and not isinstance(x, bool):
                    resolved |= renderings(x)
    text = page
    for pat in STRIP_FOR_LITERALS:
        text = re.sub(pat, " ", text, flags=re.S)
    text = html.unescape(text)
    bad = {}
    for m in re.finditer(r"(?<![\w.,$/-])(\d{1,3}(?:,\d{3})+|\d+\.\d+)(?!\.?\d)(?![\w/-])", text):
        n = m.group(1)
        if n in resolved or n in QUOTED_HISTORY or n in REGISTERED_THRESHOLD \
                or n in RETRACTED_QUOTED:
            continue
        # A section reference (§5.5, "spec 5.1") is not a measurement. Required to be
        # written with its marker so that a bare 5.5 in prose is still caught.
        pre = text[max(0, m.start() - 12):m.start()]
        if "\u00a7" in pre or "spec " in pre.lower() or "rule " in pre.lower():
            continue
        if NOT_A_MEASUREMENT.match(n) and "." not in n:
            continue
        bad.setdefault(n, re.sub(r"\s+", " ", text[max(0, m.start() - 60):m.start() + 40]))
    if bad:
        print(f"TYPED LITERAL on the {label} — a number that no figure resolves to:\n")
        for n, ctx in sorted(bad.items()):
            print(f"  {n}\n      …{ctx.strip()}…\n")
        raise SystemExit(
            "Every measurement on a page must come through fmt() from figures.py. If one of "
            "these is a historical quotation, add it to QUOTED_HISTORY with its label; if it "
            "is a measurement, compute it.")


SUPERSEDED = {
    # figure key -> why it may never again be published as a live conclusion
    "money.climb_to_2nd":
        "the slope-held-fixed range truncation; superseded 2026-08-30 by money.climb_refit. "
        "It may appear only inside the A5 card's account of its own error.",
}


# Figures whose VALUE must be present on the page that cites the record they come from.
# The literal scanner catches an invented decimal; it cannot catch an invented integer,
# because a bare number under 100 is usually a count. A red-teamer used exactly that gap:
# "46 of 50 sampled coding decisions (92%)" became "49 of 50 (98%)" on a clean build,
# contradicting the timestamped VALIDATION_RESULTS.md shipped beside it. Substituting any
# of these now removes a value the page is required to carry, and the build stops.
# key -> (how many times it must be PLACED, why it is wired rather than typed)
# The count is pinned, not just "at least one": replacing one of three occurrences with a
# literal used to leave this check satisfied by the other two, which is exactly the
# substitution it exists to catch. Changing a count here is a deliberate act.
MUST_APPEAR = {
    "val.agree": (4, "the inter-coder agreement count, from VALIDATION_RESULTS.md"),
    "val.n": (4, "the inter-coder sample size, from VALIDATION_RESULTS.md"),
    "val.alpha": (1, "Krippendorff's alpha, from VALIDATION_RESULTS.md"),
    "val.pct": (1, "the agreement percentage, derived from the two counts above"),
    "roster.n_tier_unverified": (1, "the count of rows still resting on an unsupported tier — now zero"),
    "care.D": (1, "the care-professions Democratic count, from the frozen cross-tab"),
    "care.R": (1, "the care-professions Republican count, from the frozen cross-tab"),
}


def check_must_appear(pages, F):
    """Each of these must be PLACED as a placeholder, not merely present as a value.

    Checking the built page for the value was too weak: "46" and "50" occur all over a
    page full of counts, so replacing "[[val.agree]] of [[val.n]]" with the literal
    "49 of 50" left the check satisfied by unrelated numbers elsewhere. What must be true
    is that the sentence is WIRED to the record -- so the source is what gets checked, and
    substituting a literal removes the placement and stops the build."""
    src = open(os.path.join(HERE, "make_pages.py"), encoding="utf-8").read()
    missing = []
    both = " ".join(pages)
    for key, (want, why) in MUST_APPEAR.items():
        placed = len(re.findall(r"\[\[" + re.escape(key) + r"(?:\|[^\]]*)?\]\]", src))
        if placed != want:
            missing.append((key, "%s — placed %d time%s, must be placed %d"
                            % (why, placed, "" if placed == 1 else "s", want)))
        elif not any(f in both for f in (str(F[key]), f"{F[key]:.0f}", f"{F[key]:.2f}")):
            missing.append((key, why + " — placed but its value is not on either page"))
    if missing:
        print("A REQUIRED FIGURE IS NOT WIRED TO THE PAGE:\n")
        for key, why in missing:
            print(f"  {key} — {why}\n      This figure is cited to a published record. It "
                  f"must be printed through fmt(), never typed, so the page cannot state a "
                  f"value the record does not hold.\n")
        raise SystemExit(1)


def check_prose_confidences(pages):
    """A confidence printed in PROSE must be one the register actually holds.

    register_gate checks the cards. The prose says things like "registered at 92%",
    "priced it at 60%", "<b>93%</b> on it" -- all typed, none checked, and a red-teamer
    contradicted three of them at will, one of them in the sentence directly under "It is
    the exact move this entire project was set up to make impossible."

    SCOPE: this asserts every percentage stated in a registered-at construction is SOME
    registered confidence. It does not check it is the right one for that prediction --
    that needs the id, which the prose does not always give. A narrow check that fires is
    better than a wide one that cannot be written."""
    ok = {str(v) for v in figures.CONF.values()}
    # A confidence stated as HISTORY -- "v1 priced this at 75%" -- is a fact about a frozen,
    # anchored register version, and the page now says so on three cards after a reviewer
    # found the glossary claiming confidences had never changed. So every confidence in
    # every frozen register version is acceptable in prose, not only the current ones. A
    # number that was never any prediction's confidence in any version is still refused,
    # which is the case the A11 probe plants.
    import register_gate as _rg
    for pct in re.findall(r"\*\*Confidence:\s*(\d{1,3})%", _rg.register_sections()):
        ok.add(pct)
    for pct in re.findall(r"^\|\s*[A-G]\d[a-z]?[′″']?\s*\|[^|]*\|\s*(\d{1,3})%\s*\|",
                          _rg.register_sections(), re.M):
        ok.add(pct)
    bad = []
    for label, page in pages.items():
        flat = re.sub(r"<[^>]+>", "", page)
        flat = html.unescape(re.sub(r"\s+", " ", flat))
        # "at 50%" in a glossary definition of log-odds, and "unchanged at 100%" about a
        # bootstrap implication, are not confidences. Anchored to the words that actually
        # introduce one, so the check fires on claims and not on arithmetic.
        # A date usually sits between the verb and the number ("Registered 2026-08-22 at
        # 92%"), so a short gap is allowed -- but only up to the first sentence break, and
        # only after a word that actually introduces a confidence.
        for m in re.finditer(r"\b(?:registered|priced|repriced|stated confidence"
                             r"|confidence)\b[^.<>]{0,40}?\bat (\d{1,3})%", flat, re.I):
            if m.group(1) not in ok:
                bad.append((label, m.group(1),
                            flat[max(0, m.start() - 70):m.start() + 40]))
    if bad:
        print("A CONFIDENCE STATED IN PROSE IS NOT IN THE REGISTER:\n")
        for label, pct, ctx in bad:
            print(f"  {label}: {pct}%\n      …{ctx.strip()}…\n")
        raise SystemExit("A confidence is the one number this project says never changes "
                         "after registration. The cards are checked by register_gate; this "
                         "checks the sentences.")


def check_titles(pred, story):
    """Each page names the other. A renamed h1 that leaves a stale cross-reference is
    how a reader clicks a promised title and lands on a different one — found by two
    editors on a project whose subject is claims that stop matching their evidence."""
    def h1(p):
        m = re.search(r"<h1>(.*?)</h1>", p, re.S)
        return re.sub(r"\s+", " ", m.group(1)).strip() if m else None
    for label, page, other in (("predictions page", pred, h1(story)),
                               ("story page", story, h1(pred))):
        if other and other not in re.sub(r"\s+", " ", page):
            raise SystemExit(f"{label}: does not name the other page by its current title "
                             f"({other!r}) — a cross-reference has gone stale")


# A slug is minted against a headline and then outlives it. The story page was retitled
# "The Headline I Never Re-Ran" and its slug stayed the-headline-i-couldnt-reproduce.html,
# so the canonical link, the og:url, the share card and the filename all advertised a title
# the page no longer carried. check_titles catches a stale cross-REFERENCE between pages;
# nothing caught a stale self-reference. Found by an independent reviewer.
#
# A word-overlap test was tried first and was wrong: 2026-battleground-predictions is a
# perfectly good topical slug for a page headlined "Twenty-Five Bets", and a check that
# fires on that would be trained away within a week. So the record is explicit instead --
# each slug is pinned to the headline it was minted against. Retitle the page and the
# build stops until someone decides, on purpose, whether the URL should move too.
SLUG_HEADLINE = {
    "2026-battleground-predictions.html":
        "Twenty-Five Bets, Four Lost and One Retracted",
    "the-headline-i-never-re-ran.html":
        "The Headline I Never Re-Ran",
}


def check_slugs(pages):
    for slug, page in pages.items():
        m = re.search(r"<h1>(.*?)</h1>", page, re.S)
        if not m:
            raise SystemExit("%s has no <h1>" % slug)
        h1 = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", m.group(1))).strip()
        h1 = h1.replace("&mdash;", "\u2014").replace("&amp;", "&")
        want = SLUG_HEADLINE.get(slug)
        if want is None:
            raise SystemExit("%s is not pinned in SLUG_HEADLINE — add it with the headline "
                             "the URL was minted for" % slug)
        if h1 != want:
            raise SystemExit(
                "the headline of %s has changed since its URL was minted:\n"
                "  URL was minted for: %r\n  the page now says:  %r\n"
                "Rename the file so the canonical URL, og:url and share card stop "
                "advertising the old title — or, if the URL should stay, re-pin it in "
                "SLUG_HEADLINE deliberately and say why." % (slug, want, h1))


def check_named_files(pred, story, listed):
    """Any file the prose names in <code> as the source of a figure must appear in the
    digest table. Round four found both pages citing figures.py as the provenance of
    their live numbers while the table listed neither it nor MANIFEST.sha256 — an appeal
    to a warrant the reader cannot pick up."""
    named = set(re.findall(r"<code>([A-Za-z0-9_.-]+\.(?:py|csv|md|txt|sha256))</code>",
                           pred + story))
    missing = sorted(n for n in named if n not in listed and n != "MANIFEST.sha256")
    if missing:
        raise SystemExit("named in the prose but absent from the digest table: "
                         + ", ".join(missing))


def check_superseded(pred, story, F):
    """A retracted diagnostic that is still computed is a defect waiting to be pasted
    back in. Each one is allowed exactly one printed occurrence — inside the passage
    that says it was wrong — and the build refuses any more."""
    # This used to count ONE 2-decimal rendering. Changing a placeholder's format spec to
    # |.3f walked straight past it, and a red-teamer used that to re-assert the retracted
    # step-not-slope conclusion twice on a passing build. Every rendering is counted now,
    # and the placeholder itself is counted in the SOURCE, which is where the retraction
    # can actually be enforced.
    src = open(os.path.join(HERE, "make_pages.py"), encoding="utf-8").read()
    for key, why in SUPERSEDED.items():
        # Only the renderings that IDENTIFY this figure. ".0f" and ".1f" collapse 7.6566
        # to "8" and "7.7", which appear all over the page for unrelated reasons -- the
        # first version of this counted 1,264 occurrences and would have failed forever.
        # Two decimals or more is where a figure becomes recognisable.
        vals = {f"{F[key]:{sp}}" for sp in (".2f", ".3f", ".4f", "")}
        n = sum((pred + story).count(v) for v in vals)
        if n > 1:
            raise SystemExit(f"superseded figure {key} appears {n} times as one of "
                             f"{sorted(vals)}: {why}")
        uses = len(re.findall(r"\[\[" + re.escape(key) + r"(?:\|[^\]]*)?\]\]", src))
        if uses > 1:
            raise SystemExit(f"superseded figure {key} is placed {uses} times in "
                             f"make_pages.py: {why}")


def check_gloss_links(pred, story):
    """Every '?' must land on a definition that exists. A link into a term that was
    renamed or dropped is silently broken in a browser — it just does nothing — so it
    is asserted here instead. Same class of defect as a number living in prose."""
    ids = set(re.findall(r'<dt id="(g-[a-z0-9-]+)"', pred))
    if not ids:
        raise SystemExit("glossary: no term ids found — the box did not render")
    for label, page in (("predictions page", pred), ("story page", story)):
        for tag in re.findall(r"<a class=[\"']gl[\"'][^>]*>", page):
            if "aria-label=" not in tag:
                raise SystemExit(f"{label}: a '?' link with no accessible name: {tag}")
    for label, page, prefix in (("predictions page", pred, ""),
                                ("story page", story, "/investigations/2026-battleground-predictions.html")):
        for href in re.findall(r"<a class=[\"']gl[\"'] href=[\"']([^\"']+)[\"']", page):
            base, _, frag = href.partition("#")
            if base != prefix:
                raise SystemExit(f"{label}: gloss link points at {base!r}, expected {prefix!r}")
            if frag not in ids:
                raise SystemExit(f"{label}: gloss link #{frag} has no matching <dt>")
    used = {f.split("#", 1)[1] for f in
            re.findall(r"<a class=[\"']gl[\"'] href=[\"']([^\"']+)[\"']", pred + story)}

    # EVERY in-page anchor, not just the glossary ones. Round four found the story page
    # pointing at #lost for a card that lives under a different heading — a link that is
    # silently wrong in a browser, which is how the last three defects got shipped.
    PAGES = {"": pred, "/investigations/2026-battleground-predictions.html": pred,
             "/investigations/the-headline-i-never-re-ran.html": story}
    for label, page in (("predictions page", pred), ("story page", story)):
        have = set(re.findall(r'id="([^"]+)"', page))
        for href in re.findall(r'href=[\"\'"]([^\"\'"]*#[^\"\'"]+)[\"\'"]', page):
            base, _, frag = href.partition("#")
            if base and base not in PAGES:
                continue                      # off-site or a file download
            target = have if not base else set(re.findall(r'id="([^"]+)"', PAGES[base]))
            if frag and frag not in target:
                raise SystemExit(f"{label}: anchor #{frag} (href {href!r}) has no target")
    return len(ids), len(used)


def verify(page, F, label):
    """Refuse to ship a page with an unresolved placeholder or a stale hand-typed count."""
    left = _PH.findall(page)
    if left:
        raise SystemExit(f"{label}: unresolved placeholders {left[:5]}")
    if "[[" in page or "]]" in page:
        raise SystemExit(f"{label}: stray placeholder delimiters")
    # Counts written as words in prose are allowed to be literal, but they must AGREE
    # with what figures.py computes. Any disagreement is the 16-vs-19-vs-25 failure.
    checks = [(r"(\d+)\s+most competitive", "roster.n_districts"),
              (r"all\s+(\d+)\s+candidates in the", "roster.n_roster"),
              (r"(\d+)\s+candidates across (?:\d+) competitive districts", "roster.n_roster"),
              (r"(\d+)-race sweep", "roster.n_districts")]
    for pat, key in checks:
        for m in re.finditer(pat, page):
            if int(m.group(1)) != int(F[key]):
                raise SystemExit(f"{label}: prose says {m.group(1)} where {key} computes "
                                 f"{int(F[key])} — near {page[max(0,m.start()-50):m.start()+40]!r}")
    return True


def main():
    F = figures.get()
    # Phase A's disclosure states an arithmetic: the rows once flagged equal the verified
    # plus the House-bio records plus the downgraded. A fact-checker sums prose like this,
    # so the build sums it first and refuses to ship if it does not close.
    if (F["roster.n_tier_resourced"] + F["roster.n_tier_govbio"]
            + F["roster.n_tier3_wiki"]) != F["roster.n_tier_unverified_was"]:
        raise SystemExit(
            "tier accounting does not sum: %d verified + %d House-bio + %d downgraded != %d "
            "flagged. The disclosure paragraph states this as a closed sum; fix the figures "
            "or the prose before it ships." % (
                F["roster.n_tier_resourced"], F["roster.n_tier_govbio"],
                F["roster.n_tier3_wiki"], F["roster.n_tier_unverified_was"]))
    # The page's own prose counts, namespaced C. so they can never collide with a
    # measured figure -- a count of cards is a fact about this file, not about the data.
    for k, v in counts().items():
        F["C." + k] = v
    page, meta = build_page()
    page = fmt(page, F); verify(page, F, "predictions page")
    open(os.path.join(WEB, "2026-battleground-predictions.html"), "w", encoding="utf-8").write(page)
    story = fmt(build_story(), F); verify(story, F, "story page")
    check_must_appear([page, story], F)
    check_prose_confidences({"predictions page": page, "story page": story})
    check_typed_literals(page, "predictions page", F)
    check_typed_literals(story, "story page", F)
    check_titles(page, story)
    check_slugs({"2026-battleground-predictions.html": page,
                 "the-headline-i-never-re-ran.html": story})
    check_named_files(page, story, {fn for fn, _ in VERIFY_ROWS})
    check_superseded(page, story, F)
    n_terms, n_used = check_gloss_links(page, story)
    print(f"    glossary: {n_terms} terms defined, {n_used} reached by a '?' link")
    open(os.path.join(WEB, "the-headline-i-never-re-ran.html"), "w", encoding="utf-8").write(story)
    json.dump(F, open(os.path.join(WEB, "figures.json"), "w"), indent=1, default=float, sort_keys=True)
    print(f"predictions page: {len(page):,} bytes | {meta['n']} predictions, "
          f"{meta['lost']} already lost, mean confidence {meta['mean']:.2f}%")
    print(f"story page:       {len(story):,} bytes")
    print(f"figures resolved from figures.py: {len(F)} keys")


if __name__ == "__main__":
    main()
