Files
zero/internal/perfbench/MANIFEST.md
Vasanth T 727ad4d321 perf(turn-bench): Phase 0 — strengthen oracles so pass rate can't be misread as correctness (#712)
* perf(turn-bench): Phase 0 — strengthen oracles so pass rate can't be misread as correctness

The baseline manifest's pass/fail contract had three holes that let a weak
or no-op agent read as a pass:

  - edit grep oracles were substring checks: a reworded line (e.g.
    fmt.Println("debug: starting") -> fmt.Println("starting up")) passed
    `! grep 'debug: starting'`.
  - refactor lived in the build tier with a non-positive `go build ./...`,
    so a no-op refactor passed.
  - the 24 read-only tasks (nav/longproc/longctx/parallel) had no oracle;
    a zero-exit only proved the turn ran.

This makes the tiers honest:

  - edit/refactor now carry a stamped oracle_test.go compiled by
    `go test ./...` — the Go compiler is the structural verifier, so a
    no-op refactor, a missing field, or a reworded-but-not-removed line
    fails to compile/run. The test is stamped into the fixture copy AFTER
    the agent run, so it can't interfere with the agent's own build or be
    pre-seen.
  - nav gains an answer-oracle: the harness captures the agent's final
    answer (.zero-answer.txt) and greps it for determinable facts. Count
    tasks (nav-04/05/08) use a structured `count: N` token so the oracle is
    tight rather than a loose prose-contains on "0".
  - refactor graduates from build to correctness (its oracle is now
    positive); nav graduates from latency-only to correctness. The build
    tier is empty (buildOnlyClasses: []).
  - longproc/longctx/parallel stay permanently latency-only — there is no
    deterministic oracle for "report whether the build succeeds" or
    "summarize a large file"; documented in MANIFEST.md.

Tier counts: correctness 34 (edit 10 + fix 8 + nav 10 + refactor 6),
build 0, latency 14. Total 48 unchanged.

TurnSchemaVersion 2 -> 3: the result shape is unchanged (only class-list
membership shifts) but correctnessPassRate now means something strictly
stronger, so the bump prevents a v2->v2 cross-version comparison from
misreading the jump as a model improvement.

Prerequisite fix: the mutating fixtures had no go.mod, so `go test ./...`
in the temp copy failed with "cannot find main module". Added go.mod per
fixture (edit/fix/refactor/nav) and moved copyFixture's temp dir one level
below the system temp root — Go ignores go.mod in direct children of the
temp dir (a hijack guard), which would have broken every compiler-backed
oracle. This also fixes the pre-existing fix/refactor oracles, which were
silently broken because the test suite used fake runners that never ran
go test.

Tests: stub-binary gating tests prove each oracle fails the wrong thing
(no-op refactor, missing field, wrong nav answer, reworded debug print) and
passes the right thing (real refactor, added field, correct answer); an
edit-01 test proves the scoped negative grep doesn't false-fail a correct
rename that leaves the doc comment. Aggregation, schema, and unit tests
cover the v3 tier model and the answer-capture helper.

* Address CodeRabbit review on PR #712: tighten oracles + isolation

- nav count oracles (nav-01/04/05/08): anchored line-exact
  `^count: N$` with the fixture's exact expected count, so a substring
  like "the count: 0" or a wrong count like "count: 3" fails instead
  of rubber-stamping. nav-01 added to the structured-count contract.
- stampOracleAndAnswer now errors when a task has an oracle or
  verification but no workspace fixture, so verification can't run in
  the caller's cwd with the oracle never compiled (false-pass closed).
- copyFixture uses a unique 0700 os.MkdirTemp parent under the temp
  root and nests the copy beneath it (grandchild of temp root), so the
  Go go.mod hijack guard is satisfied and cleanup removes the whole
  parent. Avoids a predictable shared world-readable dir.
- Rejection tests require a non-empty VerifyErr via a shared
  assertVerifyFailed helper, proving the failure came from
  verification rather than a harness error or a silent non-pass.
- Unify streamJSONExitCode + streamJSONFinalText behind one
  parseStreamJSON parser (behavior-preserving; scanner limits and
  malformed-event handling now live in one place).

