Add HumaneBench transcript-scoring Claude Code skill - #81
ErikaOnFire wants to merge 16 commits into
Conversation
Scores an existing AI conversation transcript against the eight HumaneBench principles (rubric v3). Single Claude judge by default (claude-sonnet-4-5, matching the published leaderboard judge for comparability); --ensemble runs the cross-family panel (Sonnet 4.5 + GPT-5.1 + Gemini 2.5 Pro) and surfaces per-judge divergence, spread, and sign-flips. Transcript parsing, judge-JSON parsing, aggregation, and report rendering are pure and covered by a 22-test suite (no keys needed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Code review (by Claude Fable)Overall this is solid, careful work. Purely additive, the pure logic is well factored from the network calls and covered by 22 offline tests (confirmed Blocking1. Degraded ensemble is misreported as "mitigated." In 2. A judge score of 0 is silently converted to -0.5. Non-blocking
SecurityFine. Keys from env only, no secrets in code, and sending transcripts to third-party APIs is inherent to the design (docs tell users to redact PII first). The broad VerdictApprove with changes. Items 1 and 2 are the ones to fix first, since both can produce a shareable report that misstates its own confidence, which is exactly what this skill's caveat discipline is trying to prevent. |
…n, doc accuracy Blocking: - A partial ensemble (some judges failed) no longer prints "mitigated / published methodology". render_report now takes the attempted-judge list; when it degrades it emits a bold PARTIAL ENSEMBLE / provisional caveat, and the JSON payload carries the actual judges, judges_attempted, and a degraded flag. - A judge score of exactly 0 (off-rubric — the rubric has no zero) is now rejected in parse_judge_json instead of being silently snapped to the negative tie (-0.5). Also: - Pin temperature=0 on the OpenAI (best-effort; falls back if the model rejects it) and Gemini judges to match the Claude judge; note determinism limits in the report. - load_transcript warns (via on_fallback) when JSON-looking input doesn't match a known transcript shape and is scored as raw text. - Docs: "matched human score direction 95.8% (23/24)" (was "consensus"); frame Sonnet 4.5 as one of the ensemble's judges, not "the leaderboard judge"; drop the unverified `ant auth login` claim; split ensemble-only SDKs into requirements-ensemble.txt. Test suite grows 22 -> 28 (offline, no keys). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for the thorough review — addressed in Blocking
Non-blocking
Deferred
|
…visibility - Medium: the "no judge produced a usable score" error now points at BOTH requirements files on an --ensemble run (the ensemble SDKs moved to requirements-ensemble.txt in the prior commit). - A degraded ensemble now labels its aggregate column and HumaneScore heading "Partial (N of M)", never bare "Ensemble", so a copied headline can't pose as the full ensemble. New test covers the 2-of-3 case (ensemble & degraded both true). - Determinism caveat now renders on degraded ensembles too (moved out of the not-degraded-only branch); it's the exact case where the OpenAI fallback may fire. - OpenAI temperature fallback now requires a 400 status AND a temperature message (was a bare substring match over any Exception) so an unrelated error can't trigger a silent duplicate paid request; it also prints a NOTE to stderr when it falls back. - Narrowed the zero-rejection comment to accurately describe guarding only the exact prohibited zero (near-zero values still snap to a side by design). Tests: 29 (offline, no keys). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Second pass (
Still deferred (unchanged rationale): per-judge |
…lback - Record temperature pinning in the DATA, not just stderr: judges now return a JudgeCall carrying temperature_pinned, threaded through aggregate into per_judge, so the JSON payload records which judges ran at temperature 0. The report adds a "Not pinned this run" caveat naming any judge that fell back. - Give the Gemini judge the same temperature-0 + graceful-fallback path as OpenAI (it previously pinned unconditionally and would drop the judge if the model refused), so the generalized determinism caveat is now accurate for both non-Claude judges. - Extract _is_temperature_400 as a pure, provider-agnostic helper (checks status_code/code or "400" in text AND a temperature message) and unit-test it against fake exceptions — the predicate that gates a paid retry is no longer untestable inline logic. - Extract _install_hint(ensemble) pure helper (+ tests); use it in the failure message. - JSON aggregate now carries is_full_ensemble / judges_used / judges_attempted inside the ensemble object, so a scraped aggregate.ensemble number can't pose as the full ensemble. - Fix nested parens in the degraded HumaneScore heading -> "HumaneScore [Partial (2 of 3)]". - Add report assertions: determinism caveat absent for single-judge, present for ensemble. - output_template.md documents the Partial (N of M) variant. Tests: 42 (offline, no keys). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Medium: _is_temperature_400 no longer trips on an unrelated error. When the SDK exposes a status it is authoritative (only an exact 400 qualifies — a 429/500 whose text merely contains "400" does not); the "400"-in-text heuristic is used only when no status exists, and now matches a standalone token (\b400\b) so 4001/8400/24000 don't. Tests added. - Ensemble self-description now lives in aggregate(judge_results, judges_attempted=None), not bolted on in main(), so every caller (and the tests) gets it. Nested counts renamed n_judges_used / n_judges_attempted to avoid a type clash with the top-level judges_attempted (a name list). Tests cover 3-of-3, 2-of-3, and single-judge. - Parameterize _try_temperature_0 with a TypeVar so callers aren't operating on `object`. - Replace a now-vacuous test assertion (old "(ensemble)" heading can never appear) with the real [Ensemble] / [Partial (N of M)] label checks, positive and negative. - output_template.md documents the HumaneScore heading forms too, not just the column. Tests: 48 (offline, no keys). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Medium: remove the second source of truth for "degraded". render_report now reads the attempted count from the aggregate's own n_judges_attempted marker (authoritative), using meta only for the display names, so the report can't contradict the aggregate object. The 2-of-3 and 1-of-3 tests now build the aggregate WITH judges_attempted and assert is_full_ensemble is False on the very object the report labels partial. - Fix a regression from round 4: a non-numeric code slug (OpenAI APIError.code is a string like "unsupported_value") is no longer treated as an authoritative status. Only a numeric status (int or digit string) is decisive; otherwise fall through to the \b400\b message check, so a genuine "Error code: 400 - temperature ..." is retried, not dropped. Test added. - aggregate() defaults is_full_ensemble to None (unknown) when judges_attempted is omitted, instead of the permissive True — a scraper can tell "verified full" from "unspecified". - payload.degraded is now derived from the aggregate markers (not a parallel computation), with a comment naming aggregate.ensemble as canonical for the degraded/full question. - Dropped a duplicate _is_temperature_400 test. Tests: 48 (offline, no keys). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Medium: render_report no longer re-derives full-vs-partial from counts. It reads the aggregate's own is_full_ensemble verdict (True/False/None) as the single source of truth. Previously an aggregate whose verdict was None (attempted set unknown) still rendered "[Ensemble]" + "published methodology" — asserting exactly the claim the aggregate declined to make. None now renders a hedged "[Multi-judge]" label and a "partially mitigated (unverified)" caveat instead of the full-ensemble claim. - Low: the PARTIAL banner only names the attempted judges when the recorded names actually match the attempted count; otherwise it drops the parenthetical rather than listing the *succeeded* judges as if they were the requested set. - Low: _is_temperature_400 narrows numeric-status detection to match its docstring — a bool is ignored (not coerced True->1), a real int is taken directly, and a str must be all-digits (after strip) before int(); anything else falls through to the \b400\b message check. - Low: aggregate() docstring now documents is_full_ensemble as bool | None with the meaning of None (attempted set not supplied). - Tests: assert is_full_ensemble is None (identity) when judges_attempted omitted; tighten degraded assertions to assertIs(..., False); add full-ensemble [Ensemble] label test, unverified [Multi-judge] test, non-numeric-code-no-400, bool-status, and padded-digit-status cases. 48 -> 53 (offline, no keys). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
roborev round 6 —
|
Round 6 over-corrected. Making `degraded` require `verdict is False` made the provisional banner UNREACHABLE for a marker-less/legacy aggregate whose only evidence of a dropped judge is meta's attempted count — the exact regression this series has been hardening against. - Medium: degraded = verdict is False OR (verdict is None AND n_used < n_attempted). The verdict stays authoritative when present; when it's None, meta's count is honored so the PARTIAL warning isn't silently lost. A confident full "Ensemble" is still never claimed on a None verdict — that path reaches only "Partial (N of M)" or the hedged "Multi-judge". Simplified the now-tautological `verdict is False and degraded` to `if degraded`. - Low: payload["degraded"] derived from the verdict (is_full_ensemble is False), not a parallel count comparison, so the JSON and markdown can't disagree (main() always supplies judges_attempted, so its verdict is concrete). - Low: reworded the bool comment (True/False coerce to 1/0, never 400, masking the message check) and documented the [Multi-judge] heading form in output_template.md. - Tests: banner-omits-names-when-count-mismatches (exercises the who="" suppression) and marker-less-aggregate-honors-meta-count (the restored-PARTIAL regression). 53 -> 55. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
roborev round 7 — Round 6's fix over-corrected. Requiring
|
Round 7's `degraded = verdict is False or ...` regressed the MOST common path. aggregate() returns is_full_ensemble=False for a single judge too (one judge is deliberately "not an ensemble"), and main() always passes judges_attempted, so every default (no --ensemble) run got verdict False, n_used==n_attempted==1 -> degraded True: the ordinary single-judge report emitted "PARTIAL ENSEMBLE — PROVISIONAL. Only 1 of 1 judges…" and dropped the loud single-judge same-family-tilt caveat entirely. My tests missed it because none rendered a single-judge aggregate the way main() builds one. - High: extract _is_degraded(n_used, n_attempted, verdict) = n_used < n_attempted and verdict is not True — an actual drop is required, so a single-judge run (1 of 1) is never degraded, a full ensemble is never degraded, and both a False and a None verdict with a real drop are. render_report and the JSON payload now BOTH call it, so they apply one rule and can't drift. - Low: reworded the payload comment to state the shared source instead of asserting an invariant the two formulas didn't actually share. - Low: names_match also requires the recorded names to cover the succeeded judges (set(judges) <= set(attempted_names)), so a same-length-but-wrong-membership list can't be printed as the requested set either. - Tests: main()-shaped single-judge run asserts NOT mitigated + no PARTIAL banner + degraded False; tilt-warning test asserts "single-judge** score" (unique to that branch) not a substring shared with the partial caveat; new TestIsDegraded truth table (single / full / False-drop / None-drop / None-no-drop). 55 -> 61. - Verified all four canonical renders end-to-end (single/full/partial/unverified). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
roborev round 8 — Round 7 regressed the most common path.
Rendered outputs confirmed: single → |
…d wiring All Low (round 8 cleared the High/Medium). Polish: - _is_degraded drops the `verdict is not True` conjunct — it was dead for every aggregate aggregate() builds (verdict True already implies n_used==n_attempted) and harmful for a self-contradictory foreign one (verdict True WITH a drop would suppress the warning and claim the full ensemble). Degradation is now purely `n_used < n_attempted`: the count is the authoritative-negative signal, so a contradictory input degrades (the loud, safe direction) instead of emitting the most confident output. This also makes `verdict` a dead param, so it's removed rather than annotated. - Extract _build_payload(meta, succeeded, judge_names, agg) and call it from main(), so the JSON's degraded wiring is now unit-tested (TestBuildPayload: one-judge -> False, 1-of-3 -> True). Reverting that line would now fail the suite; before it was untested. - Tests: TestIsDegraded simplified to the count truth table; new membership-guard render test (3 names of the right cardinality but wrong membership -> parenthetical still suppressed); dropped the duplicate direct-helper assertion the payload test now covers. Tests: 61 (offline, no keys); import clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
roborev round 9 —
61 offline tests, import clean. |
All Low/Medium polish on the round-9 refactor. - Medium: the caveat chain still tested `verdict is True` before `degraded` while the label/banner chain (round 9) tests `degraded` first, so a self-contradictory foreign aggregate (is_full_ensemble True WITH a drop) got a "Partial (2 of 3)" heading and PARTIAL banner *and* the "published HumaneBench methodology" claim in one report. Reordered the caveat chain to match: degraded -> verdict True -> ensemble_attempted -> single. The confident claim can no longer co-occur with the banner denying it. - Low: reworded the `degraded =` comment (the old "a confirmed-full ensemble is never degraded" contradicted _is_degraded's own verdict-agnostic docstring). - Low: _build_payload now derives both name lists (judges from the aggregate, judges_attempted from meta) instead of taking two adjacent same-typed list params that could be transposed silently at the call site; signature is now (meta, agg). - Tests: contradictory-True-with-drop render (asserts PARTIAL + Partial (2 of 3), no "published methodology"); payload name-lists-are-derived-not-transposable; updated the payload tests to the new signature. 61 -> 63. Re-verified all four canonical renders. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
roborev round 10 —
All four canonical renders re-verified: single → NOT mitigated / no banner; full → |
All Low — the round-10 _build_payload simplification introduced its own gaps. Fixed by
factoring the shared logic instead of duplicating it:
- Low: _build_payload's meta.get("judges_attempted", per_judge) fallback silently emitted
the *succeeded* judges as the requested set when meta didn't record one — the exact
misrepresentation render_report's name guard prevents, and it could contradict its own
payload (judges_attempted=[2 names] beside degraded=true, n_judges_attempted=3). New
_trusted_attempted_names() emits the names only when trustworthy (right count AND covers
the scorers), else null; both the report banner and the JSON use it.
- Low: _build_payload hard-indexed ens["n_judges_*"] while render_report used .get() with a
meta fallback, so a marker-less aggregate that renders a correct Partial (2 of 3) report
would KeyError in _build_payload. New _resolve_counts() does the tolerant resolution once;
both consumers call it, so they can't drift and neither KeyErrors.
- Low: the derived-names test asserted list(agg["per_judge"]) (restated the impl); now
asserts the literal ["Claude Sonnet 4.5", "GPT-5.1"] so a wrong derivation is caught too.
- Tests: payload omits untrusted attempted names (-> null, not the succeeded set); payload
handles a marker-less aggregate without KeyError. 63 -> 65. Canonical renders re-verified.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
roborev round 11 —
|
- Medium: _resolve_counts took n_used from the n_judges_used MARKER, which a foreign aggregate can inflate to hide a drop (2 judges present, marker says 3 -> degraded False, "[Ensemble]", payload degraded=false beside judges of length 2 — the confident- output-from-contradictory-input that rounds 8-10 removed for is_full_ensemble, one field over). n_used is now len(per_judge) — the scorers actually present, counted, not read; the marker is only a fallback when per_judge is absent. Deleted the dead `n_used = len(judges)` in render_report so `ensemble` (table) and the banner provably share one count. - Low: an untrusted judges_attempted was nulled at the top level but still echoed verbatim inside `meta`, so a scraper reading meta.judges_attempted got the rejected value. _build_payload now scrubs the key from the echoed meta when it's untrusted. - Low: documented that the cardinality half of the trust check only bites when the aggregate carries its own n_judges_attempted; on a marker-less aggregate membership is the sole guard (n_attempted is derived from len(meta[...]) there). - Low: annotations — _resolve_counts -> tuple[int, int], _trusted_attempted_names -> list | None. - Low: SKILL.md documents judges_attempted as `list | null` in the JSON payload. - Tests: inflated-n_judges_used still degrades (payload + count); untrusted names dropped from meta too. 65 -> 67. Canonical renders re-verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
roborev round 12 —
|
- Medium: scrubbing an untrusted judges_attempted from meta could leave a marker-less
aggregate's payload with degraded=true and no recoverable M anywhere in the JSON. Promote
the resolved n_judges_used / n_judges_attempted to the top level so `degraded` is self-
evidencing regardless of what gets scrubbed (the markdown already keeps "N of M"; now the
JSON does too — no drift).
- Low: dropped the `else ens.get("n_judges_used", 0)` fallback in _resolve_counts — it was
unreachable for an absent per_judge (both callers index per_judge first) and re-admitted
the spoofed marker for a present-but-empty one. n_used is now simply len(per_judge).
- Low: _trusted_attempted_names annotation -> list[str] | None (unquoted-style union,
matching the rest of the module; keeps the element type callers branch on).
- Low: moved the "n_used == len(judges)" comment onto its own line above the call instead
of draped across three unrelated assignments.
- Low: SKILL.md documents the top-level counts and that meta.judges_attempted is dropped
with the top-level field.
- Tests: render-side inflated-marker still degrades (pins the deleted-n_used fix on the
render side, not just payload); payload degraded is self-evidencing; marker-less +
untrusted-names keeps N/M. 67 -> 70. Canonical renders re-verified.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
roborev round 13 —
|
…ests
- Medium: the promoted top-level n_judges_used/attempted share a name with
aggregate.ensemble.n_judges_*, and for a foreign aggregate the nested copy is the raw
(possibly inflated) marker _resolve_counts refuses to read. Rather than mutate the shared
aggregate, documented the contract: the TOP-LEVEL counts are authoritative (count the
judges actually present); aggregate.ensemble.* is the aggregate's own raw self-report,
equal to the top-level for any run this tool produces. Fixed SKILL.md (it resolved
precedence against `meta`, which never held counts) and the stale aggregate() docstring
clause ("named distinctly ... to avoid a type clash" no longer described the payload).
- Low: render inflated-marker test now also sets is_full_ensemble True, so
assertNotIn("[Ensemble]") is discriminating (was vacuous — a False verdict alone already
forces "Multi-judge"); asserts the "published methodology" claim is absent too.
- Low: payload inflated-marker test now asserts the top-level counts are the resolved (2, 3)
and documents that the nested marker stays 3 (the divergence is intended and contract-doc'd).
No logic change — top-level counts were already authoritative; this pins and documents it.
Tests: 70 (offline, no keys).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
roborev round 14 —
70 offline tests. |
Code review — max effort, multi-agent (by Claude Fable orchestrating Opus sub-agents)Scope: full PR diff vs Verdict: request changes — but close. Nothing is architecturally wrong, all four deferred items are genuinely deferrable, and the degraded-ensemble labeling chain held up under fresh adversarial tracing (the four canonical render paths are coherent; markdown and JSON never disagree; the spoof-resistance guards hold). What blocks merge is one residual gap in that very mechanism, one undisclosed methodology divergence, a report-forgery hole, two stale doc claims, and one test blind spot. One focused round should clear it. Blocking
Should fix (this round if cheap, else fast-follow)
Noted — fine as follow-ups
The four deferred items — all defensible
What checks out (worth keeping as-is)The no-zero forced-choice scale is a legitimate central-tendency mitigation (compression migrates to ±0.5 rather than vanishing — worth one caveat word). The same-family-tilt caveat is well-grounded (Panickssery et al., NeurIPS 2024); "tilt" is the right calibration since magnitude is contested. Spread/sign-flip reporting is endorsed as a low-confidence flag (Trust or Escalate, ICLR 2025) — frame high agreement as "no flag," not validation, since judges can agree from shared bias. Graceful degradation with loud labeling is closer to ecosystem norm (Inspect's majority-vote multi-grader, Docent's typed failures) than the harness's strict-NaN — keep it, keep saying so. And the much-churned temperature fallback is necessary, not paranoia: GPT-5.1-class reasoning models genuinely 400-reject pinned temperature. Secrets hygiene, pytest isolation, merge interactions (paths, CODEOWNERS, .gitignore): all clean. Ecosystem context (for the README, not blockers)Closest relatives: Transluce Docent (rubric-based LLM-judge transcript analysis, Apache-2.0 — also scores jointly per call and ships no ensemble; its transcript-span citations are worth stealing) and wenxuec/llm-judge (the one comparable Claude Code judge skill — ships a human-calibration script, Cohen's κ/Spearman, which is the biggest methodological feature this skill lacks). Inspect AI already offers native multi-grader scoring, so the README should say in one sentence why this is standalone (portability). The specific niche — fixed humane-tech rubric, transcript-level, as a Claude Code skill — appears unoccupied. Post-merge ideas: span citations, a κ-calibration script, test-retest judge consistency (Rating Roulette, EMNLP 2025). One process note: two round-1 fixes were reported as landed but weren't (items in Blocking 4) — worth a final claimed-vs-actual self-audit before re-requesting review. 🤖 Review orchestrated with Claude Code (Fable 5 orchestrating Opus finder/verifier/research agents; all findings independently reproduced before reporting) |
Doc/test only. The round-14 contract text overstated the guarantee: it said BOTH top-level counts "count the judges actually present", but _resolve_counts only counts n_used — n_attempted is read straight from the aggregate's n_judges_attempted marker (or meta when absent). So an inflated attempted marker passes through to the top level unchanged; it isn't independently verified. (It's a safe pass-through: a too-large attempted count only makes `degraded` MORE conservative, never hides a drop.) - SKILL.md + aggregate() docstring: scope the "counted, authoritative" claim to n_judges_used (which overrides its marker); state that n_judges_attempted is passed through from the marker and only ever errs conservative. Fixes the self-contradiction with _resolve_counts' own docstring. - Test: inflated n_judges_attempted=7 (2 present) -> top-level n_judges_attempted 7, n_judges_used 2, degraded True; documents the pass-through-by-design asymmetry. 70 -> 71. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
roborev round 15 — Round 14's contract text overstated the guarantee — it claimed both top-level counts are "counted from the judges actually present," but Roborev has auto-paused review at 16 un-landed commits ("usually a fix→review→fix loop … resolve open findings and land it"). The substantive work converged several rounds ago; the recent rounds have been doc-contract and test-discrimination refinements on hand-built-aggregate edge cases This PR is ready for code-owner ( |
|
Babysit status — this PR is merge-ready except for the required review. Verified just now against the head commit (
🤖 Generated with Claude Code |
Adds a self-contained Claude Code skill at
skills/humanebench-transcript-score/that scores an existing AI conversation transcript against the eight HumaneBench principles (rubric v3).What it does
claude-sonnet-4-5— deliberately matches the published leaderboard judge so single-judge scores stay comparable). Ships a same-family-tilt caveat in the report.--ensemble: the real methodology — Sonnet 4.5 + GPT-5.1 + Gemini 2.5 Pro. Surfaces per-judge scores, judge spread, and sign-flips so divergence is structural, not hidden.Contents
SKILL.md/README.md— usage, keys, invocationscripts/humanebench_score.py— scorer (network calls isolated in*_judgefns; everything else pure)scripts/test_scoring.py— 22 unit tests, no API keys neededreferences/rubric_v3.md— embedded copy of the canonical rubric (lightly reformatted; substantive content matchesrubrics/rubric_v3.md) so the skill is portableexamples/,references/— sample transcript, output template, transcript-format notesVerification
python scripts/test_scoring.py→ 22/22 pass (transcript parsing, judge-JSON parsing, aggregation, report rendering — all pure, offline).skills/dir;pytest.inisetstestpaths = testsand skipsscripts, so the existing repo suite is unaffected.pip install -r scripts/requirements.txt+ANTHROPIC_API_KEY(+ OpenAI/Gemini keys for--ensemble).Note on the embedded rubric
The embedded
references/rubric_v3.mdis kept as a real file (not a symlink) for portability. To keep it from drifting from the canonicalrubrics/rubric_v3.md, re-sync on rubric changes (a small CI check comparing the two is a reasonable follow-up).🤖 Generated with Claude Code