Skip to content

Judge-comparison core: joint scorer and panel-vs-single-judge analysis - #91

Open
andalibmalit wants to merge 5 commits into
partner-replay-modefrom
external-dataset-eval
Open

andalibmalit wants to merge 5 commits into
partner-replay-modefrom
external-dataset-eval

Conversation

@andalibmalit

Copy link
Copy Markdown
Contributor

Judge-comparison core on top of partner mode: a joint scorer variant and the panel-vs-single-judge analysis.

  • humanebench/joint_scorer.py: scores all 8 principles in one call per turn (vs. the overseer's one call per principle), with per-slug parsing, invalid-handling, and aggregation mirroring the per-principle overseer so joint-vs-decomposed comparisons aren't confounded by pipeline differences. Principle order is randomized per turn and recorded in score metadata for position-bias analysis.
  • src/partner_rejudge_joint_task.py: replay task using the joint scorer; run without --model.
  • scripts/compare_panel_vs_single_judge.py: compares the 3-judge panel against a dataset's original judgments under pre-committed analysis rules — ordinal-first (per-cell panel medians), flags-based denominators, per-stratum reporting, two Holm-corrected confirmatory contrasts with everything else exploratory, NaN-attrition accounting, and __rep2/__rep3 reliability slices.
  • Tests: tests/test_joint_scorer.py, tests/test_compare_panel.py (synthetic fixtures throughout).

Second in the stack. Part of #90

🤖 Generated with Claude Code

andalibmalit and others added 5 commits August 20, 2026 10:19
Selector fixes (10 findings from the scoped max review):
- Partition the severity space: worst / negative / positive_extreme /
  positive are now mutually exclusive classes; mixed turns (+1.0 alongside
  a negative cell) are negative-class, so the positive-extreme RTM control
  contains only clean positive-tail rows
- Membership flags use exactly the stratum predicates (single source of
  truth), fixing flag-vs-predicate denominator mismatches
- Zero-safe flag totals (all flag names present even at count 0)
- Independent seeded RNG stream per sampled stratum and for the repeat
  slice — adding or resizing a stratum never changes another's draws
- Unified manifest schema per stratum: eligible / residual_pool / labeled /
  sampling_fraction, with inclusion-probability semantics documented
- Between-run file written only when non-empty; convert/judge step for it
  documented in docs/partner-mode.md
- Tests: mixed-extreme overlap case, zero-flag safety, RNG-stream
  independence, unified schema

DRAFT (pending design gate, wired into no launch path): joint-prompt arm C
machinery — humanebench/joint_scorer.py (all-8-in-one-call panel scorer
mirroring overseer rules/retries), src/partner_rejudge_joint_task.py,
convert_partner_results.py --joint mode, tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
scripts/compare_panel_vs_single_judge.py implements the pre-committed analysis
rules against arm B's .eval log:
- ordinal-first: per-cell panel median, severity distributions, disagreement
- two Holm-corrected confirmatory contrasts, everything else exploratory:
  (a) FHR de-escalation on the worst stratum vs positive-extreme RTM control
      (one-sided Fisher on the asymmetry), (b) trivial -0.5 reproduction rate
      (one-sided exact binomial vs 0.5)
- denominators from curation.flags, per-stratum only; optional IPW pooled mean
  reweighted by the manifest sampling fractions
- NaN attrition per stratum; __rep2 within-run reliability (lower bound),
  optional __rep3 between-run log
- reads the nested sample.metadata.metadata shape and scores.overseer
  individual_scores verified against the pilot log

tests/test_compare_panel.py: 22 synthetic-fixture tests (ordinal median,
both contrasts incl. symmetric-RTM negative control, flags-based overlapping
strata, NaN attrition, rep2 pairing, IPW, .eval parsing incl. NaN + repeats).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Applies the approved arm-C reconciliation delta to the joint scorer: the 8
principles are presented in a per-turn RANDOMIZED order to average out
criterion-order/position bias.

- principle_order_for(sample_id, salt): deterministic per-turn shuffle, seeded
  from the sample id so the order is reproducible across runs (never a source
  of between-run variance) and identical across the ensemble's judges.
- build_joint_prompt(prompt, response, patterns=None): the given order drives
  BOTH the numbered principle list and the JSON output template, so no
  canonical ordering leaks back through the template. 2-arg calls still work.
- joint_overseer records `principle_order` in success- and failure-path
  metadata for a secondary position-bias analysis.

Coverage (all 364 base turns; converter --joint excludes rep2/rep3) and output
shape (severity + reasoning only) already matched the approved decisions — no
change needed there.

6 new ordering tests (permutation, per-id determinism, cross-id variation,
salt sensitivity, prompt-order fidelity). 14 joint tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correctness:
- holm(): drop NaN p-values from the family and correct over only the finite
  ones, so an empty/uncomputable contrast surfaces as n/a instead of forcing a
  valid contrast's adjusted p to 1.0 (a real pre-registered effect could have
  been silently declared non-significant).
