Skip to content

Real judging and real test cases: structured verdicts, verified evidence, interface grounding - #10

Merged
barancan merged 15 commits into
mainfrom
claude/real-judging-and-grounding
Aug 4, 2026
Merged

Real judging and real test cases: structured verdicts, verified evidence, interface grounding#10
barancan merged 15 commits into
mainfrom
claude/real-judging-and-grounding

Conversation

@barancan

@barancan barancan commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Phases P2 and P3. Judges now produce schema-forced verdicts whose evidence is verified, and generated pipelines carry test cases with real inputs grounded on the target's interface.

471 tests passing with zero API keys set, up from 273. Verified across repeated full-suite runs and against a real server.

Why

After P0/P1 the builder called a real model, but two things still made its output weaker than it looked. Judges asked for JSON in prose and scraped the reply, storing evidence quotes without ever checking them — a judge could invent a supporting quote and nothing noticed. And every generated test case had an empty input, so a pipeline invoked the target with nothing in it. A pipeline whose cases have no inputs is not testing anything.

Real judging (P2)

  • Structured output is forced natively per provider — Anthropic via a required emit_verdict tool, openai_compat via json_schema (retrying as json_object on a 400, since some gateways reject it), Ollama via format. Validated with jsonschema; a reply that does not conform is an error rather than unvalidated data passed downstream. A text-only reply under a schema is no longer something to scrape.
  • Evidence is verified. With require_evidence, a quote that does not appear in the response fails the check. Matching folds case, whitespace and typographic look-alikes and honours ... elision, so a re-cased or re-wrapped span still passes and only fabrication fails.
  • Self-consistency takes the median score per dimension across N temperature-0 samples, flooring an even split — a judge torn between 1 and 2 has not agreed the dimension is met — and records the spread so a reviewer can see disagreement.
  • An unscored dimension now fails by name instead of silently reading 0.
  • Rubrics are generated, not templated: at least two anchored dimensions with observable 0/1/2 levels. The validator rejects grading-word anchors like "the response is very good", and constrains dimension ids to slugs — they become filenames, and this codebase has been bitten by path traversal before.

Real test cases (P3)

  • generator/interface.py parses Postman, OpenAPI 3 (JSON and YAML, local $refs resolved) and MCP tool schemas into request fields, a response schema and JSONPath response paths. adapters/rest.py now uses the same reader, so the adapter and the builder cannot drift into disagreeing about what a collection says. Format detection is by content, not extension.
  • generator/casegen.py produces concrete inputs per intent, grounded on the interface's real request fields, with nominal, empty, boundary and hostile variants — an eval that only tests the happy path is not an eval. A single gate in build.py means no path can emit a case with an empty input.
  • Golden datasets bind via assay generate --dataset, taking precedence over generation; a malformed row names file and line.
  • Ids are validated rather than sanitised: a ../ id means the model is confused, not that the id needs cleaning.

Also in this PR

  • Requirement coverage in both directions. Counting cases per requirement only describes what was tested. A requirement with nothing testing it was invisible — exactly what a reviewer needs before signing off. Now reports uncovered requirements by id and text, plus orphan tests citing requirements that no longer exist. When the requirement list is unavailable it says so rather than rendering "0 uncovered" and implying full coverage.
  • TargetModel.interface_hash is populated. The column has existed since the first schema and was never written, so a report could not tell you whether the interface had changed underneath it.
  • A flaky-test fix that was a real bug. Background runs resolve the session factory from module globals on every DB touch, so a run still in flight when the store was reconfigured wrote somewhere unexpected. wait_for_runs() gives callers something to wait on; the same hazard applies to graceful shutdown.

Decisions worth review

  • A missing interface file is now an error; an unreadable one is not. The two halves of P3 disagreed here, and both had a point. Parsing stays forgiving so a bad document cannot take a build down, but a path the user explicitly supplied that does not exist is checked at the boundary — silently building an ungrounded pipeline would hand them precisely what they asked not to have.
  • The web path no longer produces weaker rubrics than the CLI. /pipelines/generate called rubric_for() without a model, so the wizard quietly got deterministic fallbacks even with a builder model configured.
  • A run now costs more. Case generation is one model call per intent, and n=3 means three target invocations per intent instead of one empty-input case. That is the point of the phase, but it is a real change in what a run costs — and cost capture (P5) is not built yet, so runs still report zero spend.

Verification

  • pytest -q — 471 passing with ANTHROPIC_API_KEY and OPENAI_API_KEY explicitly unset. No test makes a network call. Run repeatedly to confirm the threading flakiness is gone.
  • End to end offline: assay generate --offline --interface api.postman_collection.json produced 9 cases across 3 requirement suites with 0 empty inputs, using the collection's real text/locale fields, including an injection probe.
  • Against a real server with no keys: generate returns a 422 naming the variable, a missing interface path returns 422, the wizard carries the interface field, no tracebacks.