* Address gnanam1990 review on PR #712: close count=0 gameability + stronger oracles

- nav count tasks (nav-04/05/08) had a count=0 ground truth, so an agent
  that never opened the workspace and always emitted "count: 0" passed
  three correctness tasks -- the exact pass-rate-misread-as-correctness
  failure this PR closes. nav-04 now has a real TestGreet (count 1) and
  nav-05 a real TODO (count 1); nav-01's file count moves to 5. nav-08
  stays at 0 (a real third-party import would need a network fetch and
  break the offline suite) but its oracle now requires the answer to name
  both stdlib imports (fmt from main.go, testing from main_test.go), so a
  blind count:0 or a main.go-only read fails. nav-08's prompt now asks the
  agent to enumerate and classify every import, so naming the stdlib
  imports is part of a correct answer, not a false-fail.
- refactor-05: stamp a behavioral oracle (TestGreetWrapped) so deleting
  both Wrapper and greetWrapped, or stubbing the caller to return "",
  fails to compile/run. The negative grep alone passed that wrong edit.
- refactor-04: replace the brittle exact-text shell grep with a stamped
  file-reading oracle using a spacing-tolerant regex, so a bare
  map[string]int decl under any spacing/:= variant can't dodge it, and
  the Stats type must be referenced in main.go.