- between-run reliability: filter candidates to repeat_kind=='rep3' (unify with
  within-run via _pair_reliability), so a mis-supplied --between-run-log can no
  longer pair base cells against themselves and print a false 100%.
- load_cells: skip sample ids without '__' loudly instead of crashing the run.
- IPW pooled mean: count and surface cells whose stratum is absent from the
  manifest (return n_used/n_dropped/dropped_strata; report warns) instead of
  silently biasing the pooled figure over a non-random subset.
- per-stratum original distribution: count off-scale and no-orig cells so the
  "original dist" column reconciles with the stratum's scored-cell count.

Report: empty-stratum p-values render as "n/a (empty stratum)" via _fmt_p.

Skipped (cleanup): sharing the ~8-line zipfile samples/ iterator with
compute_inter_judge_agreement.py would couple two standalone scripts for no
real benefit; kept self-contained.

5 new tests (holm-NaN non-poisoning, rep3 filter, bad-id skip, IPW dropped-cell
counting, orig off-scale reconciliation). 27 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The joint arm's parse/invalid/aggregation silently diverged from the
per-principle overseer it must mirror, biasing the very C-vs-B contrast arm C
exists to make. Reworked to match per-cell granularity:

humanebench/joint_scorer.py
- _coerce_invalid(): the top-level invalid flag now coerces the JSON string
  "false"/"no"/"0" to False (raw bool() treated any non-empty string as True,
  spuriously voiding all 8 principles). Matches the per-principle pydantic bool.
- parse_joint_response() is now tolerant per slug: a missing or off-scale
  severity omits only that principle instead of failing the whole 8-principle
  parse, so one hard-to-scale principle no longer NaNs the other seven. Raises
  only on total JSON-extraction failure (still retries a genuine format break).
- strict_ensemble_mean(): per-slug mean, NaN unless ALL judges validly scored
  the slug — the per-principle strict any-judge-fails=>NaN applied per principle.
- score(): single return shape; individual_scores/individual_reasoning (NaN/""
  for unscored slugs) + judge_invalid are ALWAYS present, so downstream reads
  no longer KeyError on judge-failure turns. Removed the all-NaN failure path.
- joint_pattern_score: documented that the empty-principle 0-fill is deliberate
  parity with the per-principle metric (not a divergence).

scripts/convert_partner_results.py
- convert_row_joint validates principle slugs against KNOWN_SLUGS (parity with
  convert_row) so a typo'd slug fails fast, not after the re-judge run.
- --sample-turns without --joint now errors instead of being silently ignored
  (was emitting the full dataset with a success message).
- rep-exclusion coerces sample_id to str (a non-string id crashed only --joint).
- accurate turn counts in the completion message (was counting pre-exclusion).

scripts/compare_panel_vs_single_judge.py
- load_cells rejects a JOINT (arm-C) log loudly instead of silently producing
  empty/garbage tables; arm C needs its own analyzer.

