Skip to content

feat(eval-harness): judges/llm_judge.py — opt-in LLM-judge stage (#592) - #613

Merged
robotrocketscience merged 3 commits into
mainfrom
feat/issue-592-llm-judge
May 10, 2026
Merged

feat(eval-harness): judges/llm_judge.py — opt-in LLM-judge stage (#592)#613
robotrocketscience merged 3 commits into
mainfrom
feat/issue-592-llm-judge

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented May 10, 2026

Copy link
Copy Markdown
Owner

Closes part of #592 (commit-3 of three: LLM-judge stage). Commit-1 shipped as #596; commit-2 (replay split) is in flight as PR #601.

What lands

A standalone judge module under benchmarks/context-rebuilder/judges/llm_judge.py that takes the open-ended replay rows the deterministic substring scorer cannot settle (reason="needs_llm_judge") and routes them through an opt-in, off-band judging stage. The harness's main flow is untouched in this PR; the integration commit that wires --run-dir to the judge stage joins onto PR #601's plumbing in a follow-up.

Three commits, atomic:

  1. feat(eval-harness): judges/llm_judge.py for open-ended fidelity (#592) — the module: JudgeRequest / JudgeResponse dataclasses, write_judge_requests, read_judge_responses, apply_judge_verdicts, the prompt template, and the tier-label constant. No provider SDK import; dispatch is host-polymorphic per /aelf:onboard's established pattern.
  2. test(eval-harness): judges/llm_judge round-trip + contamination guard (#592) — 14 deterministic tests covering the contamination boundary, the cost cap, round-trip parsing, malformed-line skip, fold-verdicts purity, and the anchor-tier canary.
  3. docs(eval-harness): LLM-judge stage operator flow + CHANGELOG (#592) — README section near the bottom of benchmarks/context-rebuilder/README.md (deliberately placed away from PR feat(eval-harness): replay_post_fork host-agent JSONL split (closes #600) #601's likely edit region) plus an unreleased CHANGELOG entry under ### Added.

Two design decisions diverge from the issue draft

The issue body's draft sketches the LLM-judge stage but two pieces of it conflict with prior-decision artifacts in the repo. Both divergences were confirmed before claim.

Judge prompt shape. Issue draft proposed (rebuilt_block, user_turn, expected, actual). The shipped docs/BENCHMARKS.md is explicit ("Generation and scoring are separate passes. The judge never sees the retrieval context"), and lists "LLM self-judging with answer visible" as one of three contamination modes that produces 0% results. Including the rebuilt block in the judge prompt would let the judge patch the candidate with details the candidate didn't produce, inflating fidelity. This PR ships the strict shape: (turn_idx, expected, actual) only. Asserted in test_judge_request_schema_carries_only_turn_idx_expected_actual — a future widening fails loudly.

Judge model tier. Issue draft proposed the cheaper small-model tier. The repo's prior calibration work (Cohen's-κ disagreement methodology against a zero-LLM baseline) was measured at the host CLI's anchor tier. Dropping to the small tier weakens comparability on exactly the open-ended turns this stage exists to score. This PR encodes the tier via a logical label (JUDGE_MODEL_TIER = "anchor") so the operator's host-CLI catalog can map it to the appropriate concrete model without baking vendor identifiers into the module.

Cost posture

max_judge_calls=0 by default — the stage is disabled, no file is written, no calls are issued. CI runs are free and outbound traffic is opt-in. When operators pass a positive cap, the cap binds before any file I/O so a misconfigured run cannot exceed the call budget. The harness layer that automates the dispatch step lands later (it depends on PR #601's --run-dir contract).

Operator flow

Three pure-Python helpers plus an operator-driven host-CLI dispatch step in the middle. README has the worked example with paste-ready scripts:

  1. llm_judge.write_judge_requests(rows, run_dir, max_judge_calls=N) — produces <run_dir>/judge_requests.jsonl.
  2. Host CLI dispatches off-band model calls at the anchor tier, writes <run_dir>/judge_responses.jsonl.
  3. llm_judge.apply_judge_verdicts(rows, llm_judge.read_judge_responses(run_dir)) — folds verdicts back into the replay rows, clears reason on matched rows, adds judge_rationale.

Why this doesn't conflict with PR #601

PR #601 (Planck, in attn:merge-conflict at time of writing) closes #600 — the replay_post_fork host-CLI split and the substring half of score_fidelity. Its body explicitly out-of-scopes the LLM-judge ("LLM-judge for open-ended turns — commit-3 of #592, separate cost-bounded surface"). This PR's only contact with PR #601's edit surface is the shared reason="needs_llm_judge" tag, which is a string constant — no source-file overlap with eval_harness.py or score_fidelity proper.

The README is technically a shared file but the new section is appended near the bottom, away from the "Why LLM-judge is parked" / "Method toggle" subsections #601 will likely touch.

Test plan

  • uv run pytest tests/test_context_rebuilder_eval_judge.py -v — 14 passed locally
  • CI green on pytest (3.12) and pytest (3.13)
  • Reviewer confirms the contamination-boundary divergence from the issue body is the right call (vs. shipping the issue body verbatim)

Summary by Sourcery

Introduce an optional LLM-based judging stage for open-ended context-rebuilder eval turns, with request/response helpers, strict contamination boundary, and operator-driven dispatch, plus supporting docs and tests.

New Features:

  • Add a host-polymorphic LLM judge module that scores replay rows tagged as needing LLM judgment using per-run request/response files.
  • Provide helper functions to write judge requests, read judge responses, and fold verdicts back into replay results while enforcing a strict no-retrieval-context schema.

Enhancements:

  • Enforce a default-off, cost-capped configuration for the LLM judge stage via a max_judge_calls limit and an anchor-tier model label for calibration consistency.

Documentation:

  • Document the LLM-judge stage workflow, contamination boundary, and operator flow in the context-rebuilder README and note the new stage in the unreleased CHANGELOG.

Tests:

  • Add deterministic tests covering judge eligibility filtering, cost-cap behavior, request/response round-tripping, verdict folding purity, prompt template requirements, and the anchor-tier model label.

Adds the LLM-judge stage for commit-3 of #592. Open-ended replay rows
that the deterministic score path tags `reason="needs_llm_judge"` are
written to a per-run `judge_requests.jsonl`; the host CLI dispatches
one off-band model call per row at the anchor tier and writes
`judge_responses.jsonl`; the harness joins the verdicts back by
`turn_idx`.

Contamination protocol (docs/BENCHMARKS.md): the judge sees only
`(turn_idx, expected, actual)`. Retrieval context (rebuilt block,
user turn) never reaches the judge, per the Pass 1 / Pass 2 separation
required for memory-system eval integrity.

Cost posture: `max_judge_calls=0` by default — the stage is disabled
and writes nothing in CI. Operators opt in by passing a positive cap;
the cap binds before file I/O, so misconfigured runs cannot exceed
their budget. Tier label is "anchor"; the operator maps that to their
host CLI's calibration-baseline tier (not the small-model tier below
it) so comparability against the prior calibration work holds.

No provider SDK import. Dispatch is host-polymorphic, matching
/aelf:onboard's LLM-classify pattern and #600's replay split.

Refs #592 (commit-3 of three). Independent of PR #601 (commit-2);
their contracts join at the `reason="needs_llm_judge"` tag.
…#592)

14 tests covering the judge stage's contracts:

- contamination boundary: request file carries only (turn_idx, expected,
  actual); a future schema widening that adds rebuilt_block / user_turn
  fails this test loudly.
- cost cap: max_judge_calls=0 (default) writes no file; positive caps
  bind before write so misconfigured runs cannot exceed budget.
- eligibility: only `reason="needs_llm_judge"` rows with non-empty
  expected and actual are surfaced for judging.
- response round-trip: malformed lines skipped; absent file returns {}.
- fold verdicts: pure function, clears `reason` on matched rows,
  preserves needs_llm_judge tag when no response present yet.
- anchor constants: JUDGE_MODEL_TIER=='anchor' canary, prompt-template
  carries the two judgeable fields.

CI invokes no real model; the operator-side dispatch is hand-mocked
via pre-authored judge_responses.jsonl, identical in shape to what a
host CLI would write.
README adds a self-contained "LLM-judge stage (commit-3 of #592)"
section near the bottom: contamination-boundary rationale, the
three-step operator flow (write requests -> host-side dispatch ->
fold verdicts), and a forward pointer to the harness-level wiring
that joins onto PR #601's --run-dir plumbing.

Section placement is deliberately near the end of the file so the
diff does not touch PR #601's likely edit region (the existing
"Why LLM-judge is parked" / "Method toggle" subsections under
"Continuation-fidelity scoring").

CHANGELOG entry under [Unreleased] / ### Added, ahead of the
existing #592 commit-1 entry. Documents the contamination boundary
+ default-off cost posture + anchor-tier judge decision.
@robotrocketscience robotrocketscience added the author-Maxwell PR coordination mutex label May 10, 2026
@sourcery-ai

sourcery-ai Bot commented May 10, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds an opt-in, file-based LLM-judge stage for open-ended context-rebuilder eval rows plus tests and docs, without touching the main harness flow.

Sequence diagram for the opt-in LLM-judge operator flow

sequenceDiagram
    actor Operator
    participant EvalHarness as EvalHarness_or_helper
    participant LLMJudge as llm_judge_module
    participant FS as FileSystem
    participant HostCLI as Host_CLI_dispatcher

    Operator->>EvalHarness: Run eval_harness.py (produces replay_results.jsonl)
    EvalHarness-->>Operator: replay_results.jsonl with reason=needs_llm_judge rows

    Operator->>LLMJudge: write_judge_requests(rows, run_dir, max_judge_calls=N)
    LLMJudge->>LLMJudge: _eligible_rows(filter by reason, expected, actual)
    alt max_judge_calls <= 0 or no eligible rows
        LLMJudge-->>Operator: 0 (no file written)
    else
        LLMJudge->>FS: Write judge_requests.jsonl (turn_idx, expected, actual)
        LLMJudge-->>Operator: n_requests
    end

    alt Operator opts in to judge stage
        Operator->>HostCLI: Read judge_requests.jsonl
        HostCLI->>HostCLI: For each row, call anchor-tier model with JUDGE_PROMPT_TEMPLATE
        HostCLI->>FS: Write judge_responses.jsonl (turn_idx, matched, rationale)
    end

    Operator->>LLMJudge: responses = read_judge_responses(run_dir)
    LLMJudge->>FS: Read judge_responses.jsonl
    LLMJudge-->>Operator: dict[turn_idx] = JudgeResponse

    Operator->>LLMJudge: folded = apply_judge_verdicts(rows, responses)
    LLMJudge->>LLMJudge: For each row with reason=needs_llm_judge and response
    LLMJudge-->>Operator: updated rows with matched, judge_rationale, cleared reason

    Operator->>EvalHarness: Use folded rows in subsequent analysis
Loading

Class diagram for the new LLM-judge module types and helpers

classDiagram
    class llm_judge_module {
      <<module>>
      +JUDGE_MODEL_TIER : str
      +JUDGE_REASON : str
      +JUDGE_REQUESTS_FILENAME : str
      +JUDGE_RESPONSES_FILENAME : str
      +JUDGE_PROMPT_TEMPLATE : str
      +_eligible_rows(replay_results)
      +write_judge_requests(replay_results, run_dir, max_judge_calls)
      +read_judge_responses(run_dir)
      +apply_judge_verdicts(replay_results, responses)
    }

    class JudgeRequest {
      <<dataclass>>
      +turn_idx : int
      +expected : str
      +actual : str
    }

    class JudgeResponse {
      <<dataclass>>
      +turn_idx : int
      +matched : bool
      +rationale : str
    }

    llm_judge_module ..> JudgeRequest : writes
    llm_judge_module ..> JudgeResponse : reads
Loading

File-Level Changes

Change Details Files
Introduce a standalone, host-polymorphic LLM judge module that emits and consumes JSONL request/response files for replay rows tagged as needing LLM judgment, enforcing a strict non-contaminating prompt schema and an anchor-tier model label.
  • Add JudgeRequest and JudgeResponse dataclasses and constants for reason tag, filenames, model tier label, and prompt template.
  • Implement write_judge_requests to filter eligible replay rows, enforce a max_judge_calls cost cap, and write a JSONL of strict (turn_idx, expected, actual) records.
  • Implement read_judge_responses to parse a JSONL of judge verdicts keyed by turn_idx, skipping malformed lines and handling absent files gracefully.
  • Implement apply_judge_verdicts as a pure function that folds judge responses back into replay rows by turn_idx, clearing the reason and adding a judge_rationale field.
  • Add a judges package init docstring explaining the SDK-free, host-polymorphic dispatch pattern mirroring existing onboard logic.
benchmarks/context-rebuilder/judges/llm_judge.py
benchmarks/context-rebuilder/judges/__init__.py
Document the LLM-judge stage operator flow, contamination boundary, and cost posture in the context-rebuilder README and CHANGELOG.
  • Add a README section describing when rows are tagged for LLM judging, the default-off cost posture, and the strict contamination boundary that hides retrieval context from the judge.
  • Provide a worked three-step operator script example showing writing judge requests, running host-side dispatch, and folding responses back into replay results.
  • Add a CHANGELOG entry summarizing the new judge module, its schema, cost defaults, anchor-tier model label, and dispatch pattern, and clarifying that harness wiring will land in a later PR.
benchmarks/context-rebuilder/README.md
CHANGELOG.md
Add deterministic tests that lock in the LLM-judge interface, cost cap semantics, contamination boundary, and prompt/tier invariants.
  • Test that judge request JSONL rows contain only turn_idx, expected, and actual fields and that non-judge or empty-expected/actual rows are excluded.
  • Verify that the stage is disabled by default (max_judge_calls=0), that the cost cap limits the number of written requests, and that response reading joins by turn_idx while skipping malformed lines.
  • Assert that apply_judge_verdicts is pure and correctly updates matched, reason, and judge_rationale only when a matching response exists.
  • Add end-to-end round-trip tests from request writing through a hand-authored response file to folded replay rows, plus canaries for JUDGE_MODEL_TIER="anchor" and required fields in the prompt template.
tests/test_context_rebuilder_eval_judge.py

Assessment against linked issues

Issue Objective Addressed Explanation
#600 Implement the replay split for eval harness: replay_post_fork writes replay_requests.jsonl under a per-run directory, returns rows with actual="", matched=False, reason="pending_replay", and on re‑invocation joins any existing replay_responses.jsonl by turn_idx while leaving missing rows pending. The PR only adds the LLM-judge module (benchmarks/context-rebuilder/judges/llm_judge.py), its tests, and documentation. It does not modify eval_harness.py or replay_post_fork, nor does it implement reading/writing replay_requests.jsonl / replay_responses.jsonl from the harness. The PR body explicitly states that the harness integration joins later on top of PR #601.
#600 Add a new harness mode (--mode replay-pending or equivalent skill) that reads replay_requests.jsonl, dispatches one host-subagent/model call per row via the host’s polymorphic interface (no Anthropic SDK), writes replay_responses.jsonl, and document this operator flow in benchmarks/context-rebuilder/README.md. No new harness mode or CLI entry point is added in this PR. The changes are limited to an LLM-judge helper API and tests. While the README gains a new section, it documents the LLM-judge stage and its request/response files (judge_requests.jsonl, judge_responses.jsonl), not a replay-pending mode or the replay subagent dispatch flow described in the issue.
#600 Implement the deterministic string-match half of score_fidelity that scores replayed turns via expected.lower() in actual.lower() for string-match cases, leaves open-ended turns tagged reason="needs_llm_judge", ensure it produces non-zero scores on the fixture with a round-trip test, and preserve the no-anthropic-import property. The PR assumes the existence of replay rows tagged reason="needs_llm_judge" produced by the deterministic score_fidelity path, but it does not change or introduce score_fidelity itself, nor the fixture-based scoring behavior. It focuses on consuming those rows in a separate LLM-judge stage. The no-anthropic constraint is maintained, but the core string-match scoring implementation required by the issue is not part of this PR.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@robotrocketscience has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 16 minutes and 55 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ee34e366-bbbe-4780-843c-954ea400b452

📥 Commits

Reviewing files that changed from the base of the PR and between 00b5a9c and 536e8c9.

⛔ Files ignored due to path filters (1)
  • CHANGELOG.md is excluded by !**/CHANGELOG.md
📒 Files selected for processing (4)
  • benchmarks/context-rebuilder/README.md
  • benchmarks/context-rebuilder/judges/__init__.py
  • benchmarks/context-rebuilder/judges/llm_judge.py
  • tests/test_context_rebuilder_eval_judge.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/issue-592-llm-judge

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@robotrocketscience robotrocketscience added the attn:review Needs review (PR open, awaiting reviewer) label May 10, 2026
@github-actions

Copy link
Copy Markdown

PR-size soft cap

This PR is over the advisory size threshold:

  • 664 changed lines (limit: 200)
  • 5 changed files (limit: 3)

Bigger PRs collide with more open work, which under the parallel-session workflow tends to produce repeated attn:merge-conflict cycles (see #602). When practical, split into smaller PRs that each touch a focused surface.

This is advisory only — nothing is blocked. If the size is intentional (large refactor, module removal, generated code), apply the size:override label and this comment will be removed on the next push.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've left some high level feedback:

  • In _eligible_rows, you currently only check expected/actual for non-empty strings; consider treating whitespace-only values as ineligible (e.g., if not expected.strip(): continue) to avoid wasting judge calls on effectively empty content.
  • read_judge_responses silently drops malformed lines and type mismatches; adding at least a lightweight counter or debug hook (e.g., returning the number of skipped lines or logging at debug level) would make it easier to detect operator/dispatch issues without changing the happy-path behavior.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `_eligible_rows`, you currently only check `expected`/`actual` for non-empty strings; consider treating whitespace-only values as ineligible (e.g., `if not expected.strip(): continue`) to avoid wasting judge calls on effectively empty content.
- `read_judge_responses` silently drops malformed lines and type mismatches; adding at least a lightweight counter or debug hook (e.g., returning the number of skipped lines or logging at debug level) would make it easier to detect operator/dispatch issues without changing the happy-path behavior.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:noether:2026-05-10T23:48:45Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:noether:2026-05-10T23:49:52Z]

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

Labels

attn:review Needs review (PR open, awaiting reviewer) author-Maxwell PR coordination mutex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(eval-harness): wire replay_post_fork via host-agent subagent (commit-2 of #592)

1 participant