- edit-05: also assert the "debug: starting" message text is absent, so
  a respell onto fmt.Printf / backticks / log.Println / println that
  still prints the message fails (the fmt.Println(" check missed those).
- v3 doc comments on ClassSummary / TurnBenchResult /
  FormatTurnBenchSummary now describe the v3 tiers (correctness =
  edit/fix/nav/refactor, build empty, latency = longproc/longctx/
  parallel) instead of the stale v2 model.
- stampOracleAndAnswer failure now mutates the existing outcome instead
  of returning a fresh one, so the already-parsed trace and trace issue
  survive a stamp error (attribution is preserved, pass/fail unchanged).
- Schema comment provenance: "PR #701" -> neutral "Phase 0 oracle
  hardening" (this PR is #712).
- New gating + satisfiable stub tests for refactor-04/05 and edit-05, and
  count-oracle zero-guess / inspection-proof tests for nav-04/05/08.

* Fix macOS CI: copyFixture isolation test was slash-sensitive

TestCopyFixtureIsolatesSourceFromMutation compared filepath.Dir(parent)
against os.TempDir() verbatim. On macOS $TMPDIR ends in a trailing slash
("/var/folders/.../T/") while filepath.Dir strips it, so the equality
false-failed even though the parent is genuinely a direct child of the
temp root. Linux (/tmp, no slash) and Windows (no slash in this Go
build) matched by accident.

The production copyFixture nesting is correct on all platforms (the
grandchild copy sits one level below the temp root, satisfying Go's
go.mod hijack guard); only the assertion was slash-sensitive. Normalize
both sides with filepath.Clean.

* Close nav-04/05 blind-count-one gameability (anandh8x review)

nav-04 and nav-05 had a count-only oracle (anchored '^count:1$'). Once the
ground truth became 1, an agent that never inspects the fixture and always
emits 'count: 1' passed both — the blind-count-one surface the prior
non-zero-ground-truth fix opened. Pair the count with a workspace-derived
named fact that proves inspection, mirroring nav-08's fmt+testing:

- nav-04 now also requires 'TestGreet' (the one test function in
  main_test.go) — a specific, non-generic name a blind agent can't produce
  without reading main.go to learn a greet function exists. Fully closes
  the blind-count-one surface.
- nav-05 now also requires 'main.go' (the file holding the one TODO).
  main.go is the conventional entrypoint and the most guessable filename, so
  this reduces rather than fully closes the surface — a blind 'count: 1, in
  main.go' can still pass. Accepted for Phase 0: the blind floor drops below
  100% (a bare 'count: 1' fails), so a model still moves the pass rate by
  inspecting. MANIFEST.md documents this residual honestly.

Two gating tests per task: RejectsBlindCountOne (bare 'count: 1' now fails)
and, for nav-05, RejectsSubstring (main.go present but unanchored count
still fails — parity with nav-04). Existing accept tests still pass (their
answers already name TestGreet / main.go).

Doc nits also fixed: MANIFEST.md #701 -> #712; refactor-05 removed from the
'no oracleTest' inventory (it now has TestGreetWrapped); the nav-01 named-
fact prose corrected to the three files the oracle actually anchors on;
test comment #701 -> #712.
2026-07-17 17:32:45 +05:30

9.5 KiB

Turn benchmark manifest

The baseline manifest (manifests/baseline.json) is the per-turn benchmark's program keystone: it defines the tasks the harness runs, the workspace each starts in, and — critically — what "pass" means for each task. Because make baseline is re-run on every perf change, the manifest's pass/fail contract matters for months, so the contract is written down here.

Task count

48 tasks across seven classes:

class count oracle tier
nav 10 grep on captured final answer correctness
edit 10 stamped oracle_test.go + go test correctness
fix 8 scoped go test -run <name> correctness
refactor 6 stamped oracle_test.go + go test correctness
longproc 4 none latency
longctx 4 none latency
parallel 6 none latency
total 48

Tier counts: correctness 34 (10 edit + 8 fix + 10 nav + 6 refactor), build 0, latency 14 (4 longproc + 4 longctx + 6 parallel).

Oracle tiers

Pass/fail is reported per tier so the report cannot be misread as a blanket correctness verdict. The tier is decided per task from oracle presence and the manifest's buildOnlyClasses list:

  • Correctness (34 tasks: edit, fix, nav, refactor) — a positive oracle. For edit/refactor the harness stamps an oracle_test.go (from the task's oracleTest field) into the fixture copy after the agent run, then runs go test ./...: the Go compiler is the structural verifier, so a no-op refactor, a missing field, or a reworded-but-not-removed line fails to compile/run. fix uses a scoped go test -run <name>. nav greps the agent's captured final answer (.zero-answer.txt) for determinable facts. This is the only pass rate that can move with model quality: tasksVerified / tasksPassed / correctnessPassRate.
  • Build-only (0 tasks) — the tier is empty. refactor used to live here with a non-positive go build ./... (a no-op refactor passed); PR #712 gave it a structural oracle, graduating it to correctness. buildOnlyClasses is []. buildCheckedTasks / buildPassedTasks / buildPassRate are reported as 0 and never in correctnessPassRate.
  • Latency-only (14 tasks: longproc, longctx, parallel) — no verificationCommand. An exit 0 only proves the turn ran, not that the answer was right. They contribute to latency and span attribution only and are counted in latencyOnlyTasks, never in any pass rate.

A task's tier is driven by oracle presence first: a task with no verificationCommand is latency-only even if its class is listed in buildOnlyClasses, so a missing oracle can never silently pass on exit 0.

How the stamped oracle and captured answer work

The production runner (NewTurnExecRunner in turn_bench.go) copies the fixture into an isolated temp dir, runs zero exec --trace, then — before running the verificationCommand — stamps two files into the copy:

  • oracle_test.go from task.OracleTest (edit/refactor). It is stamped after the agent run so it can't interfere with the agent's own go build/go test during the task (refactor-03's package zeroapp test would break a pre-rename build) and can't be pre-seen or tampered with. The verificationCommand (go test ./...) then compiles and runs it.
  • .zero-answer.txt from the {"type":"final","text":...} event in the stream-json output (nav). The verificationCommand is a compound bash -c grep requiring the determinable facts (e.g. nav-09 requires the answer to mention port, name, and retries). An empty file (no final event) makes the grep fail, which is the correct outcome for a run that produced no answer.

The oracleTest source uses compile-time references where the assertion is structural (var _ = Config{}.Label for edit-03, var _ = formatGreeting for refactor-01) and behavioral Test… functions where the assertion is a value (edit-04 Version != "1.1.0", edit-10 greet("x") != "hello, world", edit-09 load("nonexistent") wrapping). Tasks whose oracle is a plain grep/build with no stamped test (edit-02, refactor-02) carry no oracleTest.

Known limitations

These are accepted for the Phase 0 baseline; the tier split keeps the report honest where they apply:

  • longproc / longctx / parallel are permanently latency-only. There is no deterministic oracle for "report whether the build succeeds", "summarize a large generated file", or "report the first line of each of six files" — the answer is free-form prose and a contains-check would rubber-stamp. They stay in latencyOnlyTasks and never enter a pass rate.
  • nav count tasks (nav-01, nav-04, nav-05, nav-08) require a structured, line-anchored count: N token with the fixture's exact expected count AND a workspace-derived fact that proves the agent actually inspected the fixture — not the count alone. Their prompts ask the agent to state the count as count: N on its own line and to name the thing(s) counted, and the oracle greps for an anchored ^count: N$ with the exact expected N plus a named fact: nav-01 requires count: 5 and names main.go, config.json, and README.md (the three the oracle anchors on — a correct answer lists all five files, but the verification contract only requires those three be named); nav-04 names the one test function (TestGreet from main_test.go); nav-05 names the file holding the one TODO (main.go); nav-08 names both stdlib imports (fmt from main.go, testing from main_test.go). A substring like "the count: 1" or a wrong count like "count: 3" fails the anchor; a bare "count: 1" with no named fact fails the inspection check. Non-zero ground truth alone was not enough — it closes the always-guess-zero game but opens an always-guess-one game (an agent that never opens the workspace and emits "count: 1" would pass a count-only oracle). The named-fact requirement closes that surface: a blind count is no longer sufficient, the agent must commit to a fact only readable from the fixture. nav-04 and nav-05 were given a real TestGreet and a real TODO so the named fact exists to require; nav-08 stays at count 0 (a real third-party import would need a network fetch and break the offline suite), so its named fact is the stdlib imports the agent must enumerate across every file to conclude 0 third-party. The named facts are not all equally tight. nav-04's TestGreet is a specific, non-generic identifier a blind agent cannot produce without reading main.go to learn a greet function exists, so it fully closes the blind-count-one surface. nav-05's main.go is the conventional Go entrypoint and thus the most guessable filename, so it reduces rather than fully closes that surface — a blind "count: 1, in main.go" can still pass. Accepted for a Phase 0 baseline: the blind floor on nav-05 is below 100% (a bare "count: 1" fails), so a model still moves the pass rate by actually inspecting; strengthening it further (e.g. requiring the TODO's surrounding symbol) would trade output freedom for determinism and risks false-failing a terse-but-correct answer. nav-08's testing half is the tighter of its two: fmt alone is the most common Go stdlib import and could be named blind, but testing only appears in the test file, so naming it proves the agent read more than main.go. This trades a little output freedom for determinism (a loose prose-contains on "0"/"1" would match almost any answer, and an unanchored count: N would match inside a longer line).
  • nav-03 / nav-06 / nav-07 are lenient contains-oracles on real facts. A wrong-but-plausible answer that happens to mention the real symbols (e.g. "Config" and "MaxRetries" for nav-06) could in principle pass. Accepted for a latency-first baseline; the oracle still catches an agent that didn't look at the code at all.
  • Rename oracles use a declaration-site negative grep (! grep -RIn 'MaxRetries =' .) rather than a broad ! grep -R MaxRetries ., so a correct rename that leaves a doc-comment mention of the old name is not false-failed. The negative grep is still required: without it, a no-op that adds the new name and keeps the old would pass the compile-time var _ = RetryLimit and go test.

Fixtures

Each task's workspaceFixture points at a small self-contained workspace under testdata/ so the suite runs offline and repeatably. Mutating tasks (edit, fix, refactor) run against a per-invocation copy of their fixture, so the checked-in fixtures stay clean and one task's edits can't bleed into the next iteration or a later task.

Each mutating fixture (and nav) carries a go.mod (module <pkg>; go 1.22) so go test ./... / go build ./... oracles work in the copy: copyFixture creates a unique, 0700 parent under the system temp root (os.MkdirTemp(os.TempDir(), "zero-turn-bench-*")) and nests the copy beneath it, so the copy is a grandchild of the temp root. Go ignores go.mod in direct children of the system temp dir (a hijack guard), so the copy must sit one level below that root or every compiler-backed oracle would fail with "cannot find main module". The unique parent also avoids a predictable shared directory and is removed wholesale on cleanup.