Skip to content

feat(tasks): add NL2SH-ALFA — execution-graded natural language to Bash - #139

Merged
ethan-scitix merged 4 commits into
mainfrom
feat/nl2sh-alfa
Sep 11, 2026
Merged

feat(tasks): add NL2SH-ALFA — execution-graded natural language to Bash#139
ethan-scitix merged 4 commits into
mainfrom
feat/nl2sh-alfa

Conversation

@ethan-scitix

Copy link
Copy Markdown
Collaborator

Type

  • feature — new benchmark, task, or capability

Summary

  • NL2SH-ALFA: dataset nl2sh_alfa + two tasks — nl2sh_alfa_0shot_gen (upstream's Base reading) and nl2sh_alfa_0shot_gen_parse (its shipped prompt + parse_bash) — graded by executing the model's Bash command and upstream's gold against a prepared filesystem. Paper: arXiv:2502.06858 (NAACL 2025); harness: westenfelder/InterCode-ALFA (icalfa 0.3.6, the version the paper states Table 5 was produced with); dataset: westenfelder/NL2SH-ALFA. Both MIT.
  • The scored set is 300 rows, not the 600 the card advertises (600 counts two verified commands per instruction). This is a live trap, not bookkeeping: index_to_img raises past 299 and submit_command swallows that into a score of 0 after a bare print, so a caller who believes the larger figure loses half a run silently.
  • Grading reads the harness's own vendored gold table, not the Hub's bash column — that is what upstream scores, and the two disagree on 2 of 300 rows (index 38 echo -n 'hello' | base64 vs echo 'hello' | base64, i.e. aGVsbG8= vs aGVsbG8K; index 100 awk 'length < 20' vs < 40), with 3 more differing in wording. The dataset exposes both sides, joined positionally under a 300/300 assertion, so the mismatch is visible in the data instead of resolved inside the grader.
  • _parse is a new variant word in sieval/tasks/CLAUDE.md, licensed by upstream publishing two protocols over one benchmark and one grader — not by a preference between them. The bare name goes to Base, since parsing is one of four methods the paper layers on the benchmark. Their gap reaches 32% on small models, so one number would hide whether a model was scored on translation or on formatting compliance.
  • New infrastructure: vendor/code-evaluator gains its first stateful route (POST /shell-evaluations) and five images, because the baseline filesystem is the image. Each Dockerfile.nl2sh-{1..5} is upstream's verbatim (verified line-by-line, not asserted) with a service block appended under /opt — which upstream's own docker.gitignore excludes, so the baseline commit cannot see it and git clean -fd cannot delete it mid-run.
  • New dependency nl2sh-alfa = ["scipy>=1.16.3"]: 1 - scipy.spatial.distance.cosine(...) is the FEH's exact expression, so it is the metric and not a convenience — a hand-rolled dot product could disagree with upstream on a pair sitting within float noise of the 0.75 threshold. No lighter alternative that keeps that guarantee; scipy is already resolved in three existing groups.

Two upstream behaviours reproduced deliberately

  • Upstream's own quoting rewrites 37 of its 300 golds. clean_cmd wraps a command in double quotes and hands the string to docker-py, which normalizes it through shlex.split (docker/utils/utils.py:486, verified at 7.1.0). Index 230's awk '{print $2 " " $1}' is cut at the quoted space and reaches bash as a syntax error, making that gold's "correct answer" an awk error message. Writing the obviously-correct [entrypoint, "-c", command] would disagree with every published number on 12% of samples, so command_argv reproduces the transform and a test pins it over all 300 golds.
  • md5deep is absent from every image, and upstream's get_hash_cmd still picks it for any path without a . — so both sides get the same failure text, compare equal, and a directory-only change always scores full credit on part 2. Preserved; a test asserts the equality, which is the property the metric reads.

Related Issues

Refs the NL2Bash scoping that preceded this: NL2Bash was declined because its headline is human-judged and its grader is GPL, and NL2SH-ALFA was named as the set to land once stateful bash execution existed. This PR is that execution plus the port.

Test Plan

Automated

  • Lint/format clean (ruff check && ruff format --check)
  • Type check clean (ty check)
  • Unit tests pass — 6770 passed, including 51 new sieval-side tests; plus 17 in the vendored service (vendor/code-evaluator/tests/test_exec_sh.py, which drives the whole reset/status/hash/timeout/refusal protocol against a throwaway git tree via NL2SH_FS_ROOT — no container needed)
  • scripts/check_preflight.py — 25 checks, all PASS
  • Rebased onto main (2cdd39f8); the pdm.lock conflict was resolved by re-locking, not by picking a side, and the resulting diff against main is 4 lines (groups list, content_hash, numpy/scipy group membership) with zero version drift

Manual

  • sieval dataset download nl2sh_alfa succeeds (2.5 MB, 5 files, revision-pinned)
  • sieval task show nl2sh_alfa_0shot_gen / _parse and sieval dataset show nl2sh_alfa all resolve by name
  • All 11 vendored upstream files (5 gold tables, 5 setup scripts, docker.gitignore) verified byte-identical to upstream after commit by sha256 — the pre-commit hooks include end-of-file and trailing-whitespace fixers, so "hooks passed" is not the same evidence
  • Each of the five Dockerfiles verified line-by-line against upstream's at the pinned commit; the only difference is the documented docker/nl2sh/ COPY prefix, and ENV FILES appears on fs1 only — which matters, because exactly one gold (in fs1) reads $FILES
  • Score comparison table — NOT included, and cannot be. Building the five images and running the validation ladder needs a host with Docker; the dev box has none (no docker/podman/nerdctl, and bwrap cannot unshare). This is why both tasks ship status="experimental". The ladder, for whoever has such a host: (1) feed each gold back as its own prediction — must be 300/300; (2) run upstream icalfa at eval_mode="embed", eval_param=0.75 over one stored prediction set and compare per-sample verdicts, the only evidence the topology divergence below is harmless; (3) align against gpt-4o-2024-08-06 → 0.74 and gpt-4o-mini-2024-07-18 → 0.71 (σ ≈ 2.5 pp at n=300, so a 3 pp band). Rungs 2 and 3 are what promote off experimental; rung 1 alone does not, since it never exercises the FEH.

Checklist

Required (all PRs)

  • PR title follows conventional format
  • No internal paths, credentials, or personal info in committed files
  • AI-generated code has AI-Generated Code - <model> (<provider>) in module docstring
  • No new upper-layer dependencies added to core/core/ is untouched
  • Deleted code verified — nothing deleted; the three existing /evaluations backends are untouched, so no existing task's grading moves

If: New or Modified Benchmark

  • Reference paper/repo linked in Summary
  • Score comparison table — see Test Plan → Manual for why this is blocked on a Docker host rather than on this diff
  • Dataset loading tested (sieval dataset download nl2sh_alfa succeeds)
  • Task registered — tasks/__init__.py discovers by module and both .pyi stubs plus sieval/meta/index.json are regenerated (57 datasets / 72 tasks); verified by re-running both sync scripts after the rebase and getting zero diff, since an auto-merged index can be stale without conflicting

If: community/ Changes

  • Upstream diff documented — sieval/community/intercode_alfa/'s docstring states what is verbatim (index_to_img, parse_status, get_hash_cmd, clean_cmd, parse_bash, the three reward parts with their erf/round shapes, the A/??/C filter, the 1000-char truncation, the strict > threshold) and what is left out and why (everything touching Docker or a model: execution belongs to the code-eval service, the embedding call to the task, which owns credentials and records). Three upstream behaviours that read like defects are called out individually with the reason each is preserved.
  • License attribution preserved — both upstreams MIT, cited with pinned commits (icalfa 2d3a6947, NL2SH b405201a)
  • vendor/code-evaluator/VENDORED.md updated with the new patch, its two deliberate divergences, and the standing "land upstream in scitix/code-evaluator and re-vendor" item

If: New Dependency

  • Added to correct PDM dependency group (nl2sh-alfa, and both tasks name it as deps_group; declared by hand because check_dep_coverage scans only tasks/ and datasets/ and this import is reached through community/)
  • Justified in Summary

Three divergences from upstream, each in reference_impl.notes

  1. One container with a reset before each command, instead of two peer containers. Upstream runs the gold in a second container so the model can never perturb it. Here the gold runs first, on a freshly restored tree, which preserves that property within a sample and yields exactly the inputs p1/p2/p3 need. What is not preserved is cross-sample residue in gitignored paths — and upstream's own containers persist across all 300 samples, accumulating the same.
  2. The 10 s command wall (utils.TIMEOUT_DURATION) is applied to the gold as well. Upstream bounds only the model's command; an unbounded gold hangs a service rather than one sample. This is an execution-safety bound, so it owes evidence it never binds — n_gold_timeouts is published for exactly that.
  3. The embedding is served over an OpenAI-compatible endpoint rather than Ollama's /api/embeddings, so the weights are the Hub's rather than Ollama's f16 GGUF conversion of them. Both calls stay separate, as upstream's are. Quantifiable by re-scoring one stored run through both endpoints and counting verdict flips.

Also worth a reviewer's attention: parse_status raises IndexError on an odd token count (a path containing a space), where upstream swallows it into a score of 0. This port lets it propagate per .claude/rules/tasks.md, so the sample lands in fails with a recorded reason — and under DENOMINATOR_REQUESTED a fail is already charged as wrong, so the headline agrees with upstream's 0 while the cause stays visible instead of being indistinguishable from a model that answered incorrectly.

🤖 Generated with Claude Code

ethan-scitix and others added 3 commits September 11, 2026 11:37
Dataset `nl2sh_alfa` plus two tasks, `nl2sh_alfa_0shot_gen` (upstream's Base
reading) and `nl2sh_alfa_0shot_gen_parse` (its shipped prompt + `parse_bash`),
both `status="experimental"`. Graded by InterCode-ALFA / `icalfa` 0.3.6 — the
version the NAACL 2025 paper (arXiv:2502.06858) states its Table 5 was produced
with — vendored as `sieval/community/intercode_alfa`.

The scored set is 300 instructions, not the 600 the card advertises: 600 counts
two verified commands per instruction. That matters beyond bookkeeping —
`index_to_img` raises past 299 and `submit_command` swallows it into a score of
0, so a caller who believes the larger figure loses half a run silently.

Grading reads the harness's own vendored gold table rather than the Hub's `bash`
column, because that is what upstream scores; the two disagree on two of 300
rows (index 38 `echo -n` vs `echo`, index 100 `length < 20` vs `< 40`) and three
more differ in wording. The dataset exposes both sides of that, joined
positionally under a 300/300 assertion, rather than hiding it in the grader.

`_parse` is a new variant word in `sieval/tasks/CLAUDE.md`, licensed by upstream
publishing two protocols over one benchmark and one grader. The bare name goes
to Base, since parsing is one of four methods the paper layers on the benchmark;
the evidence behind the two is asymmetric and the notes say so — `_parse` is the
protocol upstream ships runnable code for, while Base's prompt appears in no
script, so its system/user split is inferred.

Upstream's `clean_cmd` wraps a command in double quotes and docker-py
`shlex.split`s the result, which rewrites 37 of the 300 golds and truncates
index 230 into a syntax error. Reproduced deliberately, on both sides of the
wire: passing `[entrypoint, "-c", command]` is the obviously-correct thing to
write and would disagree with every published number.

`vendor/code-evaluator` gains its first stateful route
(`POST /shell-evaluations`) and five images. The prepared filesystem *is* the
image, and there are five — four Ubuntu sharing one package set over different
trees (`/testbed`, `/system`, `/workspace`+`/backup`, and one with no tree at
all, upstream's `setup_nl2b_fs_4.sh` being a no-op) plus Alpine, whose BusyBox
coreutils print differently. Each Dockerfile is upstream's verbatim (checked
line-by-line, not asserted) with a service block appended under `/opt`, which
upstream's own `docker.gitignore` excludes — so the baseline commit cannot see
it and `git clean -fd` cannot delete it mid-run. `NL2SH_FS_ID` is baked per
image and a request for another is refused; `--workers 1` is pinned, since every
request mutates the shared tree.

The service returns execution facts and never a verdict: the FEH needs an
embedding model when outputs differ, and the service holds no model credentials.
FEH is exec + mxbai-embed-large at cosine > 0.75, upstream's own reproduction
setting rather than `submit_command`'s retired gpt-4-0613 default.

Three divergences, enumerated in `reference_impl.notes`: one container with a
reset before each command instead of two peer containers (gold runs first on a
restored tree); the 10 s wall applied to the gold as well, with
`n_gold_timeouts` published so the added bound can be shown not to bind; and an
OpenAI-compatible embeddings endpoint rather than Ollama's, so the weights are
the Hub's rather than its f16 GGUF conversion.

Verified: 51 sieval-side tests and 17 in the vendored service, plus ruff, ty and
all 24 preflight checks; the full unit suite is 6690 passing. The reward
arithmetic, the quoting rewrite over all 300 golds, p3's branch order and the
service's reset/status/hash/timeout/refusal protocol are all covered without a
container. NOT verified, and the reason both tasks ship `experimental`: the five
images, the container-topology divergences, and every published number — all
three need a Docker host.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`ping 192.168.1.1` is one of NL2SH-ALFA's 300 tasks, so the private address
is the benchmark's own data rather than a leaked internal one. The five asset
files are byte-identical upstream gold and must stay that way — they are what
grading compares against — so editing the data is not available and the
allowlist is the only fix. Same category as the existing `data/` entry.

Scoped to that one directory rather than `community/*/assets/`: a wildcard
would silently exempt vendored data nobody has read yet. Verified the check
keeps its teeth by reverse mutation — a private IP added to a non-allowlisted
file under `sieval/` still fails the scan.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…reading as a broken grader

Six findings from the review of this PR.

`_argv`'s `ValueError` escaped the handler that was written for it. It was built
as an *argument* to `_run`, so it evaluated outside `_run`'s
`except (OSError, ValueError)`: an unbalanced double quote in a model reply —
truncation mid-string is the common route — propagated out of `execute_shell`,
became a 500, and put the sample in `fails`. Upstream reaches the same raise
(docker-py normalizes a string command through `shlex.split` inside `exec_run`)
and `exec_action` catches it, grading the sample with
`observation = "Exception: No closing quotation"` and `action_executed = False`.
The score never moved — a fail is charged as wrong under DENOMINATOR_REQUESTED —
but it spent model errors out of the budget `fails` exists to report, and
`exception::*` is retriable, so each resume re-graded it. Splitting now happens
inside `_run`, which covers the bare hash path too.

`exec_action` also rewrites any model reply starting with `cd`, resolving its
argument against the working directory: `cd ~` reaches bash as `cd /~` and
fails where the reply itself would have succeeded. That was an unannotated
divergence, and it is the same genre as the quoting rewrite already reproduced
here, so it is reproduced too — `simplify_path` verbatim plus `model_action`,
applied to the model's command only, since upstream runs the gold as
`container_eval.exec_run(clean_cmd(self.gold))` and never touches
`exec_action`. A test pins that asymmetry. A reply starting with `cd` but
carrying no `"cd "` (a bare `cd`, or `cdparanoia`) escapes to `submit_command`
upstream and scores the sample 0 outright; a facts-only response cannot return
a verdict, so it reports that nothing ran, which reaches the same answer for
every gold that changed something.

Hashing on the model side is now restricted to the paths the gold also changed.
Upstream hashes only `diff_same`, which is unknowable here while the first
side's tree is still standing — but `diff_same` is a subset of the first side's
hashed paths, so the scored set is identical and `file_change_score`'s
`KeyError` contract is untouched. A reply creating 300 files went from 307
subprocesses to 7. `ShellFacts` now says the maps are the scored set rather
than an inventory, and two tests cover both directions.

`community/intercode_alfa` pointed at a design doc under `docs/designs/`, which
is gitignored — a dangling path for every reader of the public repo. Replaced
with the tests that pin each preserved behaviour, which is what the pointer was
for, and the missing `AI-Generated Code` marker goes where it was.

The Base/Parse gap cited for llama-3.2-3b was wrong: Table 5 reads 0.17 → 0.49,
which is 32 pp. 20 pp is llama-3.2-1b (0.12 → 0.32). Both are now named, and
32 pp is also what makes the "reaches 32%" claim in `sieval/tasks/CLAUDE.md`
true from the Parse column itself rather than borrowed from the abstract's
all-four-methods headline.

Finally, `/tmp` is absent from upstream's `docker.gitignore` and empty when the
baseline is committed, so the first `git reset --hard; git clean -fd` deletes
the directory outright and it does not come back — measured against a throwaway
tree, not assumed. Upstream's containers do the same, so it is documented rather
than changed: adding `tmp` to the ignore file would change what `git status`
reports and therefore what the benchmark scores. It does mean the three
stateless routes that use `tempfile` are unreliable on these five images, which
is recorded next to it.

Vendor suite 17 → 26 tests, sieval-side 51 → 52. The new sieval-side one pins
the service's `_argv` to `community.command_argv` over all 300 golds and both
entrypoints: only the mirror was swept before, and the mirror has no production
caller, so a repair to the copy that actually executes would have left every
assertion here passing. Verified by reverse mutation in both directions.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ailing samples

Seven findings from the review of this PR, two of which made a full run
unscoreable.

**The endpoint is now per filesystem.** One shell-eval instance hosts exactly
one prepared tree and refuses any other `fs_id`, and the vendored README's own
`docker run` loop starts five of them on 11451..11455 — but the task read a
single `SIEVAL_SHELL_EVAL_API`, so every sample went to one instance. Pointed at
the default, 153 of 300 samples graded and the other 147 came back refused,
which propagates into `fails` and is charged as wrong under
DENOMINATOR_REQUESTED: a headline of at most 0.51, which sits inside the
published 0.58–0.74 band and so reads as a weak model rather than a misrouted
run. `SIEVAL_SHELL_EVAL_API` is now a template whose `{fs_id}` is substituted
per sample; a value carrying no placeholder is passed through unchanged, which
is what a reverse proxy dispatching on the request body needs. Since the refusal
is the backstop in both deployments, this changes where routing happens, not
whether it does.

**Queue time is no longer charged to a deadline sized for one sample.** Each
instance serializes every request behind one lock and pins `--workers 1`, so
concurrent samples can only queue — and `timeout=self._timeout * 2 + 10` was a
bare float, which httpx applies to connection-pool acquisition as well as to
the read. With the task-level `concurrency_limit` defaulting to unbounded, the
tail of the queue therefore timed out and landed in `fails`, deflating the score
with no error anywhere. Measured against the real topology (serialized server,
`max_connections=4`, 2s/request, 20 concurrent samples, deadline scaled
proportionally): **18/20 samples failed — 14 PoolTimeout, 4 ReadTimeout**. Now a
per-filesystem semaphore holds one in-flight request per instance and the
deadline declares `pool=None`, so the read budget measures service time. Same
measurement after: **0/20**. Parallelism across the five instances is the only
parallelism there is, so `max_concurrency` is gone rather than defaulted —
raising it per instance could only build a queue.

The remaining five are small:

- `__init__` built the `httpx.AsyncClient` *before* checking
  `SIEVAL_EMBED_API_KEY`, so the guard's own `ValueError` leaked an unclosed
  client — on every suite run, since a test walks that path. The check moved
  above the construction.
- Both tasks are now in `FORBIDDEN`
  (`tests/unit/tasks/test_import_discipline_family.py`). The manifest is
  parametrized over its own keys, so a task left out is not checked but merely
  absent: the discipline held (the probe reports scipy unimported), but nothing
  pinned it, and hoisting `from scipy.spatial.distance import cosine` to module
  scope would have made registration pay for the optional group with nothing
  turning red. Verified by reverse mutation.
- Failure messages interpolated `rollout["index"]`, which is 0 for every sample
  at this benchmark's n=1 — so each of those 147 refusals would have read
  "sample 0". They now name `ctx.sample_id` and the endpoint the request went
  to, and the correlation id is `<sample>-<rollout>-<ns>` so a service log line
  leads back to a dataset row.
- `output_similarity` — the FEH's entire arithmetic — had no test. Covered now,
  including that it is cosine *similarity* and not distance: dropping the `1 -`
  inverts every verdict while preserving the range, which no threshold
  assertion notices.
- `VENDORED.md` still said 17 vendored tests; there are 26.

Rebased onto `eb9a6d8c`, via `cc4dc923`. On the first pass the three
conflicts outside the generated files were
additive (MultiPL-E's `/languages` route and its two VENDORED.md entries landed
beside this route, sharing a closing paren), so both sides are kept;
`meta/index.json` and both `.pyi` stubs were regenerated rather than
hand-merged, and `pdm.lock` needed no re-lock — its diff against main is still
the same 4 lines with zero version drift. Commit 3's content was checked to
have survived at its new location.

Unit suite 7186 passing, vendored service 26; ruff, ty and all 25 preflight
checks clean. Each of the four behavioural fixes is pinned by a test shown to
fail when the fix is reverted.

Docs and comments say each fact once: the full argument for per-filesystem
routing lives in the module docstring, `reference_impl.notes` carries the
self-contained copy that ships in `meta/index.json`, and the inline comments
point at them rather than restating them (net -36 lines of prose).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@ethan-scitix
ethan-scitix merged commit e55faa3 into main Sep 11, 2026
9 checks passed
@ethan-scitix
ethan-scitix deleted the feat/nl2sh-alfa branch September 11, 2026 04:01
ethan-scitix added a commit that referenced this pull request Sep 11, 2026
…LFA landed

This PR said it owed this: "Neither is merged yet — whichever lands second
should reconcile the two rather than grow a third shape." NL2SH-ALFA (#139)
landed first, so QuoteBench is second and the debt is due.

Reconciling does NOT mean merging them. The split is forced by the benchmarks,
not chosen: `/shell-evaluations` returns facts because deciding Bash equivalence
needs an embedding model and the service holds no credentials, while
`quotebench` returns a verdict because an exact final-state comparison is
decidable in-process. One is stateful and pinned to five prepared images at
`--workers 1`; the other is stateless with a per-attempt fixture and is safe in
parallel. Folding either into the other would cost exactly the property that
makes it work. So VENDORED.md gains a table of the two and a rule for picking
between them, phrased as a row to match rather than a column to add.

One concrete interaction, not just prose: `quotebench` is a FOURTH route that
needs `/tmp`, which the NL2SH images delete on their first `git clean -fd`.
That entry listed three (`exec_py_code`, `exec_js`, `exec_ts`) because it was
written before this source existed. `run_attempt` calls `mkdtemp` per attempt,
so on those images grading raises before any command runs — loud, but it reads
as a broken grader rather than as a route on the wrong image.

Also corrects the `tests/unit/vendor/` carve-out added earlier in this branch.
It claimed an upstream-bound patch keeps its tests in `scitix/code-evaluator`;
NL2SH-ALFA in fact keeps 26 of them in `vendor/code-evaluator/tests/`, a third
location that did not exist when the carve-out was written. The note now states
both locations and the trade between them — tests that travel with the code are
outside CI's collection, which is the price of their travelling.

Verified on the rebased tree rather than assumed: all five routes are live on
one merged server (`/shell-evaluations` answers 422, not 404), the digest is
unchanged, and the 224-execution anchor still replays 224/224 through the merged
HTTP path. NL2SH's own 53 tests (27 task + 26 vendored) pass.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
ethan-scitix added a commit that referenced this pull request Sep 11, 2026
…wo command paths (#140)

* feat(community): vendor QuoteBench task definitions at 693325a6

Vendors upstream's `core`, `scenarios` and `shellesc` byte-identically from
LeonardNJU/quoteBench @ 693325a6 (Apache-2.0). Only the halves sieval needs:
contract prompts, the Task type, the literal-text markers, and the 56 frozen
tasks. `harness.py` is deliberately left out — execution is the
code-evaluator's half, and vendoring it here would put an executor one import
away from a task.

The identity test pins each file's sha256 taken from the upstream tree rather
than from the copy, because a digest read off the copy pins whatever was
copied. Proven to bite by reverse mutation: substituting an ASCII lookalike
for the U+27EA marker turns 2 of the 7 tests red, and restoring returns all 7
to green.

One assertion was written wrong and corrected against the measurement, not the
other way round: 48 of 56 instructions carry marked literal text, and the 8
that do not are exactly the `find-glob` and `bulk-rename` families. That is
structural — those two put their hazards in the fixture, as hostile filenames
already on disk, instead of handing the model a literal to reproduce. The test
pins the split rather than the bare count so it still bites if a family that
should carry markers loses them.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* feat(datasets): add QuoteBench frozen-core dataset

QuoteBench ships no downloadable prompt set — upstream constructs all 56 tasks
in Python. `scripts/gen_quotebench_snapshot.py` renders the model-facing half
into `<data-dir>/quotebench/quotebench-core.json`, following the `local:`
bring-your-own convention that `datasets/downloaders/local.py` defines: the file
is produced by a script straight into the data dir, never committed, and an
absent snapshot raises instead of yielding an empty split.

The oracle, the naive probe and the fixture builders are deliberately not
serialized: they are the evaluator's half, and shipping the oracle beside the
prompt would put the answer in the dataset. A test asserts the row keys are
exactly the five prompt fields.

`local:` sources cannot carry a declared checksum — `check_datasets` accepts
checksum keys only for `url:` basenames — so the pin between the snapshot and
the vendored package is a test that compares staged rows against `all_tasks()`.
It builds the fixture by calling the generator's own `build_rows()` rather than
reimplementing it, so it cannot keep passing after the generator breaks.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* feat(code-evaluator): add quotebench source with fixture-state grading

A source whose unit of work is a task id plus one command, not a program plus
test cases: the task builds its own filesystem fixture in Python, the reply runs
inside it as a single `bash -c` payload, and the verdict is the exact final
state — file bytes, argv, JSON, directory contents, or Git history. No reference
command string is compared, which is why none of the existing exec_* modules
could carry it. sieval itself still executes nothing.

Verified through the HTTP path with TestClient, so pydantic validation and the
declared response model are both in play — the latter matters because
`ResourceMetrics` had to gain four optional fields rather than a per-source
subclass, whose extras FastAPI would silently strip:

- all 56 oracles pass;
- replaying the stored replies of upstream's raw-vs-nested arm reproduces the
  GNU verdicts upstream recorded for them 224/224 on `passed` and 224/224 on
  failure class, across all four crossover cells;
- protocol errors (unknown task, unknown contract, missing kwargs) answer with
  `data=None` while a merely wrong command answers with `data` present, so a
  caller can tell them apart.

The contract-to-transport mapping lives here, not in the vendored package:
upstream's `public_cli.command_for_transport` raises ValueError on `nested`, the
spelling its own released dataset uses, so its public scorer cannot read its own
release. We accept the released spellings and reject the CLI-only ones.

Upstream's `get_task` rebuilds all 56 tasks per lookup, which under --workers N
would be paid per request; an lru_cached index replaces it. The Task objects are
reusable — setup/check close over the task's own constants and run_attempt
allocates a fresh temp dir per attempt.

Dockerfile.quotebench pins upstream's base digest and its seven GNU packages
because QuoteBench scores BSD and GNU separately and the published crossover
table is the GNU replay. The image is unbuilt and unrun so far — no container
runtime here — so this ships behind QUOTEBENCH_EXECUTOR=local for now.

VENDORED.md now separates upstream-bound patches from ones sieval owns in-tree
by decision; this is the first of the latter.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* feat(tasks): add QuoteBench raw and nested-shell-v2 contracts

Two registered tasks over one benchmark, because a contract is not a prompt
variant: it declares what happens to the reply between the model and the shell,
and upstream's finding is that two matched command paths can score alike while
reversing the order of 5 of 26 model pairs. One number for "QuoteBench accuracy"
would hide which command path produced it, and the name is the only place that
distinction can live.

Which prompt backs the published nested column was measured rather than assumed:
all 56 stored system messages in upstream's released raw-vs-nested arm match
SYSTEM_PROMPT_NESTED_SHELL_V2 by identity, so the v1 constant the same module
still ships — one-line-reply rule plus an escaping tutorial, which upstream marks
contract-confounded — is the wrong one to carry.

No extraction, deliberately: upstream passes the reply straight to `bash -c` and
scores a fenced or chatty answer as the shell failure it becomes, so stripping
fences would repair the thing being measured.

The shared base pins the two vendored copies to each other at run time. Every
response echoes a digest over core.py + scenarios.py + shellesc.py, and the task
raises rather than scoring when it does not match what the prompts were built
from — drift would otherwise be silent, with prompts from one fixture set,
grading against another, and every number still plausible. An evaluator that
omits the field is treated as a mismatch, not as no objection.

`report.json` publishes upstream's own `pass_rate_pct` as the headline, with
`score` riding as an alias on the same interval rather than a second one
computed the same way; all seven failure classes are columns even at zero, plus
`n_unknown_class` so a taxonomy upstream widens is counted rather than dropped,
and per-tier / per-scenario rates carry no interval because each sits on a
different population from the headline's.

The acceptance anchor is a standing test now: the release recompute needs only
the arm file, and the replay needs a running evaluator and agrees with
upstream's recorded GNU verdicts 224/224 on both `passed` and failure class.

Both ship `experimental`: the shipped path grades inside a GNU userland image
that has not been built or run, so no score impact is quantified against it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(tests): commit the QuoteBench anchor fixture, which `data/` was hiding

CI failed on the two acceptance tests with FileNotFoundError while 6706 tests
passed. The arm file was never in the commit: `.gitignore` carries a bare
`data/`, which matches at any depth and so swallowed
`tests/acceptance/quotebench/data/` along with the run-artifact directories it
was written for. `git add tests/acceptance/quotebench` reported only the README
and the test, and I did not check the staged list against what the tests read.

Re-included with an explicit negation. Both lines are needed: git will not
descend into an excluded directory, so the directory has to be re-included
before anything inside it can be. Scoped to `tests/acceptance/*/data/` rather
than to this one path, because any acceptance fixture would hit the same rule.

The failure mode is the reason to commit the fixture rather than fetch it: a
test that reads a checked-in file passes locally and fails only in CI, which is
the slowest possible place to learn the file is missing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(tasks): address the eight QuoteBench review findings

Three behaviour changes, five corrections. No verdict moves: the released
rollout grid still replays 224/224 on `passed` and on failure class.

Grading no longer blocks the event loop. `execute_quotebench` is fully
blocking and `evaluate` is `async def`, so FastAPI ran it ON the loop rather
than in the threadpool it gives a plain `def`. Measured with four concurrent
gradings of a `sleep 3` reply while polling `/health`:

  wall clock for the four   12.02 s -> 3.01 s
  /health polls served            2 -> 59
  /health worst latency    11969 ms -> 1.9 ms

The stall was not confined to quotebench -- one shared loop, so a slow Bash
reply also held up LiveCodeBench and HumanEval on that worker. The other
three sources were already off the loop, two of them via `asyncio.to_thread`,
so this is the package's own idiom.

The digest handshake now settles in `setup()`, over a new read-only
`GET /quotebench/digest`. It was checked only per sample in `feedback`, which
`TaskRunner` catches and turns into a FAILED context -- so a skewed evaluator
did not abort the run, it failed all 56 samples and wrote a report.json
reading 0.0. On a hard shell-quoting benchmark that is a number someone would
act on. `setup` is the only stage that can stop a run; checked there, a skew
costs one GET and no inference. The per-sample check stays as a second line,
for an evaluator replaced mid-run behind one address. The URL is joined
relatively so a service mounted under a prefix keeps it.

`n != 1` is refused at construction instead of being silently narrowed.
`report()` reads one rollout per sample on every axis it publishes -- the
headline, the seven failure-class counts, and the per-tier and per-scenario
rates -- so a larger budget was generated, graded, paid for and then dropped
behind a score that still looked like a complete run. Averaging the extras in
is a design question, not a fix: a failure class has no agreed reading across
repeats, and upstream's anchor grid is single-draw, so there is no reference
answer to port. Both leaves' reference_impl.notes say so.

Corrections:

- The acceptance README claimed `crossover.py` carries a transport-alias map.
  It does not; `rollouts.py` reads the released spelling only as dict keys
  over stored verdicts, and both modules that build a command reject it.
- VENDORED.md described the 56-oracle check and the 224/224 replay as one
  verification level. They are gated differently, and the stronger of the two
  is the one CI does not run.
- The README and Dockerfile told operators to run the service itself with
  `--network none`, which cannot work -- it has to be reachable to be called.
  Both now state the two real isolation models: upstream's `exec_docker`
  enforces `--network none` per attempt in its own argv, and the in-container
  route needs an internal network, not no network.
- Attribution line normalized to the form the other twelve modules use.
- Restored upstream's `__version__` in the partially-vendored `__init__`, so
  the two copies of the package do not answer differently. Not added to the
  digest, which covers only what decides a verdict.

Tests: the setup guard aborting rather than degrading to 0.0, the 404 and
unreachable paths, the relative URL join across five base shapes, and the `n`
refusal. Plus the contrast the guard exists for -- an all-fails run publishing
a believable zero.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* feat(tasks): promote QuoteBench to stable — the GNU image is built, run and measured

Both tasks shipped `experimental` on one sentence: the GNU userland image the
shipped path grades in had not been built or run, so no score impact was
quantified against it. It has now been run, and the sentence no longer holds.

No Docker daemon is available here (container-in-container: AppArmor refuses the
legacy mount(2) podman/crun/bwrap need), so the Dockerfile's layers were replayed
into a udocker/PRoot container. The base is pulled BY DIGEST, which is
load-bearing: `debian:stable-slim` has moved since the Dockerfile was written, so
a tag pull grades in a different userland. The pin is not cosmetic either -- an
ordinary host here has mawk and no gawk at all, against the image's gawk 5.2.1.

The anchor reaches 224/224 on `passed` and on failure class in BOTH userlands, so
the host and containerized executors agree with upstream and with each other: the
userland pin moves no verdict on the anchor data. `docker build` itself stays
unexercised, and the notes say so.

Live crossover through that container, gpt-5.5 -- the one published row with
released rollouts, and both system prompts are byte-identical to the released
ones: 96.4 / 30.4 / 44.6 / 98.2 against a published 100.0 / 28.6 / 50.0 / 89.3.
Three cells sit inside sampling noise; NN is +8.9 at z~2.0. All 19 per-task
disagreements were attributed by re-grading upstream's OWN stored reply through
the same container, which reproduces upstream's verdict and failure class 19/19
while our reply text differed 19/19 -- the divergence is the model, not the port.

The status test is rewritten rather than deleted: it now pins that a `stable`
whose notes stop naming the containerized anchor fails, so promoting the status
and dropping the evidence cannot be separate edits.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(tasks): address the QuoteBench re-review findings

Eight findings from a second review pass. No scoring behaviour changes: the
224-execution anchor still reproduces upstream's recorded GNU verdicts on both
axes -- through the HTTP path, and now also without a server.

**The anchor becomes a CI gate.** It was enforced by hand only:
`tests/acceptance/quotebench/` skips when nothing is listening, so what CI
actually ran was 56 oracles under the `raw` contract plus string-level
`transport()` assertions. None of that grades a real model reply, exercises the
nested transport end to end, or touches the failure taxonomy. The new
`tests/unit/vendor/code_evaluator/test_exec_quotebench_anchor.py` replays the
whole 224-execution crossover grid through `execute_quotebench` -- stdlib plus
the vendored package, no `fastapi`, ~0.7s -- reading the acceptance fixture
rather than copying it, so there is still one arm file and one hash pin.
Measured rather than asserted: a wiring bug that ignored `contract` and graded
every nested sample as raw passes the entire suite green today, and lands at
158/224 against the grid.

**Per-tier and per-scenario rates now share the headline's denominator policy.**
They divided by finals only while the headline divided by requested, so with
`fails > 0` every row read HIGHER than the score above it -- and `feedback`
raises on an evaluator or protocol fault, so a partial outage is exactly what
produces failed samples, and exactly when someone reads those rows. A failure
is now charged to its own tier and scenario; one that lost its `raw_sample` is
charged only to the headline, rather than pooled into an axis it never had.

Also:

- `scenarios_digest()` is cached. It re-read and re-hashed three files on every
  verdict, from the event loop -- `evaluate` is `async def`, which is the same
  hazard the `asyncio.to_thread` call two functions away exists to avoid.
- An unknown `task_id` names the field and the frozen core's size instead of
  reaching `msg` as a bare `'no-such/task'`. The handler unwraps
  `KeyError.args[0]`, since `str()` on a `KeyError` is the repr of its argument
  and was re-quoting the sentence.
- The acceptance reachability probe asks for `/quotebench/digest` rather than
  `/health`. The latter is source-agnostic by design, so any unrelated service
  bound to the port defeated the skip and the replay died partway through on a
  404 that read like a grading failure.
- The unit tests no longer inherit `SIEVAL_CODE_EVAL_API` from the environment.
  Exporting it -- which the acceptance instructions tell you to do -- turned the
  handshake URL assertion red for a reason it did not name.
- `VENDORED.md` records that the flat `ResourceMetrics` widens every other
  source's response: the six task modules that bucket unrecognised fields into a
  `resources` catch-all (HumanEval x2, LiveCodeBench x2, MBPP, MultiPL-E) gain
  four always-null keys in every rollout record. No score moves, and each
  alternative (per-source model, `response_model_exclude_none`) breaks something
  else.
- `tests/README.md` records the `tests/unit/vendor/` carve-out and scopes it to
  patches `VENDORED.md` marks *in-tree by decision*, so these tests are not
  removed the way the earlier upstream-bound ones were.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* docs(code-evaluator): reconcile the two shell routes now that NL2SH-ALFA landed

This PR said it owed this: "Neither is merged yet — whichever lands second
should reconcile the two rather than grow a third shape." NL2SH-ALFA (#139)
landed first, so QuoteBench is second and the debt is due.

Reconciling does NOT mean merging them. The split is forced by the benchmarks,
not chosen: `/shell-evaluations` returns facts because deciding Bash equivalence
needs an embedding model and the service holds no credentials, while
`quotebench` returns a verdict because an exact final-state comparison is
decidable in-process. One is stateful and pinned to five prepared images at
`--workers 1`; the other is stateless with a per-attempt fixture and is safe in
parallel. Folding either into the other would cost exactly the property that
makes it work. So VENDORED.md gains a table of the two and a rule for picking
between them, phrased as a row to match rather than a column to add.

One concrete interaction, not just prose: `quotebench` is a FOURTH route that
needs `/tmp`, which the NL2SH images delete on their first `git clean -fd`.
That entry listed three (`exec_py_code`, `exec_js`, `exec_ts`) because it was
written before this source existed. `run_attempt` calls `mkdtemp` per attempt,
so on those images grading raises before any command runs — loud, but it reads
as a broken grader rather than as a route on the wrong image.

Also corrects the `tests/unit/vendor/` carve-out added earlier in this branch.
It claimed an upstream-bound patch keeps its tests in `scitix/code-evaluator`;
NL2SH-ALFA in fact keeps 26 of them in `vendor/code-evaluator/tests/`, a third
location that did not exist when the carve-out was written. The note now states
both locations and the trade between them — tests that travel with the code are
outside CI's collection, which is the price of their travelling.

Verified on the rebased tree rather than assumed: all five routes are live on
one merged server (`/shell-evaluations` answers 422, not 404), the digest is
unchanged, and the 224-execution anchor still replays 224/224 through the merged
HTTP path. NL2SH's own 53 tests (27 task + 26 vendored) pass.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
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.

1 participant