Skip to content

Commit c81069d

Browse files
modelmirrorclaude
andauthored
fix(validate): refuse a committed vote score off a merits event
Resolving reviewer blockers on the stage gate. `vote_accuracy` is the evaluator's own field to write on a real cell — the harness computes it only in the stub and replay runners — so gating `pipeline.evaluate` and the leaderboard aggregate still left an agent free to commit a scored cert vote. The leaderboard refused to average it, but the artifact was wrong. `validate`'s `vote_accuracy_only_on_merits_events` closes that: it reads the stage off the committed `event.yaml`, exactly as `check_merits_predictions` does for the judgment half, and refuses the record rather than only the figure derived from it. It passes over every committed evaluation today. `docs/outcome-decomposition.md` still described the gate as owed by a future vote channel, contradicting `decision-model.md`; restated as current design. The SCDB entry's legacy-host claim was wrong. `scdb.wustl.edu` resolves and serves over plain HTTP — it only refuses TLS — and carries a live CC BY-NC 3.0 US `rel="license"` badge whose prose caption is HTML-commented out. The entry now states that rather than declining to look, and the conclusion is unchanged: neither host states terms for the release that would be imported, so the terms are unknown rather than permissive. `metrics/README.md` and `decision-model.md` claimed the ranked board's vote mean "is always null", which rested on data well-formedness rather than on construction; both now state the guarantee the code makes. `scores_votes` delegates to `declares` so the equality-not-identity rule holds by construction. A test pins that the aggregate keys on the cell's own event rather than the block it landed in. `test_scoring` keeps its coherent cert fixture instead of being repointed at a merits id to preserve an assertion the new evaluate tests cover properly. Refs #1090 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent fdc08ca commit c81069d

11 files changed

Lines changed: 234 additions & 68 deletions

docs/data-sources.md

Lines changed: 49 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -33,33 +33,43 @@ CourtListener roles once funding allows — see *The planned end-state* in
3333
[data-pipeline.md](data-pipeline.md). Adopting it requires reviewing that
3434
agreement's terms alongside the licenses below.
3535

