Real judging and real test cases: structured verdicts, verified evidence, interface grounding - #10
Merged
Merged
Conversation
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
…ing-and-grounding
…ing-and-grounding
…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
…ing-and-grounding
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
emit_verdicttool, openai_compat viajson_schema(retrying asjson_objecton a 400, since some gateways reject it), Ollama viaformat. Validated withjsonschema; 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.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.Real test cases (P3)
generator/interface.pyparses 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.pynow 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.pyproduces 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 inbuild.pymeans no path can emit a case with an empty input.assay generate --dataset, taking precedence over generation; a malformed row names file and line.../id means the model is confused, not that the id needs cleaning.Also in this PR
TargetModel.interface_hashis 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.wait_for_runs()gives callers something to wait on; the same hazard applies to graceful shutdown.Decisions worth review
/pipelines/generatecalledrubric_for()without a model, so the wizard quietly got deterministic fallbacks even with a builder model configured.n=3means 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 withANTHROPIC_API_KEYandOPENAI_API_KEYexplicitly unset. No test makes a network call. Run repeatedly to confirm the threading flakiness is gone.assay generate --offline --interface api.postman_collection.jsonproduced 9 cases across 3 requirement suites with 0 empty inputs, using the collection's realtext/localefields, including an injection probe.Not in this PR
Codegen (P4) still does not exist — an intent routed to
generatedproduces 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