Skipped: _extract_json's //-comment stripping can corrupt URLs in reasoning —
real but pre-existing in shared scorer.py used by both arms; out of scope here.

15 new tests (invalid coercion, tolerant per-slug parse, strict ensemble mean,
joint-slug validation). 74 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@ErikaOnFire ErikaOnFire left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment only — no formal verdict yet. My read is approve-worthy once two decisions get made explicitly; neither is a bug. Stack context (mergeability, the absence of CI, and what could and could not be verified mechanically — including that the 675 lines of tests here were read but not run) is in my review on #90.

9 files, +2,008/−102:

  • humanebench/joint_scorer.py — 343 lines, scores all 8 principles in one call per turn, with per-turn randomized principle order seeded from the sample id
  • scripts/compare_panel_vs_single_judge.py — 741 lines
  • 675 lines of tests

The contract against humanebench/scorer.py holds

I checked the joint scorer against the single scorer point by point, because a parallel scorer that diverges quietly is the worst kind of bug here:

  • individual_scores is a list of per-judge severities in both (scorer.py:226).
  • The strict any-judge-fails ⇒ NaN rule is mirrored per-slug rather than collapsed to a single global failure.
  • _extract_json and InvalidOverseerResponse are imported rather than reimplemented — so the parsing behaviour cannot drift between the two scorers. This is the right call and it's the one I'd have worried about most.
  • HUMANE_PATTERNS keys equal the pattern ids, so the prompt's JSON template and the parser agree by construction rather than by convention.
  • load_cells correctly treats both NaN-valued scores and the early-return failure path as NaN cells. That second one is easy to miss: the early return at scorer.py:196-204 omits individual_scores entirely rather than filling it with NaN, so a reader who only handles the NaN-valued case gets a KeyError or a silently-dropped cell. You handle it.

Seeding the shuffle from the sample id is also the right choice over a global RNG — it makes the ordering reproducible per sample without coupling to iteration order.

Review point 1 — the confirmatory contrast pairs a one-principle treatment with an all-principles control

contrast_fhr_deescalation builds the treatment arm as c.slug == FHR_SLUG and c.orig_severity == -1.0, but builds the RTM control arm as c.flags.get("is_positive_extreme") and c.orig_severity == 1.0with no slug filter. So contrast (a) compares an FHR-only treatment against a pooled, cross-principle control.

The FHR-only control counts (ctrl_fhr_n, ctrl_fhr_regressed) are computed and printed as a descriptive, so the information is there. But the one-sided Fisher test uses the pooled control.

For a claim the PR itself frames as "a one-principle claim and is tested as one," pairing it with a cross-principle control should be an explicit reviewer decision, not an absorbed implementation detail. It may well be the right choice — a pooled control has more power and the regression rate may not vary much by principle — but that argument should be on the record before the number goes in a paper, because it's the first thing a referee will ask. Either filter the control to FHR, or state in the docstring why pooling is defensible here.

Review point 2 — holm() shrinks the pre-registered family from m=2 to m=1 when one contrast is empty

NaN p-values are dropped and the survivor is corrected over the remaining finite tests only. The docstring argues for this well, and the argument is correct as far as it goes: a NaN sorted first would force every valid contrast to 1.0, which is worse. A test pins the behaviour, so it's deliberate rather than incidental.

The thing I'd want said out loud: the effect is weaker FWER control than the pre-registration implies. If you pre-registered a family of two and one comes back empty, correcting the survivor at m=1 means it faces α rather than α/2 — which is a different test than the one that was registered. That's a legitimate position (you can argue the empty contrast was never a test), but it's a reviewer's call and it belongs in the analysis write-up, not only in a docstring.

Both of these are statistical rather than mechanical, and both are documented choices rather than oversights. I'm raising them because they're exactly the two places where "the code does what it says" and "the claim is the one that was registered" can come apart.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants