Skip to content

Commit 05ce29a

Browse files
Fix(eval): Each developer case statement names the interface its withheld tests import (#1750)
* Fix(eval): Developer case statements name what withheld tests import * Fix(eval): Compare Interface fields as parsed; refuse failed searches
1 parent 0b57038 commit 05ce29a

12 files changed

Lines changed: 1042 additions & 129 deletions

‎apps/cli/specs/evals.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ A glob with no wildcard covers that path and everything under it; `*` matches wi
256256

257257
### Banned terms
258258

259-
`banned-terms.ts` holds the one list of process terms a neutral task statement may not contain: `Surface`, `objective`, `brief` and `verdict` (any case, singular or plural); every reviewer severity name as the scales write it (`BLOCKER`, `MAJOR`, `MINOR`, `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`); an objective id such as `O3`; a finding id such as `F2` or `R2-CR-3`; and the role names `principal`, `planner`, `architect`, `archivist`, `developer`, `reviewer` and `operator`. `checkTaskStatement` refuses a statement containing any of them as `banned_term`, naming the term, and accepts one free of them; `findBannedTerms` lists every term found. Severity names are matched only in capitals, so lowercase `blocker`, `major` or `minor` — and `blocking` — are accepted: banning them in any case would refuse ordinary English such as "a major release" or "low latency". A statement that names a severity in lowercase is therefore not caught here.
259+
`banned-terms.ts` holds the one list of process terms a neutral task statement may not contain: `Surface`, `objective`, `brief` and `verdict` (any case, singular or plural); every reviewer severity name as the scales write it (`BLOCKER`, `MAJOR`, `MINOR`, `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`); an objective id such as `O3`; a finding id such as `F2` or `R2-CR-3`; and the role names `principal`, `planner`, `architect`, `archivist`, `developer`, `reviewer` and `operator`. `checkTaskStatement` refuses a statement containing any of them as `banned_term`, naming the term, and accepts one free of them; `findBannedTerms` lists every term found, outside the code spans of an Interface block that ends the statement (see the layer-`G` re-cut). Severity names are matched only in capitals, so lowercase `blocker`, `major` or `minor` — and `blocking` — are accepted: banning them in any case would refuse ordinary English such as "a major release" or "low latency". A statement that names a severity in lowercase is therefore not caught here.
260260

261261
<!-- AEG:CLAIM: apps/cli/src/lib/dev-review-eval/banned-terms.ts contains:export function checkTaskStatement(statement: string, path = 'taskStatement'): Validated<string> { -->
262262

@@ -305,7 +305,9 @@ A glob with no wildcard covers that path and everything under it; `*` matches wi
305305
- **Source citation** — each development case becomes an `answer` case at the case file's repository commit, its scope empty, each source a delivered document pinned by the SHA-256 of its content with its own `relevant` flag, and `read_document` granted exactly when a source is delivered by retrieval tool. The held-out source-citation cases map the same way, from `source-citation-held-out.json` to `source-citation-generic-held-out.json`, and so every neutral suite has a held-out split.
306306
- **Whole loop** — each developer capability case, development and held-out, also becomes a `loop` case: the same base revision, task statement, authorized scope, delivered documents, hidden tests and reference-solution digest as its `change` case, granting the capabilities the developer, both reviewers and the publication need (`LOOP_CAPABILITIES`). Its cases live in their own files, `whole-loop-generic-cases.json` and `whole-loop-generic-held-out.json`, with their own digests and held-out index; the held-out file is cut from the developer held-out cases under the developer approval. The suite's entry in each file carries a `variants` object beside its `cases` and `layerPOnly`, never inside a case, mapping each live variant to the sorted ids of the cases it applies to: `source-requiring` when a case delivers a document, and `reviewer-requested-revision` when the committed `whole-loop-revision-facts.json` records at least one review round that asked its source change for a revision. That file lists every developer capability case whose rounds were read; a loop case it does not list refuses the re-cut.
307307

308-
A development case's task statement is the one `generic-task-statements.json` holds for its id, committed verbatim; a missing statement refuses the re-cut. A loop case's statement is its developer capability case's, copied unchanged. A held-out case's statement is generated by the script from the case's own fields — its withheld tests' count and its scope, the file its change touches, or the count of documents a source-citation case delivers — leaving out any field that would carry a banned term. The held-out run needs the pinned digest of each of the three layer-`P` held-out files (developer, reviewer and source citation), reads each case by field, writes only the derived files and prints only case counts, ids and digests; a derived case that is not valid and renderable is reported by id and digest only, and nothing is written.
308+
A development case's task statement is the one `generic-task-statements.json` holds for its id, committed verbatim, a developer case's followed by its Interface block (below); a missing statement refuses the re-cut. A loop case's statement is its developer capability case's, copied unchanged. A held-out case's statement is generated by the script from the case's own fields — its withheld tests' count and its scope, the file its change touches, or the count of documents a source-citation case delivers — leaving out any field that would carry a banned term. The held-out run needs the pinned digest of each of the three layer-`P` held-out files (developer, reviewer and source citation), reads each case by field, writes only the derived files and prints only case counts, ids and digests; a derived case that is not valid and renderable is reported by id and digest only, and nothing is written.
309+
310+
A developer capability case's statement, development and held-out, and so its loop case's, ends with the case's Interface block, so that a correct solution written independently of the reference change can pass the withheld tests. `interfaceItems` derives it from the case's fail-to-pass test files and its reference solution files, each read by its pinned blob, and the base revision's tree, parsing every source file with the TypeScript compiler API. It names each export a fail-to-pass test imports, a default import naming the module's `default` export, with that export's declared signature — the parameter list, without default values, and the return annotation as the reference source writes them, or the declaration's kind alone (`const`, `class`, `interface`, `type`) where nothing is written; a type alias's or enum's literal members; never a type the compiler infers, so a parameter with a default value is written optional, with its type only when the source annotates it — and each module the tests import that the reference change adds; then each environment variable the reference solution reads, by property, by index or by destructuring `process.env`, each `--flag` it names, each event it passes to `.on`, `.emit` or `.once`, and each field a named interface, class or object type alias declares as a direct member, with the field's written value type and the literal members of a same-file alias or enum it names, that the fail-to-pass tests also name; a type written across lines is written on one line by the compiler's printer, which keeps the member separators a line break stood for; and each string literal a fail-to-pass test passes to `expect(...)` or a matcher that the reference solution's files contain and that holds a letter. An export the base revision's module at the imported path already exports with the same signature is left out, and so is every other literal some tracked base file outside the fail-to-pass test files already contains: the base copies of the files the reference changes are checked first — a field by its parsed owner, name and type, so a type written across lines is found too — and the rest by a fixed-string `git grep -F` of the base revision, at most two hundred patterns a call; a search that fails for any reason but finding nothing refuses the re-cut. No function body, test name, exported name the tests do not import, or number is named. The block is one space, `Interface:`, then the items, each `` <kind> `<text>` `` with kind `export`, `file`, `env`, `flag`, `event`, `field` or `string`, separated by `; `, listed by kind then text, and a full stop, all on the statement's one line; a case with no item keeps its statement byte for byte. The held-out blocks are derived only once the developer approval has opened the held-out cases, and no run prints a block or any part of one. `findBannedTerms` skips the code spans of a well-formed Interface block that ends the statement, after its last ` Interface: ` marker, since a span names an identifier the tests use, and checks the rest of the statement as written.
309311

310312
```text
311313
bun apps/cli/tests/eval/recut-generic-cases.ts development
@@ -925,7 +927,7 @@ bun apps/cli/src/lib/dev-review-eval/harbor-tasks.ts --out <dir> [--archive <fil
925927
A folder holds:
926928

927929
- `task.toml` — Harbor `0.24.0`, the suite and the Vinaya renderer version under `[metadata]`, with, for a quarantined case, its quarantine's `quarantine_cause`, `quarantine_reason`, `quarantine_reason_version` and `quarantine_adjudication_digest`, and a network policy per phase. Conversion defaults to `--adapter claude-code`: the agent phase allows `api.anthropic.com` alone, with `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` so Claude makes no telemetry or update call. Conversion with `--adapter codex` allows `api.openai.com` and `chatgpt.com` instead, the Codex subscription hosts. The verifier phase, which runs in the agent's container, has no network. Image build and agent install run on the public network; only the Codex install path stages a login. No allowlist names `github.com` or `codeload.github.com`.
928-
- `instruction.md` — the case's statement from `generic-task-statements.json`.
930+
- `instruction.md` — the neutral case's `taskStatement`, its Interface block included, in the development split as in the held-out split.
929931
- `environment/Dockerfile` — Bun `1.4.2` and Node 22 in `/usr/local/bin`; `gh` from the Debian package; Claude Code installed with `npm install --global`, its `/usr/local/bin/claude` removed and `claude` linked at `/opt/claude/bin/claude` to the installed package's `bin/claude.exe`, with `/opt/claude/bin` first on `PATH`, a directory holding neither `bun` nor `node`, so a test that drops every `PATH` directory holding `claude`, `codex` or `gemini` still finds both; and the non-root user `tester` (uid `1001`, home `/home/tester`). Then the case repository at `/app`, built by `environment/prepare-repository.sh`: a partial clone of the case's real base commit with its full commit history and no blob fetched up front, so a test that reads the base's history finds it, a non-cone sparse checkout that leaves out every `apps/cli/tests/eval/*.json` and every `*held-out*.json` (a cone pattern cannot leave out a file pattern), so those files' contents are in neither the working tree nor the object store, then, for a `change` case only, `bun install --frozen-lockfile` in `/app` while the remote still exists, then the remote removed so no blob can be fetched later. A `change` case installs because its verifier has no network and runs the hidden tests with vitest from `/app/node_modules`; a `review` or `answer` case runs no hidden test and its role runs no repository code, so its image installs no workspace dependencies in `/app`. A task converted with `--archive` also holds the Vinaya CLI archive as `environment/vinaya.tar`; the image unpacks it at `/opt/vinaya`, runs `bun install --frozen-lockfile` there while the build still has the public network, records the archive's SHA-256 in `/harbor/vinaya-archive.sha256` and removes the archive. A task converted without it builds with no Vinaya CLI and still runs Harbor's oracle and `nop` agents. `HEAD` stays the real base commit, which the change-bound grader compares with the case's base revision; a rebuilt repository would have another id. A `review` case then commits the change under review as `HEAD` (`environment/commit-review.sh`): its head commit, fetched by the clone, or, for a seeded change whose head commit no repository holds, the case's inline patch applied with `git apply --unidiff-zero` and committed with the seeded identity, which reproduces the head commit's id. Last, as root, `/app`, `/case`, `/logs` (with `/logs/agent` and `/logs/verifier`) and, with `--archive`, `/opt/vinaya` are made `tester`'s, `/app` is trusted by git for any user, `HOME` is `/home/tester`, and the image ends with `USER tester`, after the clone and the review commit: the role, the verifier and the oracle run as `tester`.
930932
- `environment/case.json` — the neutral case and only what the run needs from the layer-`P` case of the same id: a `change` case's pull request number, a `review` case's patch, an `answer` case's documents. It holds no hidden test and no reference outcome; those are written only under `tests/` and `solution/`, which the role never sees.
931933
- `tests/test.sh`, `tests/grade.js`, `tests/grading.json` and `tests/hidden/` — the verifier, below.

‎apps/cli/src/lib/dev-review-eval/banned-terms.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,34 @@ export interface BannedTermHit {
6464
index: number
6565
}
6666

67+
/** The marker an Interface block opens with. */
68+
const INTERFACE_MARKER = ' Interface: '
69+
70+
/** A whole Interface block: items `<kind> `<text>`` separated by `; `, ending the statement with a full stop. */
71+
const INTERFACE_BLOCK =
72+
/^ Interface: (?:export|file|env|flag|event|field|string) `[^`]*`(?:; (?:export|file|env|flag|event|field|string) `[^`]*`)*\.$/
73+
74+
/**
75+
* The statement with the code spans of a closing Interface block blanked out:
76+
* a span names an identifier the task's tests use, which may hold a process
77+
* word (a `--surface-*` flag) without stating the task in process terms. Only
78+
* a block after the last marker that ends the statement counts; the rest of
79+
* the statement is checked as written, and each index stays where it was.
80+
*/
81+
function withoutInterfaceSpans(statement: string): string {
82+
const at = statement.lastIndexOf(INTERFACE_MARKER)
83+
if (at < 0) return statement
84+
const block = statement.slice(at)
85+
if (!INTERFACE_BLOCK.test(block)) return statement
86+
return statement.slice(0, at) + block.replace(/`[^`]*`/g, (span) => `\`${' '.repeat(span.length - 2)}\``)
87+
}
88+
6789
/** Every banned term the statement contains, in the order the list names them. */
6890
export function findBannedTerms(statement: string): BannedTermHit[] {
91+
const checked = withoutInterfaceSpans(statement)
6992
const hits: BannedTermHit[] = []
7093
for (const { term, pattern } of BANNED_TERMS) {
71-
const found = pattern.exec(statement)
94+
const found = pattern.exec(checked)
7295
if (found !== null) hits.push({ term, match: found[0], index: found.index })
7396
}
7497
return hits

‎apps/cli/src/lib/dev-review-eval/harbor-tasks.ts‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
*
99
* - `task.toml` — the Harbor version, the suite, the Vinaya renderer version
1010
* and the network policy of each phase;
11-
* - `instruction.md` — the case's neutral task statement;
11+
* - `instruction.md` — the case's neutral task statement, its `taskStatement`
12+
* in either split;
1213
* - `environment/` — the image: the case repository at `/app` at its real
1314
* base commit, with every evaluation case file and every held-out file left
1415
* out of its working tree and object store, and `case.json`, the neutral
@@ -45,7 +46,7 @@ import {
4546
type ReadBytes,
4647
referenceEntry
4748
} from '../../../tests/eval/generic-suites'
48-
import { GENERIC_SUITE_IDS, SEEDED_COMMIT_IDENTITY, STATEMENTS_PATH } from '../../../tests/eval/recut-generic-cases'
49+
import { GENERIC_SUITE_IDS, SEEDED_COMMIT_IDENTITY } from '../../../tests/eval/recut-generic-cases'
4950
import {
5051
loadDevelopment as loadReviewerDevelopment,
5152
loadHeldOut as loadReviewerHeldOut,
@@ -704,10 +705,6 @@ const HELD_OUT_FAMILY: Partial<Record<GenericSuiteKey, string>> = {
704705
sourceCitation: DERIVED_FILE_DIGESTS.sourceCitation.heldOut
705706
}
706707

707-
function statementsOf(): Record<string, string> {
708-
return JSON.parse(readFileSync(STATEMENTS_PATH, 'utf8')) as Record<string, string>
709-
}
710-
711708
/** The layer-`P` counterparts a split converts with, by suite. */
712709
function counterparts(split: 'development' | 'held-out', keys: readonly GenericSuiteKey[]) {
713710
const developer: DeveloperCapabilityCase[] = []
@@ -762,7 +759,6 @@ export async function convertHarborTasks(options: ConvertOptions): Promise<Conve
762759
throw new HarborTasksRefused(`the quarantine record names ${stray.taskId}, which ${suite} does not hold`)
763760
}
764761
const script = await bundleGradeScript()
765-
const statements = split === 'development' ? statementsOf() : {}
766762
const layerP = counterparts(split, keys)
767763
const url = options.repositoryUrl ?? GITHUB_REPOSITORY_URL
768764
// The archive is written first: every task image installs the Vinaya CLI from it at build.
@@ -793,7 +789,7 @@ export async function convertHarborTasks(options: ConvertOptions): Promise<Conve
793789
const sources: CaseSources = {
794790
key,
795791
case: c,
796-
statement: split === 'development' ? (statements[c.id] ?? '') : c.taskStatement,
792+
statement: c.taskStatement,
797793
developer: layerP.developer.find((p) => p.id === c.id),
798794
reviewer: layerP.reviewer.find((p) => p.id === c.id && p.role === role),
799795
citation: layerP.citation.find((p) => p.id === c.id),

‎apps/cli/tests/eval/banned-terms.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,29 @@ describe('banned-terms checker', () => {
6565
])
6666
})
6767

68+
it("skips the code spans of a closing Interface block, and still reports every term in the statement's prose", () => {
69+
const block = ' Interface: flag `--surface-only`; field `Report.reviewer: string`; string `the brief is ready`.'
70+
expect(findBannedTerms(`${NEUTRAL}${block}`)).toEqual([])
71+
expect(checkTaskStatement(`${NEUTRAL}${block}`).ok).toBe(true)
72+
// The same words in the prose are reported, at the index the statement holds them.
73+
const prose = 'Keep the surface flag working for the reviewer.'
74+
expect(findBannedTerms(`${prose}${block}`)).toEqual([
75+
{ term: 'Surface', match: 'surface', index: prose.indexOf('surface') },
76+
{ term: 'reviewer', match: 'reviewer', index: prose.indexOf('reviewer') }
77+
])
78+
// Only a well-formed block that ends the statement is skipped: a marker in the middle, an unknown kind or a
79+
// missing full stop leaves every span checked.
80+
expect(findBannedTerms(`${NEUTRAL}${block} Then stop.`).map((hit) => hit.term)).toContain('Surface')
81+
expect(findBannedTerms(`${NEUTRAL} Interface: name \`--surface-only\`.`).map((hit) => hit.term)).toEqual([
82+
'Surface'
83+
])
84+
expect(findBannedTerms(`${NEUTRAL} Interface: flag \`--surface-only\``).map((hit) => hit.term)).toEqual(['Surface'])
85+
// A span before the last marker is prose.
86+
expect(findBannedTerms(`Use \`--surface\`.${' Interface: env `PATH`.'}`).map((hit) => hit.term)).toEqual([
87+
'Surface'
88+
])
89+
})
90+
6891
it('names each term once', () => {
6992
const terms = BANNED_TERMS.map((t) => t.term)
7093
expect(new Set(terms).size).toBe(terms.length)

0 commit comments

Comments
 (0)