Design spine: trace re-seeding¶
Status: accepted · Scope: five upstream changes and the two format contracts they share.
Historical note: this document records the 0.8 outcome-only gate. Wind Tunnel 0.9 supersedes that scoring policy with inferred declared gates, experiment integrity, and robustness-under-perturbation semantics; see Migrating to 0.9.
This document is the design spine for making Wind Tunnel's core loop — turn any production trace into a regression test — a first-class, end-to-end path. It specifies the two format contracts that are expensive to get wrong and cheap to implement against:
- Contract A — the trace interchange format: a neutral, importable trace schema aligned with the OpenTelemetry GenAI semantic conventions.
- Contract B — the recorded tool-universe fixture: a queryable fake upstream built from recorded tool-call/result pairs, with an explicit divergence policy.
Around those contracts, five changes, ranked by how much of the vision each unblocks:
| # | Change | Depends on |
|---|---|---|
| 1 | Recorded tool-universe fixture (RecordedMCPServer) |
Contract B |
| 2 | Outcome scorer library (windtunnel.api.scorers) |
Scenario.outcome_fn (shipped) |
| 3 | Pack ownership + machine-readable run ledger | — |
| 4 | Trace import (wt import) |
Contracts A + B |
| 5 | CI ergonomics (JUnit/JSON output, tag filtering) | — |
The re-seeding model¶
A production trace already contains almost everything a regression scenario needs: the user's ask, the tools that were available, the answers those tools gave, and the outcome the agent produced. Re-seeding turns that into a hermetic test:
production trace
│ wt import
▼
scenario skeleton + universe fixture + scorer stub
│ wt run (hermetic — recorded universe, no live upstream)
▼
trace + score → results ledger → CI gate / report / triage
The model only works if two things hold:
- The fake upstream is queryable, not scripted. A rerun agent will not reproduce the recorded tool sequence — it will phrase the same lookup differently, retry, or take another route to the same answer. The fixture must answer queries, and must have a deliberate policy for queries the recording doesn't contain (§ Contract B).
- Scoring judges outcomes, not trajectories. Exact tool sequences diverge immediately on rerun; the final answer, the end state, and the provenance of claims do not have to. Wind Tunnel's per-run gate is already outcome-only (architecture); this spine extends that stance with a library of reusable outcome scorers (§ Change 2).
Trajectory, constraint, and robustness stay exactly what they are today: recorded on every run, surfaced in reports, never the deploy gate.
Contract B — the recorded tool-universe fixture¶
The keystone. Specified first because Contract A's importer emits it.
What it is¶
A universe file is a JSON artifact holding recorded tool-call/result
pairs plus the tool schemas they came from. A RecordedMCPServer loads
one and implements the existing SPI — MCPServer.start() → MCPHandle
(windtunnel/spi/mcp_server.py) — so it slots into
ScenarioPack.mcp_factory unchanged and every existing runner/scoring
path (server-witnessed call_log(), mcp_calls on the trace,
configure_failure_mode) works without modification. It is also the
general-purpose hermetic fake-data layer: nothing about it requires that
the recordings came from production; they can be authored by hand or
synthesized.
File format (*.universe.json)¶
{
"windtunnel_universe": 1,
"tools": [
{
"name": "client_lookup",
"description": "Look up a client by name or account id.",
"input_schema": { "type": "object", "properties": { "query": { "type": "string" } } },
"result_schema": { "type": "object" }
}
],
"recordings": [
{
"tool_name": "client_lookup",
"args": { "query": "Bluewing Logistics" },
"result": { "name": "Bluewing Logistics", "email": "ops@bluewing.example" }
}
],
"matching": {
"on_miss": "fail_call",
"arg_keys": { "client_lookup": ["query"] },
"per_tool_on_miss": { "audit_log_append": "empty" }
}
}
Field notes:
windtunnel_universe— schema version, integer, required. Same forward-tolerance discipline asTrace._from_dict.tools[*].input_schema/result_schema— JSON Schema.input_schemais what the agent is offered (it becomes the served MCP tool schema);result_schemais optional and only consulted by theemptyandsynthesizemiss policies.recordings[*]— deliberately the same shape asMCPCall(tool_name,args,result) minus the runtime-only fields (timestamp_ms,extra). A recording is a witnessed call at rest;wt importproduces them by draining witnessed calls out of a trace, and a live run'scall_log()can be re-frozen into a new universe file (record mode) with zero shape conversion.argsare stored in the flat shape ({"query": ...}), never the OpenAI wire shape — the recorder normalizes on write, because lookup keys must be canonical even though live traffic arrives in either shape (the same dual-shape toleranceMCPCalldocuments).
Producers must redact
Recordings hold verbatim tool results — scrub PII, credentials, and anything else you wouldn't commit before a universe file enters version control; Wind Tunnel replays what you froze, it never sanitizes it.
Matching: how a live call finds a recording¶
Lookup is stateless by default: the same query always returns the
same recording, however many times it is asked. (Agents retry; a
consume-once queue would turn every retry into a divergence. Sequential
consume-once semantics are a per-tool opt-in, "mode": "sequence", for
genuinely stateful tools — a counter, a paginated cursor.)
Match resolution, in order:
- Exact — same
tool_name, canonically-equalargs(keys sorted, values compared after JSON canonicalization). - Keyed — same
tool_name, equal values for the tool's declaredarg_keyssubset, ignoring the rest. This is the workhorse: it makesclient_lookup(query="Bluewing Logistics", verbose=true)hit a recording made withoutverbose. Noarg_keysentry → skip this level. - Miss — apply the divergence policy.
Deliberately not in core: fuzzy/embedding similarity. Matching must be
deterministic and explainable in a diff; anything cleverer belongs in a
synthesize hook (below).
The divergence policy¶
The central design decision. A rerun agent will ask something the recording doesn't contain, and each policy trades hermeticity against run completion differently:
on_miss |
The agent sees | Use when |
|---|---|---|
fail_call (default) |
A structured tool error: {"error": "no_recorded_result", "tool": ..., "args": ...} |
You want hermetic and loud. Doubles as a free silent_failure-style probe: does the agent notice, or fabricate? |
empty |
A schema-shaped empty result ([] / {} per result_schema, "" fallback) |
The tool is a search/list where "no results" is a valid, safe answer. |
nearest |
The recording with the highest count of exactly-matching arg key/value pairs for that tool (deterministic tie-break: first by recording order) | Best-effort completion matters more than strictness — exploratory reruns, not CI gates. |
synthesize |
Whatever a user-supplied hook returns: Callable[[str, dict, Universe], Any] |
You have a generator (an LLM, a faker, a domain model) that can extrapolate from result_schema + existing recordings. Core ships the hook, never a generator — the pure-stdlib rule holds. |
on_miss sets the universe-wide default; per_tool_on_miss overrides
per tool (a read-only search can be nearest while payment_submit
stays fail_call).
Every divergence is first-class evidence, whatever the policy. On a miss the server:
- appends the call to
call_log()withextra={"divergence": {"policy": ..., "matched": null | <recording index>}}, so it lands intrace.mcp_callslike any witnessed call, and - emits a
universe_divergence: tool=<name> policy=<policy>worker warning, the same marker discipline perturbations use.
That makes divergence scorable rather than merely logged: the scorer
library ships a no_divergence() policy predicate (constraint layer)
for scenarios that must stay fully inside the recording, and reports can
show divergence counts per scenario without any new plumbing.
Non-goals¶
- No record-mode proxy against live upstreams in the first cut —
call_log()+ afreeze_universe()helper covers "record what my mock served"; recording against real production tools is a downstream exporter concern. - No statefulness beyond
sequencemode. A universe file is not a database simulator; if a dim needs mutable state it keeps writing asynthetic_dbmock, which remains fully supported.
Contract A — the trace interchange format¶
Why not just Trace JSON?¶
save_trace() output is Wind Tunnel's native schema — right for the
bench's own runs, wrong as an import surface: nobody else emits it, and
freezing it as the public contract would couple external producers to
internal evolution. The interchange format is the neutral boundary:
external systems export to it, wt import reads from it, and the
mapping to native Trace stays private to the importer.
Alignment with OpenTelemetry GenAI¶
The OTel GenAI semantic conventions (now in their own semantic-conventions-genai repository, status Development) already define the two things an interchange trace needs, and we adopt both verbatim rather than inventing a dialect:
- The message/parts model —
gen_ai.input.messages/gen_ai.output.messages: an ordered array of{role, parts: [...]}where parts are typedtext,tool_call({id, name, arguments}), ortool_call_response({id, response}). - The attribute vocabulary —
gen_ai.request.model,gen_ai.request.temperature/top_p,gen_ai.provider.name,gen_ai.tool.name/gen_ai.tool.call.id/gen_ai.tool.call.arguments/gen_ai.tool.call.result(fromexecute_toolspans).
Because the semconv is Development-status, the envelope pins the mapping
("otel_genai_mapping") so a future attribute rename is a versioned
migration, not silent drift.
Envelope (*.wtin.json)¶
{
"windtunnel_interchange": 1,
"otel_genai_mapping": "semantic-conventions-genai@development-2026-06",
"source": {
"ref": "incident-2026-06-30-412",
"system": "acme-observability",
"captured_at": "2026-06-30T12:01:00Z"
},
"session": {
"model": "gpt-example-1",
"provider": "openai",
"sampler": { "temperature": 0.7, "top_p": 0.95 },
"started_at": "2026-06-30T12:00:00Z"
},
"messages": [
{ "role": "user", "parts": [ { "type": "text", "content": "Email on file for Bluewing?" } ] },
{ "role": "assistant", "parts": [ { "type": "tool_call", "id": "call_1", "name": "client_lookup", "arguments": { "query": "Bluewing" } } ] },
{ "role": "tool", "parts": [ { "type": "tool_call_response", "id": "call_1", "response": { "email": "ops@bluewing.example" } } ] },
{ "role": "assistant", "parts": [ { "type": "text", "content": "ops@bluewing.example" } ] }
],
"tool_definitions": [
{ "name": "client_lookup", "description": "…", "input_schema": { "type": "object" } }
],
"witnessed_calls": [
{ "tool_name": "client_lookup", "args": { "query": "Bluewing" }, "result": { "email": "ops@bluewing.example" } }
]
}
messagesis exactly the OTel parts model — an exporter sitting on OTel spans copiesgen_ai.input.messages+gen_ai.output.messagesthrough; an exporter sitting on any other logging pipeline writes the same shape directly. Onlysession.modelandmessagesare required.witnessed_callsis optional server-side evidence (populated fromexecute_toolspans when the producer has them). When absent, the importer reconstructs call/result pairs fromtool_call/tool_call_responseparts — the agent's own account, which is exactly the evidence-quality distinctionTrace.mcp_callsvsturns[*].tool_callsalready draws, and the skeleton records which source it used.tool_definitionsis optional; without it the universe file'sinput_schemas are stubbed and flagged for hand-editing.sourceis optional, opaque provenance in the producer's own vocabulary — an incident id, a ticket, a span/trace id. Wind Tunnel never interprets it;wt importstampssource.refonto the scenario skeleton as anorigin:<ref>tag and records the full object inIMPORTED.md, and the ledger carries it (§ Change 3), so a red row in CI traces back to the incident that seeded the scenario.- The envelope carries message and tool content verbatim — producers must redact PII, credentials, and anything else that shouldn't leave their boundary before the file does; nothing downstream sanitizes it.
wt import¶
wt import --trace prod_incident_412.wtin.json --out scenarios/imported/lookup_bluewing/
Emits a self-contained scenario directory:
scenario.py— a skeletonScenario:prompt(oruser_turnsfor multi-turn) from the user messages;must_callpre-filled from the tools observed and commented out — trajectory expectations are an opt-in tightening, not a default, per the outcome-first stance;requires_tool_use=Truewhen any tool was called.fixture.universe.json— Contract B, recordings drained fromwitnessed_calls(preferred) or reconstructed pairs,on_miss: fail_call.scorer.py— anoutcome_fnstub:target_factsseeded from the final assistant text as a suggestion block, plus a TODO pointing at the scorer library. The importer never guesses what "correct" means; it hands the author the evidence and the vocabulary.IMPORTED.md— what was inferred, from which evidence source, and what needs human judgment.
The importer is a skeleton generator, deliberately: a trace shows what the agent did, not what it should have done. Everything mechanical is filled in; everything judgmental is a marked TODO.
Change 2 — the outcome scorer library¶
Scenario.outcome_fn: Callable[[Trace], LayerResult] (shipped) is the
seam; what's missing is the library that makes reaching for it cheap.
New module windtunnel/api/scorers.py, pure stdlib, everything
returning or consuming the existing LayerResult:
- Combinators —
all_of(*fns),any_of(*fns), each producing a joined diagnosticdetailon failure (mirroring howevaluate_trajectoryjoins check failures). - State assertion —
observation(source, path, predicate, label): readstrace.observations[source], walks a dotted/indexed path, applies a predicate. This is the artifact-grading patternoutcome_fnwas built for, packaged. - Rubric LLM-judge —
llm_judge(rubric, generate_fn): BYOGenerateFn(the same bring-your-own-model contractreplayuses); core ships the harness (prompt assembly from rubric + final answer + evidence, strict PASS/FAIL parse, parse-failure = layer failure with the raw response indetail), never a vendor client. TheLLMJudgeClassifierstub intriage/shares this generate-fn seam. - Provenance checker —
substantiated_by_tools(facts=None): the claims in the final answer must appear in some server-witnessed tool result (trace.mcp_calls[*].result, falling back to transcripttool_resultswith the evidence source named indetail, same fallback discipline asevaluate_trajectory). Rule-based first cut: reuse the existing fact/number matching machinery (target_facts-style substring groups,NumberFactword-boundary matching) pointed at tool results instead of scenario constants — "does the answer cite what the tools actually returned, or numbers from nowhere?" An LLM-judge variant composes on top viallm_judge. - Divergence predicate —
no_divergence()as aPolicyfactory (constraint layer, see Contract B), because "stayed inside the recording" is a property of the path, not the answer.
outcome_fn composition keeps the existing semantics: structural gates
(missing assistant turn, requires_tool_use) still run first; a raise
inside any scorer is caught and scored as failure.
Change 3 — suite ownership and the run ledger¶
Two additions, both mechanism-only — Wind Tunnel ships the ledger format, never the gating policy a consumer builds over it:
Ownership. ScenarioPack gains owner: str | None = None (free-form
— a team name, a GitHub handle, a codeowners path) and
metadata: dict[str, str] for anything else. Owner flows into the
ledger record and into report grouping. No semantics attached upstream.
The ledger. wt run appends one line per scenario-aggregate to
<runs-dir>/ledger.ndjsonl at the end of a sweep:
{"ts": "2026-06-30T12:03:41Z", "scenario_id": "lookup_before_action",
"pack": "tool_affordance", "owner": "team-ops", "label": "candidate",
"model": "…", "quant": "…", "verdict": "PASS",
"runs": 3, "layer_pass_rates": {"outcome": 1.0, "trajectory": 1.0, "constraint": 1.0, "robustness": 1.0},
"run_ids": ["…"], "origin": "incident-2026-06-30-412",
"git_sha": "3efbf94", "wt_version": "0.2.0"}
Append-only NDJSON, one record per (scenario, sweep): trivially
greppable, trivially windowed — pass-rate trends, flake detection, "how
long has this scenario been green" are each a few lines of jq, and by
design that analysis lives in the consumer, not here. git_sha is best-effort from the environment, null
outside a repo; origin is best-effort from the scenario's
origin:<ref> tag (stamped by wt import, see Contract A), null when
the scenario wasn't seeded from a trace. This fixes the current history gap where
report.load_runs() keeps only the latest run per cell: the per-run
trace+score files remain the deep evidence; the ledger is the queryable
index over time.
wt report --format json additionally emits the already-computed report
data structure (currently embedded as a JSON island in the HTML) as a
standalone artifact.
Change 5 — CI ergonomics¶
Exit codes are already right (0 pass / 1 regression-or-error / 2 usage, with the transport-only exemption and the consecutive-error circuit breaker) — unchanged. Added:
wt run --format junit --out results.xml— one<testsuite>per pack, one<testcase>per scenario-aggregate; failures carry the layer details and triage category when available. This is the "gate a PR likego test" surface.wt run --format json --out results.json— the same records the ledger gets, as a single sweep document, for anything that doesn't speak JUnit.- Selection — beyond today's exact
--scenarionames:--tag dim:recovery(matches the existing tag convention),--pack <name>,--owner <owner>, and glob support in--scenario(--scenario "lookup_*"). Selection composes as AND across flags, OR within a repeated flag.
Sequencing¶
Each lands as its own PR, in dependency order; 2, 3, 5 are independent of each other and can go in any order once 1 is in review:
- Universe fixture (Contract B): format module +
RecordedMCPServer - divergence evidence +
freeze_universe()helper + docs page. - Scorer library:
api/scorers.py(builds onoutcome_fn). - Ownership + ledger:
ScenarioPack.owner,ledger.ndjsonl,report --format json. - Interchange +
wt import(Contract A): needs 1 (emits universe files) and wants 2 (scorer stubs reference the library). - CI output + selection: junit/json writers,
--tag/--pack/--owner.
Explicit non-goals (the downstream boundary)¶
Wind Tunnel defines formats and mechanisms; anything that encodes a particular organization's judgment stays downstream:
- Exporters from specific event logs, observability stacks, or products (they target Contract A; that's the whole point of it).
- Scenario content and fake-data corpora beyond the built-in dims' synthetic examples.
- Gating and deployment policy — what a green ledger history means is a consumer decision; upstream ships the ledger.
- LLM clients of any kind — judges and synthesizers are BYO callables, keeping the pure-stdlib core intact.
Open questions¶
- Canonical arg equality: JSON canonicalization is defined here as sorted-keys + exact scalar equality. Float tolerance and case-insensitive strings are plausible per-tool knobs — deferred until a real universe file needs them.
sequencemode interaction with retries: a retried call consumes the next recording. Acceptable for v1 (sequence mode is opt-in and rare); revisit if it bites.- Interchange multi-session traces: v1 assumes one session per file.
Multi-session bundles (
sessions: [...]) are a compatible extension. - Semconv drift:
otel_genai_mappingpins the vocabulary; when the semconv stabilizes, cut interchange v2 with a migration note rather than mutating v1.