36-
A second is planned and not yet adopted: the **Supreme Court Database**
37-
(SCDB) — the standing academic coding of every Supreme Court decision, and the
38-
only realistic route to per-Justice merits votes at scale. It is the channel
39-
`docs/decision-model.md` names as one that could populate `Outcome.votes` with
40-
the provenance block no docket text supports today. Its terms are why it is not
41-
adopted, and the finding there is a negative one:
42-
43-
- **No license is stated.** The project's current home is
44-
[`scdb.la.psu.edu`](https://scdb.la.psu.edu/), and none of its homepage,
45-
*About*, *Documentation*, *Data*, current-release, or *Cite Us* pages carries
46-
a license, a terms-of-use statement, or anything about redistribution. What
47-
they carry is a university copyright footer — "Copyright ©2026 The
48-
Pennsylvania State University" — and a citation request. A CC BY-NC license
49-
is widely attributed to this source second-hand and may well have been
50-
declared on the legacy Washington University host, but that host
51-
(`scdb.wustl.edu`) serves plain HTTP only and refuses TLS, so it could not be
52-
read here and nothing about it is asserted. **Treat the terms as unknown, not
53-
as permissive.**
54-
- **That is the blocker, and NC is why it matters.** If the license is a
55-
NonCommercial one, the question is not whether this pilot is commercial —
56-
unfunded research over public records, publishing no paid product, is the
57-
easy case — but that NC binds downstream reuse of everything derived under it,
58-
and this pipeline is built as a durable evaluation harness rather than one
59-
paper. An adoption decision has to answer that for the project's intended
60-
future. Since the site does not answer it either way, adopting this channel
61-
means **getting the terms in writing from the maintainers first**, not
62-
inferring them from a badge on a host that no longer resolves.
36+
A second such channel is planned and not yet adopted: the **Supreme Court
37+
Database** (SCDB) — the standing academic coding of every Supreme Court
38+
decision since the 1946 Term, and the only realistic route to per-Justice
39+
merits votes at scale. It is the channel [decision-model.md](decision-model.md)
40+
names as one that could populate `Outcome.votes` with the provenance block no
41+
docket text supports today. Its terms are why it is not adopted, and they are
42+
split across two hosts that do not agree:
43+
44+
- **The current home states no license at all.**
45+
[`scdb.la.psu.edu`](https://scdb.la.psu.edu/) is where the data is now
46+
published, and none of its homepage, *About*, *Documentation*, *Data*,
47+
current-release, or *Cite Us* pages carries a license, a terms-of-use
48+
statement, or anything about redistribution. What they carry is a university
49+
copyright footer — "Copyright ©2026 The Pennsylvania State University" — and
50+
a citation request.
51+
- **The legacy host carries a badge and no sentence.** `scdb.wustl.edu` still
52+
resolves and still serves the old site over plain HTTP (it refuses TLS, so it
53+
is unreachable to any HTTPS-only client). That page carries a live
54+
`rel="license"` badge linking
55+
[CC BY-NC 3.0 US](https://creativecommons.org/licenses/by-nc/3.0/us/)
56+
Attribution-NonCommercial, with no ShareAlike and no NoDerivatives term. The
57+
sentence that would have named the license in prose is **HTML-commented out**
58+
and renders to nobody, so an image link is the whole of the declaration.
59+
- **Neither states terms for the release we would actually import.** A badge on
60+
the superseded host is not a licence grant for the Penn State-published 2025
61+
release, and the publishing host says nothing. **Treat the terms as unknown
62+
rather than permissive**, and note that the more restrictive reading is the
63+
safe one precisely because the permissive-looking evidence is the stale half.
64+
- **That is the blocker, and NC is why it matters.** Taking the badge at face
65+
value, the question is not whether this pilot is commercial — unfunded
66+
research over public records, publishing no paid product, is the easy case —
67+
but that NC binds downstream reuse of everything derived under it, and this
68+
pipeline is built as a durable evaluation harness rather than one paper. An
69+
adoption decision has to answer that for the project's intended future. Since
70+
the publishing host answers it neither way, adopting this channel means
71+
**getting the terms in writing from the maintainers first**, not inferring
72+
them from a commented-out caption on a host the project has moved off.
6373
- **Attribution is specific and versioned.** The project asks to be cited with
6474
its full author list and the exact release, because the data is corrected and
6575
extended in place: "Please be sure to include the specific Version Number;
@@ -97,13 +107,17 @@ reuse across years, and the pair covers the corpus as it stands — SCDB
97107
publishes a docket-organized cut of both its case-centered and justice-centered
98108
files, so the join is against a shipped organization rather than a
99109
reconstruction. The U.S. Reporter citation (`usCite`) join is the more precise
100-
one and is deliberately **not** primary: it reaches only ~161 corpus rows today,
101-
and its coverage is gated on the opinion-cluster enrichment backlog (*Pull
102-
cadence* below) rather than on anything SCDB controls. It stays a confirmation
103-
path, not the key. Justice names normalize to the **entry-printed surnames**
104-
already in use — the `judgment.opinion_author` vocabulary — rather than to
105-
SCDB's justice-name or numeric justice-id variables, so a single spelling serves
106-
both the docket-derived authorship recital and any imported vote list.
110+
one and is deliberately **not** primary: it reaches only the corpus rows whose
111+
`citations` column is populated — a small minority, since the column fills only
112+
as the opinion-cluster enrichment backlog (*Pull cadence* below) works through
113+
the cert-granted slice, not from anything SCDB controls. It stays a confirmation
114+
path, not the key. Justice names normalize to the **entry-printed surnames** the
115+
authorship parser already reads — `pipeline.judgment.opinion_author`'s
116+
vocabulary — rather than to SCDB's justice-name or numeric justice-id variables,
117+
so a single spelling serves both the docket-derived authorship recital and any
118+
imported vote list. That parser is advisory today and takes one name token, so
119+
it would have to be hardened for multi-token surnames before it could be the
120+
normalization target in fact rather than in intent.
107121

108122
Two layers of rights apply, and they are different:
109123

@@ -152,7 +166,7 @@ The automated consumer stays within CourtListener's published API limits by desi
152166
site has no metered API; the client is simply polite (browser user-agent,
153167
~1 request/second, backoff on errors).
154168
- **SCDB would spend none either.** It publishes no API — access is bulk file
155-
download only, in CSV and Stata, offered as case-centered and
169+
download only, including CSV and Stata, offered as case-centered and
156170
justice-centered cuts (each organized by citation, by docket, or by
157171
issue/legal provision). So that channel's cost is a release pin and a
158172
re-download when the release moves, not a request budget, and it competes

docs/decision-model.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -487,8 +487,9 @@ gate enforces "merits-stage event ⇒ the scored prediction carries a judgment"
487487
from the committed `event.yaml`, the two halves meeting because a prediction
488488
does not carry its event's stage. The block is scored by `vote_accuracy`
489489
alone: over the Justices the outcome record actually names, under
490-
`vote_provenance` — never over what the predictor attempted, and never
491-
entering any total beyond that per-cell fraction. Today the merits outcome
490+
`vote_provenance` — never over what the predictor attempted. Beyond that
491+
per-cell fraction it enters one aggregate only, the merits block's
492+
`mean_vote_accuracy`, and no ranked total anywhere. Today the merits outcome
492493
writer records **no** votes, deliberately: the terminal docket entry's
493494
authorship recital names at most the opinion's author and never the
494495
participating count `VoteProvenance` requires as the aggregation denominator,
@@ -516,9 +517,13 @@ cannot be shown *not* to be cert. The consequence is that an ingestion channel
516517
populating `Outcome.votes` at the cert stage — noted dissents from denial are
517518
published on the order list and are the obvious candidate — changes nothing
518519
about what is scored. That is what makes the rule structural rather than a
519-
property of what a particular record contains, and the tests that fail when the
520-
gate is removed sit beside the scorer and the board (`tests/test_evaluate.py`,
521-
`tests/test_leaderboard.py`).
520+
property of what a particular record contains. A third seam covers the one the
521+
first two cannot: `vote_accuracy` is the evaluator's own field to write, so
522+
`validate`'s `vote_accuracy_only_on_merits_events` refuses to let a scored vote
523+
be *committed* off a merits event, rather than only refusing to aggregate it.
524+
The tests that fail when any of the three is removed sit beside the scorer, the
525+
board, and the gate (`tests/test_evaluate.py`, `tests/test_leaderboard.py`,
526+
`tests/test_replay.py`, `tests/test_validate.py`).
522527

523528
**`judgment_correct` is descriptive, not a score.** The exact-match bit on the
524529
full vocabulary (`Evaluation.judgment_correct`) reports

docs/outcome-decomposition.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -755,10 +755,11 @@ scores rather than before it is declared.
755755

756756
The per-Justice prohibition is structural, not stylistic: nothing in the
757757
mechanical family records or
758-
resolves a per-Justice dissent, and nothing writes one into `Outcome.votes`,
759-
because `docs/decision-model.md` pre-registers that an individual cert vote is
760-
never scored and a vote-populating channel owes a stage gate this claim does
761-
not pass through.
758+
resolves a per-Justice dissent, and nothing writes one into `Outcome.votes`.
759+
`docs/decision-model.md` pre-registers that an individual cert vote is never
760+
scored, and `pipeline.moments.scores_votes` enforces it — vote scoring is
761+
admitted only on a declared merits moment, so a channel that populated cert
762+
votes would not put this claim through a scored path.
762763

763764
**`cert-v1` is superseded where it is declared, and kept where it is the
764765
fallback.** A version id names what a cell was *asked*, and the declaration is

docs/predicted-artifacts.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -458,9 +458,9 @@ in prose.
458458
prediction name the right label on the stage's own axis: the disposition at
459459
cert and interim, the judgment at merits), `brier_score` on the stage's
460460
binary, `judgment_correct` on a merits cell only, `vote_accuracy` over the
461-
Justices both sides name — also on a merits cell only, because an individual
462-
cert vote is never scored (`docs/decision-model.md`) —
463-
`reasoning_quality`, a structured `leakage`
461+
Justices both sides name — also on a merits cell only, since that is the one
462+
stage whose votes are scored at all and an individual cert vote never is
463+
(`docs/decision-model.md`) — `reasoning_quality`, a structured `leakage`
464464
assessment over the harness-captured retrieval log, and the evaluator's own
465465
independent `big_case` read. `claim_scores`, `base_rate_salience_version`,
466466
and `process_version` are the harness's, never the evaluator's word.

src/fedcourtsai/cli.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1522,7 +1522,9 @@ def leaderboard(
15221522
Deterministic and offline: aggregates the newest committed
15231523
``evaluation.json`` per (case, event, predictor, evaluator) under ``data/``
15241524
into one best-first standing per predictor — accuracy, mean
1525-
Brier score, mean vote accuracy, a reasoning-quality summary, and counts,
1525+
Brier score, mean vote accuracy (declared merits moments only — an
1526+
individual cert vote is never scored, so the ranked board carries no vote
1527+
mean), a reasoning-quality summary, and counts,
15261528
each reported **per stratum** (forward forecasts vs retrospective cells vs
15271529
procedural mootness-basis cells, never blended and with only the timing
15281530
strata ranked; see the ``Leaderboard`` schema). The ranked board is the

src/fedcourtsai/pipeline/moments.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,9 +232,12 @@ def scores_votes(event_id: str) -> bool:
232232
:func:`spec_for` documents is the wrong one for a prohibition; a caller
233233
reading a vocabulary loses nothing by falling back, while a guard that
234234
falls back scores exactly the ids it cannot vouch for.
235+
236+
Expressed through :func:`declares` rather than re-reading the table, so the
237+
equality-not-identity rule that function documents holds here by
238+
construction instead of by inspection.
235239
"""
236-
spec = _BY_EVENT_ID.get(event_id)
237-
return spec is not None and spec.stage == Stage.merits
240+
return declares(event_id, Stage.merits)
238241

239242

240243
def first_moment(stage: Stage) -> Moment | None:

src/fedcourtsai/validate.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@
2525
prose document a ``prediction.json`` names resolves to a file beside it; every
2626
committed claims block is one the claim scorer will not silently void; and
2727
every merits-stage event's scored (latest-per-predictor) prediction carries
28-
its ``judgment`` — the stage-aware half of the merits prediction contract.
28+
its ``judgment`` — the stage-aware half of the merits prediction contract —
29+
and no evaluation carries a ``vote_accuracy`` off a merits event, since an
30+
individual cert vote is never scored and that field is the evaluator's own to
31+
write.
2932
3033
The verdict is a pure function of its inputs (corpus, ledger, baseline,
3134
tracked courts, as-of date), with no clock or network, so it is deterministic and
@@ -59,6 +62,7 @@
5962
CorpusScopeAudit,
6063
CorpusValidation,
6164
Disposition,
65+
Evaluation,
6266
EventKind,
6367
LedgerValidation,
6468
PredictableEvent,
@@ -98,6 +102,7 @@
98102
CHECK_PREDICTION_DOCS = "prediction_docs_exist"
99103
CHECK_PREDICTION_CLAIMS = "prediction_claims_scoreable"
100104
CHECK_MERITS_PREDICTIONS = "merits_predictions_carry_judgment"
105+
CHECK_SCORED_VOTES = "vote_accuracy_only_on_merits_events"
101106
CHECK_STALE_UNPARSED_GRANTS = "no_stale_unparsed_grants"
102107

103108
# How long after its cert grant a merits proceeding may sit with neither a
@@ -814,6 +819,58 @@ def check_merits_predictions(data_root: Path) -> CorpusCheck:
814819
return _check(CHECK_MERITS_PREDICTIONS, problems, checked=checked)
815820

816821

822+
def check_scored_votes(data_root: Path) -> CorpusCheck:
823+
"""No committed evaluation may carry ``vote_accuracy`` off a merits event.
824+
825+
An individual cert vote is never scored (``docs/decision-model.md``), and
826+
``pipeline.moments.scores_votes`` enforces that wherever the harness computes
827+
the figure. But on a real cell ``vote_accuracy`` is the *evaluator's* field to
828+
write, like ``brier_score`` — the harness stamps only ``claim_scores`` and the
829+
base-rate basis record — so the computed gate cannot speak for an agent that
830+
wrote the number itself. The leaderboard refuses to aggregate such a value,
831+
which keeps it out of every published total; this check refuses to let it be
832+
committed at all, so the prohibition holds on the artifact and not merely on
833+
the figures derived from it.
834+
835+
Read against the committed ``event.yaml`` rather than the moments register:
836+
an ``evaluation.json`` does not carry its event's stage, and the ledger's
837+
stage stamp is what a reader of the artifact sees. That makes this the same
838+
shape as :func:`check_merits_predictions` — the schema holds what it can
839+
self-contained, and the half needing the event definition lives here. A file
840+
that does not parse is ``validate_ledger``'s concern (schema law) and is
841+
skipped.
842+
843+
The directory test precedes the parse for the reason
844+
:func:`check_merits_predictions` gives: most events carry no evaluations, and
845+
``validate data`` runs once per cell in both fan-outs.
846+
"""
847+
problems: list[str] = []
848+
checked = 0
849+
for event_file in _ledger_files(data_root, "*/*/events/*/event.yaml"):
850+
evaluations_root = event_file.parent / "evaluations"
851+
if not evaluations_root.is_dir():
852+
continue
853+
try:
854+
event = PredictableEvent.model_validate(yaml.safe_load(event_file.read_text()))
855+
except (OSError, ValueError, ValidationError):
856+
continue
857+
if event.stage == Stage.merits:
858+
continue
859+
for path in sorted(evaluations_root.glob("*/*/*/evaluation.json")):
860+
try:
861+
evaluation = Evaluation.model_validate(json.loads(path.read_text()))
862+
except (OSError, ValueError, ValidationError):
863+
continue
864+
checked += 1
865+
if evaluation.vote_accuracy is not None:
866+
problems.append(
867+
f"evaluation {path}: carries vote_accuracy on "
868+
f"{event.stage or 'stage-less'}-stage event {event.event_id!r} — "
869+
f"a vote is scored only on a merits event"
870+
)
871+
return _check(CHECK_SCORED_VOTES, problems, checked=checked)
872+
873+
817874
# --- referential integrity (git-only subset, for the PR gate) ------------------
818875

819876

@@ -885,6 +942,7 @@ def run_ledger_referential_checks(data_root: Path) -> list[CorpusCheck]:
885942
check_prediction_docs(data_root),
886943
check_prediction_claims(data_root),
887944
check_merits_predictions(data_root),
945+
check_scored_votes(data_root),
888946
]
889947

890948

@@ -915,6 +973,7 @@ def _run_checks(
915973
check_prediction_docs(data_root),
916974
check_prediction_claims(data_root),
917975
check_merits_predictions(data_root),
976+
check_scored_votes(data_root),
918977
]
919978
return CorpusValidation(
920979
ok=all(c.passed for c in checks),

tests/test_evaluate.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
import pytest
2020

21-
from fedcourtsai import corpus
21+
from fedcourtsai import corpus, ids
2222
from fedcourtsai.pipeline.base_rates import (
2323
INTERIM_BASE_RATE_MIN_RESOLVED,
2424
MERITS_BASE_RATE_MIN_PARSED,
@@ -44,6 +44,7 @@
4444
BaseRateBucket,
4545
Disposition,
4646
Engine,
47+
EventKind,
4748
Judgment,
4849
JusticeVote,
4950
Outcome,
@@ -1036,15 +1037,24 @@ def test_vote_accuracy_is_denied_by_default_off_the_register() -> None:
10361037
Denial is the default rather than the cert stage being named: the register
10371038
is the only authority on an event's stage, so an id it does not declare —
10381039
an entry-pinned event, a record older than the table — is one that cannot be
1039-
shown *not* to be cert. Interim moments are declared and unscored for the
1040-
plainer reason that the stage forecasts no votes.
1040+
shown *not* to be cert.
10411041
"""
1042-
assert not scores_votes("evt-appeal-disposition")
1043-
prediction, outcome = _voting_pair("evt-appeal-disposition")
1042+
undeclared = ids.event_id(EventKind.appeal.value, "disposition")
1043+
assert not scores_votes(undeclared)
1044+
prediction, outcome = _voting_pair(undeclared)
10441045
assert vote_accuracy(prediction, outcome) is None
1046+
1047+
1048+
def test_vote_accuracy_is_not_scored_at_the_interim_stage() -> None:
1049+
"""Declared, and still unscored — for a plainer reason than the cert rule.
1050+
1051+
An interim moment is on the register, so this is not the deny-by-default
1052+
fallback: the stage simply forecasts no votes, and admitting only merits
1053+
moments is what makes that hold without a second rule.
1054+
"""
10451055
for spec in moments_for(Stage.interim):
1046-
interim_prediction, interim_outcome = _voting_pair(spec.event_id)
1047-
assert vote_accuracy(interim_prediction, interim_outcome) is None, spec.event_id
1056+
prediction, outcome = _voting_pair(spec.event_id)
1057+
assert vote_accuracy(prediction, outcome) is None, spec.event_id
10481058

10491059

10501060
def test_realized_band_rate_reads_a_retired_versions_alt_segments_block() -> None:

0 commit comments

Comments
 (0)