Not in this PR

Codegen (P4) still does not exist — an intent routed to generated produces a spec entry with no source behind it. That is now the top open item in the ranked-gap table and the last piece of the product's core claim. Cost capture (P5) and retiring the mock default (P6) also remain.


Generated by Claude Code

claude added 15 commits August 4, 2026 19:16
Adds assay/generator/interface.py -- the normalised view of whatever interface
description a user supplies (Postman today, OpenAPI and MCP to come). Without it
the builder knows a target exists but not what a request looks like or what comes
back, which is why generated cases have empty inputs and generated checks cannot
reference real response paths.

tests/test_phase_contracts.py pins the two seams that let the judging and
grounding workstreams proceed in parallel: structured output (adapters produce a
parsed object in ModelResponse.json when a schema is passed, which the judge
consumes instead of scraping prose) and the Interface shape (parsing produces it,
case generation and codegen consume it).

The Postman reader here is deliberately minimal -- enough to make the contract
real and testable, not the finished parser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
Coverage counted cases per requirement, which only describes requirements that
were tested. The question a reviewer needs answered before signing a report off
is the inverse: what has nothing testing it? A requirement with no case was
invisible, and the report read as though everything was accounted for.

coverage() now reports uncovered requirements by id and text, orphan tests citing
a requirement that no longer exists, and a covered percentage. The requirement
list is recovered by re-splitting the text stored on the pipeline version with the
same function the builder used, so ids line up with the refs the intents cite.

When the requirement list is unavailable the report says so rather than rendering
zero uncovered, which would read as full coverage -- the one failure mode that
would make this worse than not having it.

Docs row deferred to integration: another workstream owns docs/STATUS.md right now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
TargetModel.interface_hash has existed since the first schema and was never
populated, so a report could not tell you whether the interface it was tested
against had changed underneath it. It is now derived from the target's interface
file when one is supplied, and stays null when none is.

Provenance is worth recording but never worth failing a run over, so a parse
failure is swallowed rather than aborting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
The adapters accepted schema=/tools= and ignored both, so the judge asked for
JSON in prose and scraped the reply. Each provider now uses its own forcing
mechanism and the parsed object lands in ModelResponse.json:

  * anthropic     -- a single emit_verdict tool with the schema as its
                     input_schema, plus tool_choice forcing it; the tool input
                     is the answer and tool_calls is populated
  * openai_compat -- response_format json_schema, falling back once to
                     json_object on the 400 that vLLM and older gateways
                     return; the mode that worked is recorded in .raw
  * ollama        -- format: <schema>, falling back to format: "json" for
                     pre-0.5 servers
  * mock          -- builds a placeholder object from the schema so the
                     structured path runs offline

Parsing is shared and defensive (base.parse_structured): a reply that is not a
JSON object, or that fails jsonschema validation, becomes status="error" naming
the provider rather than malformed data handed to the judge. Callers that pass
no schema see exactly the old behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
Judging was easy to fool. The judge asked for JSON in prose and scraped the
reply, stored evidence quotes without ever checking them, and read a missing
dimension score as 0 -- so an invented quote passed and an omitted score looked
like a bad response. Rubric generation was a fixed one-dimension template, the
same for every judge intent.

Judge (assay/judges/judge.py):
  * the verdict is requested with schema=VERDICT_SCHEMA and read from
    ModelResponse.json; parsing out.text remains as a fallback for adapters that
    have not been upgraded
  * require_evidence: true verifies every quote against the response. Matching
    folds case, whitespace and typographic look-alikes and honours "..." elision,
    so a genuine span survives reformatting while a fabricated one fails
  * samples (from the rubric's samples: key, or the argument) takes the median
    score per dimension across N temperature-0 calls, floors on a tie, and
    records the per-dimension spread under evidence.consistency
  * an unscored dimension fails by name instead of silently reading 0
  * the rubric may be a dict or a path -- the engine still passes paths

Rubric generation (assay/generator/rubricgen.py):
  * generate_rubric asks the builder model for >=2 anchored dimensions with
    observable 0/1/2 levels, min_score, require_evidence and the verdict
    output_schema
  * output is validated -- unique slug-safe ids (an id is a YAML key and a path
    component, so ../../etc/passwd is rejected outright), complete scales,
    non-vacuous anchors, min_score in range -- then repaired once, then falls
    back to the deterministic fallback_rubric, which is also the --offline path
  * build.py's rubric_for() takes the builder model and hands it through

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
The run-progress tests were flaky in the full suite while passing in isolation,
and a different test failed each run. Background runs resolve the session factory
from module globals on every DB touch, so a run still in flight when the next test
reloaded the store wrote into that test's database.

start_run now tracks its threads and wait_for_runs() blocks until they finish; the
fixture waits before tearing the database down, and asserts none outlived the test
rather than papering over it with a sleep.

This is not only a test concern -- the same reconfiguration hazard applies to a
graceful shutdown, which now has something to wait on.

Verified with five consecutive full-suite runs: 299 passed each time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
…e builder

The builder had no idea what a request to the target looks like or what comes back,
so generated checks could not name real response fields. `generator/interface.py` now
reads the three formats users actually have: Postman collections (nested folders,
named requests, {{variables}}, headers, collection auth), OpenAPI 3 in JSON or YAML
(local $refs resolved, request fields from the body schema and parameters, JSONPath
response paths from the 2xx schema), and MCP tool schemas.

`adapters/rest.py` imports through the same functions instead of its own JSON-only
Postman reader, so what a run sends and what the builder grounds on cannot disagree --
and an OpenAPI file no longer dies in json.loads, which is what the README claimed
worked all along. Format is decided by content, because users mislabel files, and
anything unreadable comes back as an ungrounded Interface rather than an exception.

`sample_response` is what codegen will dry-run against, so it follows nested objects,
arrays of objects, resolved $refs, formats, enums and examples, and terminates on
recursive schemas.

README and docs/STATUS.md now say what is true: `rest` is Built with OpenAPI import,
interface grounding is Partial (parsing exists; case generation does not consume it).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
The grounding work introduced interface_from_target(), which handles a spec's
request selection and both the dict and TargetSpec forms. The runner's own
narrower lookup is now redundant, so it goes through the shared seam.

Also corrects the STATUS row: interface_hash is written on every run, not
pending -- the two workstreams landed on either side of each other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
J6.6 and J11.3 described the pre-P2 behaviour. Rubrics are generated with
anchored dimensions now, and judge quotes are verified against the response
rather than merely stored.

The grounding and case-generation rows stay as they are until that workstream
lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
Every generated case had `"input": {}`, so a pipeline invoked its target with
nothing and every check graded the same empty response. A pipeline whose cases
have no inputs is not testing anything.

* `generator/casegen.py`: `generate_cases` asks the builder model for concrete
  inputs for an intent, grounded in the parsed interface so the fields are the
  ones the target actually accepts, with empty/boundary/hostile edge variants —
  an eval that only tests the happy path is not an eval. Replies are validated
  before they are persisted (unique, slug-safe ids; non-empty dict inputs; at
  least one real request field when the interface declares them), repaired once
  with the complaint fed back, then abandoned for a deterministic set rather
  than persisting garbage.
* `load_dataset` reads `datasets/*.jsonl` — the directory `assay init` has been
  scaffolding and nothing has ever read — naming file and line on a bad row. A
  supplied dataset *is* the cases; generation is skipped.
* `intents_to_spec` gains `iface`/`cases_by_intent` and refuses to emit a case
  with an empty input; `build_pipeline`/`build_pipeline_to_db` gain
  `interface_path`/`dataset`; the run records `TargetModel.interface_hash`.
* `assay generate` gains `--interface` and `--dataset`; `--offline` uses the
  deterministic generator and still produces real inputs.
* Wizard step 2 collects an interface file into `adapter_spec["import"]` (a
  declared TargetSpec field) and carries it through resume, so editing a
  pipeline cannot silently unground it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
Resolves two conflicts where workstreams met:

engine/runner.py had two _interface_hash definitions. Kept the target-taking
signature and the interface_from_target seam, which handles request selection and
both the dict and TargetSpec forms.

generator/build.py had the real rubric_for alongside a stale copy of the
one-dimension stub it replaced. Kept the real one.

Two integration fixes on top:

The interface-file contract was genuinely disputed. Parsing treats an unreadable
document as ungrounded so a bad file cannot take a build down; case generation
expected a missing file to be loud. Both are right about different things, so
existence is now checked at the boundary in resolve_interface -- a path the user
explicitly supplied and that does not exist is an error, while content that
cannot be understood stays ungrounded. Silently building an ungrounded pipeline
would hand the user precisely what they asked not to have.

The web generate route called rubric_for without a model, so the UI produced
deterministic fallback rubrics even with a builder model configured -- quietly
weaker than the CLI for the same input. It now passes the model and the interface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
Each workstream updated the status page without seeing the others, so several
rows described gaps that a sibling had already closed and the headline still said
case inputs were empty. Reconciled against the merged tree.

Structured output, evidence enforcement, self-consistency, rubric generation,
interface grounding, case generation, dataset binding and the bidirectional
coverage matrix all move to Built. The rubric row no longer warns that the web
path skips the builder model, because it no longer does.

The ranked-gap table keeps closed items with the phase that closed them, so the
list stays auditable rather than merely getting shorter. Codegen is now the top
open item and the last piece of the product's core claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
@barancan
barancan merged commit a483cc7 into main Aug 4, 2026
0 of 2 checks passed
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