Skip to content

feat(evals): run the SPACE text benchmarks through the agent-eval path - #3414

Open
mc856 wants to merge 13 commits into
dimensionalOS:cc/feat/frozen-qa-evalfrom
mc856:feat/space-qa-eval
Open

feat(evals): run the SPACE text benchmarks through the agent-eval path#3414
mc856 wants to merge 13 commits into
dimensionalOS:cc/feat/frozen-qa-evalfrom
mc856:feat/space-qa-eval

Conversation

@mc856

@mc856 mc856 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Contribution path

Problem

The #3378 agent-eval framework scores agents only against internally authored suites with local
oracles. #3399 asks for an external benchmark — Apple's SPACE (spatial cognition) — wired through
the same execution path, so that an unmodified official scorer grades what a DimOS agent actually
does. The integration must not redistribute any SPACE content (ND-licensed data), and the seams it
adds should be reusable for the next external benchmark rather than SPACE-specific.

Solution

One seam. SPACE's official code keeps everything it already owns — question loading, the
evaluation loop, answer parsing, scoring — and upstream space.evaluate_qas.main writes the
only results.json that counts. DimOS enters at exactly one point: DimosQAAgent registers in
SPACE's own agent registry and replaces the model call — nothing else — with an
execute_single_case run (the #3378 path, python_exec on an empty kernel). Local
cross-checks can only refuse a run, never alter a score.

Agent-eval contract — three new generic case kinds. No SPACE naming anywhere, so a future
external benchmark can reuse them unchanged:

  • source none — nothing to materialize (no recording to replay);
  • task verbatim_prompt — the benchmark's prompt passes through untouched (any injected
    instruction would fight SPACE's own JSON format directive);
  • validator external_evaluator — no local oracle; scoring is delegated and recorded as
    benchmark + revision.

One change is not purely additive and is called out here deliberately:
CompactEvalResult.recording/progress relax from required to Optional — environment-less
cases have neither. Old result files still parse; new files write null there. Whether this
should bump schema_version is left as a maintainer decision (see below).

Scope. SPACE breaks down into 10 text QA tasks, 14 image QA tasks, and 4 interactive
task families
; the image tasks need an image input channel this eval path does not have yet,
and the interactive ones need a stepwise multi-turn environment loop rather than one-shot QA.
So this PR integrates exactly the 10 text tasks — the other two slices ship as design only
(scope rationale in the #3399 comment; roadmap write-up available on request).

space_qa package — the SPACE-specific side. A small BenchmarkAdapter protocol
(iter_items / to_case / score) turns benchmark rows into eval cases. BenchmarkItem has
no answer field, so the answer key cannot enter an EvalCase by construction. Subset drawing
follows the sampling design below — whole answer-order groups on a recorded seed. A generated
grid suite is the second adapter implementation and the zero-data test tier.

Data & provenance — fetched at run time, never committed. Nothing SPACE-derived enters the
repository. On first run, the tool clones apple/ml-space-benchmark at a pinned revision and
downloads the official 3.6 GB data release into the user cache, checking the tarball's SHA256;
a checkout with local edits, or off the pin, is refused. Every run writes a manifest naming
exactly what it consumed: task, seed (given or drawn), group count, the upstream row ordinals,
a SHA256 per question, digests for the archive and for the question file the run actually read,
and the dimos revision — recorded only when the package really runs from this source tree, so
an installed copy under some unrelated checkout cannot get signed with that checkout's HEAD.
Anyone holding the official release can re-identify from the manifest exactly which items a run
graded; the outputs sit beside it — per-question records and upstream's own results.json.

What SPACE ships, and what a run samples. Each of the ten tasks is a pool of stimuli,
and SPACE counterbalances every stimulus into four answer-order variants — four consecutive
rows in the released question file, the correct answer rotated into a different slot in each.
The pools are uneven: from 30 stimuli (MapSketchingBEVText) to 150
(DirectionEstimationBEVText), 755 stimuli / 3,020 questions across the ten (per-task
sizes in the results table below). Scoring the whole release is tens of hours of agent time at
the observed cadence, so a run samples: --groups N sets how many stimuli it draws (4N
questions), --seed sets which ones — omitted, a seed is drawn, printed beside the score, and
written to the manifest, so every run is reproducible from its record; passed explicitly, it
turns two runs into a comparison. Upstream itself has no sampling — evaluate_qas scores
every row it is handed — and --groups <pool size> reproduces exactly that.

Two properties of the release, both re-checkable by anyone holding it, decided how to sample:

  • Whole groups, never rows. In the three BEV tasks the answer slot inside every group
    follows the same ①→④ order — 600 of 600, 540 of 540, 120 of 120 rows — so a row-wise subset
    has its answer in one fixed slot, and its score cannot be told apart from a slot preference.
    Whole groups keep a stimulus's four variants together and make the answer-position
    distribution flat by construction; they also fix the unit of evidence — 32 rows are 8
    independent stimuli, and inference below is per stimulus.
  • A seeded random draw, not the first N. Rows are blocked by scene (Direction's 150
    stimuli are 30 scenes × 5 consecutive groups), so the first 8 groups of that file cover 2
    scenes; a seeded random 8 covers ~7. The seed is a budget knob, not part of the benchmark.

Results — an integration-validation profile, not a controlled benchmark. All ten tasks
were scored at the same subset size — --groups 8 --seed 20260808: 8 stimuli × 4 variants =
32 questions per task, 320 questions, 80 of the release's 755 stimuli (10.6%) — model
gpt-5.6-luna through this path. Accuracy, exactly as SPACE's unmodified scorer wrote it to
results.json (infrastructure failures count as misses): 90.94% (291/320) — macro and
micro alike, since every task has the same n. The paper's GPT-4o macro-mean on the same ten tasks is
59.8 — context for scale, not a comparison this profile can support: the paper's baselines
are bare no-tool chat models while this path carries python_exec, and these runs accumulated
over the course of development, against more than one locally configured OpenAI-compatible
endpoint (the full accounting is in the companion report).

Task stimuli sampled questions Accuracy (SPACE scorer) GPT-4o (paper)
SAtt_text 8 of 100 32 100.00 98.8
MPFB_text 8 of 50 32 100.00 50.5
MapSketchingBEVText 8 of 30 32 96.88 46.7
CBTT_text 8 of 50 32 96.88 82.5
SAdd_text 8 of 50 32 93.75 93.5
MRT_text 8 of 40 32 93.75 41.9
DistanceEstimationBEVText 8 of 135 32 93.75 33.3
PTT_text 8 of 100 32 81.25 55.5
JLO_text 8 of 50 32 81.25 66.5
DirectionEstimationBEVText 8 of 150 32 71.88 28.7

Completed: 315 of 320 — two transient HTTP 503s and three sessions that ended
terminated. Accuracy among completed replies: 92.38% (291/315). Malformed completed
replies: 0 — the upstream parser read an integer out of every reply that arrived.
Direction carries two of the five losses: 23 of its 30 completed answers were correct (76.67%).
Three observations worth carrying out of the per-item data (all of it in the companion report,
available on request):

  • JLO_text's six misses are one stimulus answered wrong on all four of its answer orders plus
    two single-variant misses — a stable per-stimulus failure, not slot noise.
  • Tool use tracks question shape: one hundred questions across five tasks made not a single
    python_exec call, while the three BEV tasks account for 119 of the profile's 144.
  • Removing the tool changed nothing measurable on the one task with enough errors to look at:
    the same 32 Direction questions, same channel, behind a local patch not in this PR — 71.88
    with the tool, 71.88 without, 37% faster without. Read it as no benefit detected, not no
    benefit
    (8 stimulus groups, sign test p = 1.0; the two runs differ in more than the tool —
    caveats in the companion report).

Coverage is the boundary on every claim above. Eight stimuli per task is a quarter of
MapSketching's pool and a nineteenth of Direction's; nothing here is a full-benchmark
result. --groups is the whole knob — the subset a task is given is everything that task ran,
and full coverage is a spending decision, not a code change.

Known limitations (each disclosed on purpose; the first one wants your read):

  • python_exec runs unsandboxed on the host that also holds the answer key on disk; dropping
    the two pointer env vars is defence in depth, not isolation. Every completed transcript under
    the scored-run tree was audited — 419 of them, carrying 199 python_exec calls: zero
    filesystem, environment, subprocess, or network access, no imports beyond numpy, math,
    itertools, collections. That certifies these runs, not the runner. The structural fix is
    a per-case tool surface (also what a paper-comparable no-tool run and vision need); until
    then the benchmark leans on after-the-fact audit — whether that interim is acceptable is
    your call.
  • The system prompt is chosen by task kind alone; unsupported source/task/validator
    combinations are not rejected at construction. A capability preflight is the right general
    mechanism and is left as a follow-up rather than grown ad hoc here.
  • A run where some questions fail on infrastructure still exits 0; the summary prints
    Infra fails N of M next to the accuracy it distorts, and an all-failures run is refused. A
    bounded per-question retry (original failure kept in the record) would re-ask exactly the
    five questions this profile lost; left out because it changes what a score means.
  • Two bookkeeping asymmetries, both stated in the docs: the manifest carries no dirty flag for
    the dimos tree (the SPACE checkout, by contrast, is refused outright when dirty), and the
    run-root cases.jsonl ledger is written only after the three consistency gates — a refused
    run keeps its per-question records; only the aggregated file is missing.

One decision requested

  • schema_version: this PR changes CompactEvalResult.recording/progress from required to
    optional
    (environment-less cases have neither, so new result files may hold null there —
    a value the 1.0 shape never produced). Kept at 1.0 for now — readers that treat the two
    fields as optional stay compatible, and this PR discloses the change here rather than hiding
    it. Say the word and I will bump it to 1.1 in this PR.

Roadmap

Both remaining SPACE slices keep the one-seam invariant.

Vision is next, and is the prerequisite for the VQA benchmark that follows SPACE. One more
task kind (multimodal_prompt, parts instead of a string); the Pi CLI already accepts image
files as positional @file arguments, so the change concentrates in the transport layer. Once
that channel exists, SPACE's own *_vision variants become a zero-integration regression load
for it — same adapter, same scorer, different presentation.

Interactive (route retracing, shortcut discovery, MCT, CSWM) needs one decision before any
code
: expose the benchmark environment as a tool inside one persistent session, or drive one
agent call per step from the upstream script. The trade-offs are in the companion report — I
would rather argue them against your pick than pre-empt it.

Smaller follow-ups — a configurable tool surface, the bounded infra retry, capability
preflight, a wording pass on the shared python_exec description — will each get their own
issue.

How to Test

Offline (no SPACE data, no network, no API key):

uv run pytest dimos/benchmark/space_qa dimos/benchmark/agent_eval dimos/agents/test_code_policy_core.py

The feature itself (first run clones the pinned source and downloads the 3.6 GB release into the
user cache):

uv sync --extra space
npm ci --prefix packages/pi-code-policy-extension
npm run build --prefix packages/pi-code-policy-extension
export OPENAI_API_KEY=...

One command, four sizes:

dimos eval space --task SAtt_text --groups 1         # smoke: one stimulus, 4 questions — checks the wiring end to end
dimos eval space --task SAtt_text                    # default: 8 groups (32 questions), seed drawn and recorded
dimos eval space --task SAtt_text --seed 20260808    # pinned subset: reproduces this PR's SAtt_text run
dimos eval space --task SAtt_text --groups 100       # the whole pool: upstream's own full evaluation, no sampling

Timing, measured on the runs behind the results: at --workers 2 a 32-question subset ranged
from about a minute of wall clock (SAtt_text) to about half an hour
(DirectionEstimationBEVText — the long BEV prompts dominate), and the full ten-task profile
came to roughly an hour and a half. The smoke and the offline tier are seconds-scale.

Scores print from upstream's own results.json; the run directory holds the manifest,
per-question records, and provenance. Docs: docs/capabilities/agents/space-benchmark.md.

AI assistance

Claude Code (Fable 5 and Opus 5) wrote most of the implementation under my direction, and the
branch went through several adversarial review rounds with Claude Code and Codex before
submission. I can explain every line in this diff.

Checklist

  • I have read and approved the CLA.

mc856 added 9 commits August 9, 2026 17:46
Contract (models.py): three new discriminated-union members --
source "none" (nothing to materialize), task "verbatim_prompt"
(prompt handed to the agent untouched), validator "external_evaluator"
(scoring delegated to an external suite, recorded as benchmark +
revision). CompactEvalResult.recording/progress relax to optional:
cases without a frozen environment have neither.

Execution (single_case.py): kind-aware dispatch at oracle preflight,
materialization, server environment, prompt assembly, system prompt,
scoring, and both result constructors. EmptyEnvironment keeps
python_exec on an empty kernel (code_policy_core.py). CLI rendering
handles environment-less results (cli/eval.py).

Existing tests unchanged and green; mypy union-attr clean.
… consumer

BenchmarkAdapter protocol (name/revision + iter_items/to_case/score) is the
seam an external benchmark plugs into; the answer key never enters an
EvalCase -- BenchmarkItem has no answer field by construction. Group-aware
sampling takes whole answer-order groups (4 permutations per stimulus) so
subsets stay balanced. The SPACE text-task registry records upstream row
counts; a generated grid suite is the second adapter implementation and the
zero-data default test tier.
Source and data are fetched at run time to the user cache -- a pinned
checkout of apple/ml-space-benchmark (564e4393) and the official release
tarball (SHA256-verified); nothing SPACE-derived enters the repository.
DimosQAAgent registers in SPACE's own registry and keeps the upstream
question, parsing, and scoring code untouched: the only replaced step is
the model call, which now runs execute_single_case. Every question writes
a per-case record, and the run fails loudly if those records disagree
with the official results.json. `dimos eval space --task <name>` is the
documented entry point; the `space` extra declares what `import space`
needs beyond dimos itself.
Re-running with the same --output used to spend the full set of agent
sessions and only then fail on the duplicate results.json. The guard sits
before source, data, and any network or agent work.
Preflight refuses a run that could never score before anything is
fetched (bounds, API key, extra modules, Pi build), and a run where no
question was ever answered no longer reports 0% as if the model had
scored it; the summary counts infra failures next to the accuracy they
distort. The downloader follows the repo HTTP rule (requests, with
timeouts) and the cached release is verified against its provenance
record on every run, which is now written before the release it
describes. The adapter score path runs on every question as a
consistency gate over the official predictions. Records that cannot be
written no longer kill the worker pool. The kernel for environment-less
cases no longer inherits the run- and data-directory variables, and the
synthetic suite default seed no longer lets an ordinal predict its
answer slot. Docstrings and the docs page claim exactly what the code
does.
@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.71243% with 114 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
dimos/benchmark/space_qa/test_upstream.py 35.71% 36 Missing ⚠️
dimos/benchmark/space_qa/run.py 85.11% 22 Missing and 3 partials ⚠️
dimos/benchmark/space_qa/source.py 77.77% 13 Missing and 1 partial ⚠️
dimos/benchmark/space_qa/suite.py 76.59% 11 Missing ⚠️
dimos/benchmark/space_qa/agent.py 93.75% 4 Missing and 4 partials ⚠️
dimos/benchmark/space_qa/manifest.py 88.40% 4 Missing and 4 partials ⚠️
dimos/benchmark/agent_eval/single_case.py 94.11% 1 Missing and 1 partial ⚠️
dimos/benchmark/space_qa/data.py 98.33% 1 Missing and 1 partial ⚠️
dimos/benchmark/space_qa/synthetic.py 97.53% 2 Missing ⚠️
dimos/benchmark/space_qa/test_agent.py 99.20% 2 Missing ⚠️
... and 3 more
@@                    Coverage Diff                     @@
##           cc/feat/frozen-qa-eval    #3414      +/-   ##
==========================================================
+ Coverage                   76.26%   76.62%   +0.35%     
==========================================================
  Files                        1211     1231      +20     
  Lines                      116918   119052    +2134     
  Branches                    10512    10635     +123     
==========================================================
+ Hits                        89164    91220    +2056     
- Misses                      24670    24757      +87     
+ Partials                     3084     3075       -9     
Flag Coverage Δ
OS-ubuntu-24.04-arm 71.02% <94.71%> (+0.44%) ⬆️
OS-ubuntu-latest 72.78% <94.71%> (+0.40%) ⬆️
Py-3.10 72.77% <94.71%> (+0.40%) ⬆️
Py-3.11 72.77% <94.71%> (+0.40%) ⬆️
Py-3.12 72.77% <94.71%> (+0.41%) ⬆️
Py-3.13 72.77% <94.71%> (+0.40%) ⬆️
Py-3.14 72.78% <94.71%> (+0.41%) ⬆️
Py-3.14t 72.77% <94.71%> (+0.40%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
dimos/agents/code_policy_core.py 85.09% <100.00%> (+6.74%) ⬆️
dimos/agents/test_code_policy_core.py 100.00% <100.00%> (ø)
dimos/benchmark/agent_eval/models.py 96.15% <100.00%> (+1.95%) ⬆️
dimos/benchmark/agent_eval/test_models.py 100.00% <100.00%> (ø)
dimos/benchmark/space_qa/adapter.py 100.00% <100.00%> (ø)
dimos/benchmark/space_qa/sampling.py 100.00% <100.00%> (ø)
dimos/benchmark/space_qa/tasks.py 100.00% <100.00%> (ø)
dimos/benchmark/space_qa/test_data.py 100.00% <100.00%> (ø)
dimos/benchmark/space_qa/test_sampling.py 100.00% <100.00%> (ø)
dimos/benchmark/space_qa/test_source.py 100.00% <100.00%> (ø)
... and 17 more
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

mc856 added 4 commits August 9, 2026 18:49
`dimos_revision` asked git for a HEAD from the directory this package sits in,
and git answers from the nearest repository at or above that, so an installed
copy under an unrelated checkout would have signed the manifest with that
checkout's commit. It now returns nothing unless the toplevel git names is this
source tree, and a git that hangs no longer takes the run down with it. The docs
row says what the field still cannot see: uncommitted changes here, unlike the
SPACE checkout, which a run refuses outright when its tree is dirty.

The run claims its --output directory beside the preflight instead of beside the
first write, so a path nothing can create is refused before the 3.6 GB download
rather than after it.

Three tests claimed more than they checked. The stub upstream parser skipped the
integer coercion the real one does, so a parametrized case asserted an outcome
no run produces; the ledger's atomicity test passed for a plain write; and
nothing pinned the four-consecutive-rows-per-stimulus layout that whole-group
sampling rests on, now asserted against the release for every registered task.

The evaluation page follows the command it documents, which grew a third
rendering for externally scored cases, and the benchmark page joins the docs
nav. Sampling docs say how to ask for a whole task, and that anything smaller
reports one draw.
`--seed` was required so two runs of one command asked about the same stimuli,
which is what a comparison needs. But it made the ordinary case -- score this
task -- carry a flag whose value nobody had a reason to choose, and it pushed
everyone toward whatever number they typed last, so a task was always measured
on one subset of itself.

The seed is now drawn when it is absent, printed beside the score as `(drawn)`,
and written to the manifest as it already was, so the run is reproducible from
its own record. Pinning it is still what turns two runs into a comparison, and
the sampling docs say that where they say what --groups does to coverage.

The draw happens in the CLI rather than in run_space_task, which still takes an
integer and stays deterministic under test.
git's toplevel answer was split on any whitespace, so a checkout at a path
with a space in it failed the two-line check and lost its dimos revision --
on exactly the kind of machine the guard was written for. The answer now
splits by lines.

mkdir proves nothing about a directory that already exists, so the
pre-download claim let an unwritable --output through to the far side of the
3.6 GB fetch; claiming now writes and removes a probe file. The revision
guard's hanging-git path, half of its motivation, gets the test it was
missing, and one overlong docs line is rewrapped.
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