From 9df50eacc7f4f0c41ef206c4c267a7bd216af277 Mon Sep 17 00:00:00 2001 From: Varun Ursekar Date: Sun, 2 Aug 2026 22:40:02 -0700 Subject: [PATCH 01/16] Add interpret module: harbor adapter and deterministic edit decomposition Phases 1 and 2 of an interpretability pipeline for optimization runs. No model is consulted anywhere in this commit; everything here is reproducible and diffable. Source artifacts enter through an adapter that canonicalises them into `models`, so downstream code never sees a harbor path, a tarball, or a git object, and a second producer of runs means a new adapter and nothing else. `artifacts/` is forbidden from importing `edits/` or `labeling/` for that reason. The unit of analysis is a symbol-scoped edit, not a candidate. A single commit routinely bundles a bug fix with unrelated tuning under one subject line, so labelling per candidate assigns one category to several distinct modifications and buries the interesting one -- on a real candidate here, +908/-142 across one commit decomposes into 57 edits. Locus is derived from the syntax tree rather than git's hunk header, which reports the enclosing class for a method and so attributes a one-line shell-exec fix to the whole agent. Module-level bindings are split further by target name and value shape, because that bucket otherwise holds a system prompt, a tool table and a dozen tuning constants as one undifferentiated blob. Caching is content-addressed and split in two: unpacking a session archive costs minutes and hundreds of megabytes, while a label costs a fraction of a cent, so revising a taxonomy later re-labels without re-extracting. Edit ids are derived from edit content, not position, so cached labels survive re-extraction and reordering. Scope selection deliberately does not use harbor's `reportable` flag, which requires error_rate == 0.0 exactly while every build config sets a threshold of 0.1 -- that discards good runs that lost a single case to a platform hiccup. Co-Authored-By: Claude Opus 5 (1M context) --- vero/pyproject.toml | 3 + vero/src/vero/interpret/__init__.py | 26 +++ vero/src/vero/interpret/artifacts/__init__.py | 5 + vero/src/vero/interpret/artifacts/base.py | 53 ++++++ .../interpret/artifacts/harbor/__init__.py | 5 + .../interpret/artifacts/harbor/adapter.py | 153 +++++++++++++++++ .../vero/interpret/artifacts/harbor/repo.py | 77 +++++++++ .../interpret/artifacts/harbor/session.py | 79 +++++++++ vero/src/vero/interpret/cache.py | 94 ++++++++++ vero/src/vero/interpret/edits/__init__.py | 5 + vero/src/vero/interpret/edits/decompose.py | 148 ++++++++++++++++ vero/src/vero/interpret/edits/locus.py | 131 ++++++++++++++ vero/src/vero/interpret/models.py | 161 ++++++++++++++++++ vero/tests/test_interpret_locus.py | 75 ++++++++ 14 files changed, 1015 insertions(+) create mode 100644 vero/src/vero/interpret/__init__.py create mode 100644 vero/src/vero/interpret/artifacts/__init__.py create mode 100644 vero/src/vero/interpret/artifacts/base.py create mode 100644 vero/src/vero/interpret/artifacts/harbor/__init__.py create mode 100644 vero/src/vero/interpret/artifacts/harbor/adapter.py create mode 100644 vero/src/vero/interpret/artifacts/harbor/repo.py create mode 100644 vero/src/vero/interpret/artifacts/harbor/session.py create mode 100644 vero/src/vero/interpret/cache.py create mode 100644 vero/src/vero/interpret/edits/__init__.py create mode 100644 vero/src/vero/interpret/edits/decompose.py create mode 100644 vero/src/vero/interpret/edits/locus.py create mode 100644 vero/src/vero/interpret/models.py create mode 100644 vero/tests/test_interpret_locus.py diff --git a/vero/pyproject.toml b/vero/pyproject.toml index fb743449..6da8e97d 100644 --- a/vero/pyproject.toml +++ b/vero/pyproject.toml @@ -29,6 +29,9 @@ harbor = [ claude = [ "claude-agent-sdk>=0.1.56", ] +interpret = [ + "openai>=1.0", +] optimize = [ "async-lru>=2.0.5", "beautifulsoup4>=4.14.2", diff --git a/vero/src/vero/interpret/__init__.py b/vero/src/vero/interpret/__init__.py new file mode 100644 index 00000000..72261d06 --- /dev/null +++ b/vero/src/vero/interpret/__init__.py @@ -0,0 +1,26 @@ +"""Interpretability analysis over optimization runs. + +Source artifacts are canonicalised by an adapter (`artifacts`), split into +symbol-scoped edits deterministically (`edits`), labelled with a model +(`labeling`), and aggregated (`analysis`). Only `labeling` is non-deterministic. +""" + +from vero.interpret.models import ( + Candidate, + CellRef, + Corpus, + Edit, + EvalRecord, + SymbolKind, + Trajectory, +) + +__all__ = [ + "Candidate", + "CellRef", + "Corpus", + "Edit", + "EvalRecord", + "SymbolKind", + "Trajectory", +] diff --git a/vero/src/vero/interpret/artifacts/__init__.py b/vero/src/vero/interpret/artifacts/__init__.py new file mode 100644 index 00000000..36b309e2 --- /dev/null +++ b/vero/src/vero/interpret/artifacts/__init__.py @@ -0,0 +1,5 @@ +"""Source adapters: raw run artifacts in, canonical models out.""" + +from vero.interpret.artifacts.base import SourceAdapter, available, get, register + +__all__ = ["SourceAdapter", "available", "get", "register"] diff --git a/vero/src/vero/interpret/artifacts/base.py b/vero/src/vero/interpret/artifacts/base.py new file mode 100644 index 00000000..7a7aeaf5 --- /dev/null +++ b/vero/src/vero/interpret/artifacts/base.py @@ -0,0 +1,53 @@ +"""The adapter boundary. + +An adapter takes raw run artifacts from one producer and canonicalises them into +`models`. Everything downstream — edit decomposition, labelling, analysis — consumes +only the canonical types, so supporting a second producer of optimization runs is a +new adapter and nothing else. + +Adapters do two things and no more: find runs under the roots they are given, and +load one run. They must not interpret, classify, or score anything; that judgement +belongs in `edits/` (deterministic) and `labeling/` (model-assisted), which is what +keeps the boundary useful. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Iterable, Protocol, runtime_checkable + +from vero.interpret.models import CellRef, Trajectory + + +@runtime_checkable +class SourceAdapter(Protocol): + """Canonicalises one producer's run artifacts.""" + + name: str + + def discover(self, roots: Iterable[Path]) -> list[CellRef]: + """Find every run under these roots. Cheap: no archives are opened.""" + ... + + def load(self, ref: CellRef) -> Trajectory: + """Materialise one run. May be expensive; results are cached by the caller.""" + ... + + +_REGISTRY: dict[str, SourceAdapter] = {} + + +def register(adapter: SourceAdapter) -> SourceAdapter: + _REGISTRY[adapter.name] = adapter + return adapter + + +def get(name: str) -> SourceAdapter: + if name not in _REGISTRY: + known = ", ".join(sorted(_REGISTRY)) or "none registered" + raise KeyError(f"unknown source adapter {name!r} (known: {known})") + return _REGISTRY[name] + + +def available() -> list[str]: + return sorted(_REGISTRY) diff --git a/vero/src/vero/interpret/artifacts/harbor/__init__.py b/vero/src/vero/interpret/artifacts/harbor/__init__.py new file mode 100644 index 00000000..b902360b --- /dev/null +++ b/vero/src/vero/interpret/artifacts/harbor/__init__.py @@ -0,0 +1,5 @@ +"""Adapter for `vero harbor run` job trees.""" + +from vero.interpret.artifacts.harbor.adapter import HarborAdapter, build + +__all__ = ["HarborAdapter", "build"] diff --git a/vero/src/vero/interpret/artifacts/harbor/adapter.py b/vero/src/vero/interpret/artifacts/harbor/adapter.py new file mode 100644 index 00000000..69930f00 --- /dev/null +++ b/vero/src/vero/interpret/artifacts/harbor/adapter.py @@ -0,0 +1,153 @@ +"""Harbor adapter: raw job artifacts in, canonical `Trajectory` out. + +Layout it expects, which is what `vero harbor run` writes: + + ///jobs//task__*/verifier/ + finalization.json the shipped candidate and its held-out reward + session.tar.gz the candidate repo and the sidecar evaluation records + +`` may be a benchmark directory or a tree of them; discovery handles both, so +callers can point at one benchmark or a whole runs/ tree without knowing the depth. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Iterable + +from vero.interpret.artifacts.base import register +from vero.interpret.artifacts.harbor import session as session_mod +from vero.interpret.artifacts.harbor.repo import CandidateRepo +from vero.interpret.cache import Cache +from vero.interpret.models import Candidate, CellRef, EvalRecord, Trajectory + +# `shipped AND error_rate == 0.0 AND tokens > 0` is the flag harbor's own tooling +# uses, but every benchmark's build config sets error_rate_threshold: 0.1, so the +# exact-zero test discards good runs that lost one case to a platform hiccup. The +# benchmark's own threshold is the defensible line. +DEFAULT_MAX_ERROR_RATE = 0.1 + + +class HarborAdapter: + name = "harbor" + + def __init__(self, cache: Cache) -> None: + self.cache = cache + + # -- discovery ------------------------------------------------------------ + + def discover(self, roots: Iterable[Path]) -> list[CellRef]: + refs: list[CellRef] = [] + seen: set[tuple[str, str]] = set() + for root in roots: + root = Path(root) + if not root.is_dir(): + continue + for final in sorted(root.glob("**/jobs/*/task__*/verifier/finalization.json")): + # ...///jobs//task__*/verifier/ + job_dir = final.parents[2] + cell_dir = final.parents[4] + benchmark = final.parents[5].name + identity = (benchmark, cell_dir.name) + if identity in seen: + continue + seen.add(identity) + refs.append( + CellRef( + source=self.name, + benchmark=benchmark, + cell=cell_dir.name, + root=str(root), + cell_dir=str(cell_dir), + job_dir=str(job_dir), + ) + ) + return refs + + # -- loading -------------------------------------------------------------- + + def load(self, ref: CellRef) -> Trajectory: + cell_dir = Path(ref.cell_dir) + finals = sorted(cell_dir.glob("jobs/*/task__*/verifier/finalization.json")) + if not finals: + return Trajectory(ref=ref) + + # Last job wins: a cell re-run in place leaves the earlier attempt behind. + final_path = finals[-1] + final = json.loads(final_path.read_text()) + metrics = (final.get("reward_metrics") or {}).get("reward", {}) or {} + shipped_sha = ((final.get("candidate") or {}).get("id") or "") + + traj = Trajectory( + ref=ref, + reward=(final.get("rewards") or {}).get("reward"), + baseline_reward=(final.get("baseline_rewards") or {}).get("reward"), + error_rate=metrics.get("error_rate"), + total_tokens=metrics.get("inference_total_tokens"), + ) + + archive = final_path.parent / "session.tar.gz" + if not archive.is_file(): + return traj + root = session_mod.unpack(archive, self.cache) + if root is None: + return traj + + repo_dir = session_mod.find_repo(root) + if repo_dir is not None: + traj.candidates = self._candidates(CandidateRepo(repo_dir), shipped_sha) + traj.evaluations = self._evaluations(session_mod.read_evaluations(root)) + return traj + + def _candidates(self, repo: CandidateRepo, shipped_sha: str) -> list[Candidate]: + out: list[Candidate] = [] + for position, (sha, subject, body) in enumerate(repo.log()): + stats = repo.numstat(f"{sha}^", sha) if position else {} + out.append( + Candidate( + sha=sha, + parent_sha=repo.parent(sha), + position=position, + subject=subject, + body=body[:4000], + files=repo.files(sha), + insertions=sum(a for a, _ in stats.values()), + deletions=sum(r for _, r in stats.values()), + tree_sha=repo.tree_sha(sha), + is_seed=position == 0, + is_shipped=bool(shipped_sha) and sha.startswith(shipped_sha[:12]), + ) + ) + return out + + @staticmethod + def _evaluations(records: list[dict]) -> list[EvalRecord]: + out: list[EvalRecord] = [] + for doc in records: + request = doc.get("request") or {} + report = doc.get("report") or {} + eval_set = request.get("evaluation_set") or {} + metrics = report.get("metrics") or {} + sha = ((request.get("candidate") or {}).get("id") or "") + partition = eval_set.get("partition") + score = metrics.get("score") + if not (sha and partition and score is not None): + continue + out.append( + EvalRecord( + candidate_sha=sha, + partition=partition, + score=score, + error_rate=metrics.get("error_rate"), + selection_kind=(eval_set.get("selection") or {}).get("kind"), + n_attempts=(request.get("limits") or {}).get("n_attempts"), + started_at=report.get("started_at"), + finished_at=report.get("finished_at"), + ) + ) + return out + + +def build(cache: Cache) -> HarborAdapter: + return register(HarborAdapter(cache)) diff --git a/vero/src/vero/interpret/artifacts/harbor/repo.py b/vero/src/vero/interpret/artifacts/harbor/repo.py new file mode 100644 index 00000000..85aef3ed --- /dev/null +++ b/vero/src/vero/interpret/artifacts/harbor/repo.py @@ -0,0 +1,77 @@ +"""Read-only git access to a candidate repository. + +Bare-repo reads only, via subprocess. No checkout ever happens: the analysis needs +trees and diffs, and materialising working copies for 100 cells would cost disk for +nothing. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +class CandidateRepo: + def __init__(self, git_dir: Path) -> None: + self.git_dir = Path(git_dir) + + def _run(self, *args: str) -> str: + out = subprocess.run( + ["git", "--git-dir", str(self.git_dir), *args], + capture_output=True, + text=True, + ) + return out.stdout + + def log(self) -> list[tuple[str, str, str]]: + """(sha, subject, body) oldest first, so index 0 is the seed. + + `--all` because a candidate chain can leave commits unreachable from any + branch head once the optimizer reaches back past them. + """ + raw = self._run("log", "--all", "--format=%H%x1f%s%x1f%b%x1e") + rows = [] + for record in raw.split("\x1e"): + record = record.strip("\n") + if not record: + continue + sha, subject, body = (record.split("\x1f") + ["", "", ""])[:3] + rows.append((sha, subject, body)) + rows.reverse() + return rows + + def tree_sha(self, sha: str) -> str: + return self._run("rev-parse", f"{sha}^{{tree}}").strip() + + def parent(self, sha: str) -> str | None: + out = self._run("rev-parse", f"{sha}^").strip() + return out or None + + def files(self, sha: str) -> list[str]: + return [ + f + for f in self._run("show", "--name-only", "--format=", sha).splitlines() + if f.strip() and "__pycache__" not in f + ] + + def show_file(self, sha: str, path: str) -> str: + return self._run("show", f"{sha}:{path}") + + def diff(self, a: str, b: str, path: str | None = None, context: int = 0) -> str: + args = ["diff", f"-U{context}", a, b, "--", path or ".", ":(exclude)*__pycache__*"] + return self._run(*args) + + def numstat(self, a: str, b: str) -> dict[str, tuple[int, int]]: + """path -> (added, removed). Binary files report as (0, 0).""" + out: dict[str, tuple[int, int]] = {} + raw = self._run("diff", "--numstat", a, b, "--", ".", ":(exclude)*__pycache__*") + for line in raw.splitlines(): + parts = line.split("\t") + if len(parts) != 3: + continue + add, rem, path = parts + out[path] = ( + int(add) if add.isdigit() else 0, + int(rem) if rem.isdigit() else 0, + ) + return out diff --git a/vero/src/vero/interpret/artifacts/harbor/session.py b/vero/src/vero/interpret/artifacts/harbor/session.py new file mode 100644 index 00000000..48202170 --- /dev/null +++ b/vero/src/vero/interpret/artifacts/harbor/session.py @@ -0,0 +1,79 @@ +"""Unpack the two things worth keeping out of a harbor `session.tar.gz`. + +These archives run to hundreds of megabytes, almost all of it agent transcripts and +container logs. Only the candidate git repository and the sidecar evaluation records +are needed here, so members are filtered on the way out and the result is cached by +the archive's own digest — re-running the pipeline never re-extracts. +""" + +from __future__ import annotations + +import hashlib +import json +import sys +import tarfile +from pathlib import Path + +from vero.interpret.cache import Cache + +_WANTED_DIR = "/candidates/repository.git/" +_WANTED_FILE = "evaluation.json" + + +def digest(path: Path, *, chunk: int = 1 << 20) -> str: + """Hash the archive itself, so the cache key is independent of where it sits.""" + h = hashlib.sha256() + with path.open("rb") as fh: + while block := fh.read(chunk): + h.update(block) + return h.hexdigest() + + +def unpack(archive: Path, cache: Cache) -> Path | None: + """Extract the wanted members, returning the session root.""" + key = digest(archive) + if (hit := cache.get_dir(key)) is not None: + return hit + + dest = cache.reserve_dir(key) + try: + with tarfile.open(archive) as tar: + members = [ + m + for m in tar.getmembers() + if _WANTED_DIR in m.name or m.name.endswith(_WANTED_FILE) + ] + if not members: + return None + # filter= is 3.12+; these are our own verifier's archives, so the guard is + # about running on 3.11 rather than about untrusted input. + if sys.version_info >= (3, 12): + tar.extractall(dest, members=members, filter="data") + else: + tar.extractall(dest, members=members) + except (tarfile.TarError, OSError): + return None + + cache.commit_dir(key) + return dest + + +def find_repo(session_root: Path) -> Path | None: + hits = list(session_root.glob("**/candidates/repository.git")) + return hits[0] if hits else None + + +def read_evaluations(session_root: Path) -> list[dict]: + """Every sidecar evaluation record, unaggregated. + + Repeats of the same candidate on the same partition are kept separate: collapsing + them into a per-partition map is exactly how a re-score silently overwrites the + earlier one, which hides the corpus's only direct measurement of scoring noise. + """ + out = [] + for path in session_root.glob("**/evaluations/*/evaluation.json"): + try: + out.append(json.loads(path.read_text())) + except (OSError, json.JSONDecodeError): + continue + return out diff --git a/vero/src/vero/interpret/cache.py b/vero/src/vero/interpret/cache.py new file mode 100644 index 00000000..d7d3e9ec --- /dev/null +++ b/vero/src/vero/interpret/cache.py @@ -0,0 +1,94 @@ +"""Content-addressed file cache, shared by artifact extraction and labelling. + +Two callers with very different economics use this: unpacking a session archive costs +minutes and hundreds of megabytes, while an LLM label costs a fraction of a cent. They +get separate namespaces so revising a taxonomy re-labels everything without +re-extracting anything — the failure mode that actually wastes an afternoon. + +Writes go to a temp file and are renamed into place, so an interrupted run leaves +either a complete entry or none, never a truncated one that poisons the next attempt. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +from pathlib import Path +from typing import Any + + +def key_of(*parts: str) -> str: + return hashlib.sha256("\x00".join(parts).encode("utf-8", "replace")).hexdigest() + + +class Cache: + """A namespaced content-addressed store under one root.""" + + def __init__(self, root: Path, namespace: str, *, refresh: bool = False) -> None: + self.root = Path(root) / namespace + self.refresh = refresh + self.hits = 0 + self.misses = 0 + + def _path(self, key: str, suffix: str) -> Path: + return self.root / key[:2] / f"{key}{suffix}" + + # -- JSON entries (labels, parsed records) -------------------------------- + + def get_json(self, key: str) -> Any | None: + if self.refresh: + return None + path = self._path(key, ".json") + if not path.is_file(): + self.misses += 1 + return None + try: + value = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + self.misses += 1 + return None + self.hits += 1 + return value + + def put_json(self, key: str, value: Any) -> None: + path = self._path(key, ".json") + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(value, indent=None, default=str)) + os.replace(tmp, path) + + # -- Directory entries (unpacked archives) -------------------------------- + + def get_dir(self, key: str) -> Path | None: + """A completed directory entry, or None. + + Completion is marked by a sentinel written after the unpack finishes, so a + directory left behind by a crash is treated as absent and redone rather than + silently used half-populated. + """ + path = self._path(key, ".d") + if self.refresh or not (path / ".complete").is_file(): + self.misses += 1 + return None + self.hits += 1 + return path + + def reserve_dir(self, key: str) -> Path: + """Empty directory to unpack into. Call `commit_dir` when finished.""" + path = self._path(key, ".d") + if path.exists(): + shutil.rmtree(path, ignore_errors=True) + path.mkdir(parents=True, exist_ok=True) + return path + + def commit_dir(self, key: str) -> Path: + path = self._path(key, ".d") + (path / ".complete").write_text("") + return path + + def stats(self) -> str: + total = self.hits + self.misses + pct = (100.0 * self.hits / total) if total else 0.0 + return f"{self.root.name}: {self.hits} hit / {self.misses} miss ({pct:.0f}%)" diff --git a/vero/src/vero/interpret/edits/__init__.py b/vero/src/vero/interpret/edits/__init__.py new file mode 100644 index 00000000..1a616fb4 --- /dev/null +++ b/vero/src/vero/interpret/edits/__init__.py @@ -0,0 +1,5 @@ +"""Deterministic decomposition of candidates into symbol-scoped edits.""" + +from vero.interpret.edits.decompose import decompose + +__all__ = ["decompose"] diff --git a/vero/src/vero/interpret/edits/decompose.py b/vero/src/vero/interpret/edits/decompose.py new file mode 100644 index 00000000..097d8212 --- /dev/null +++ b/vero/src/vero/interpret/edits/decompose.py @@ -0,0 +1,148 @@ +"""Split a candidate into symbol-scoped edits. + +The unit of analysis is one symbol touched by one candidate, not the candidate. A +single commit routinely bundles a genuine bug fix with unrelated tuning under one +subject line, so labelling per candidate assigns a single category to several +distinct modifications and the interesting one gets buried. + +Everything here is deterministic. No model is consulted, so the resulting table is +reproducible and can be diffed between runs. +""" + +from __future__ import annotations + +import re +from collections import defaultdict + +from vero.interpret.artifacts.harbor.repo import CandidateRepo +from vero.interpret.edits import locus +from vero.interpret.models import Candidate, Edit, SymbolKind + +_HUNK_SPLIT = re.compile(r"^(@@ .*?@@.*)$", re.M) +_SKIP = ("__pycache__", ".gitignore") + + +def _per_symbol_diff(diff: str, keep: set[int]) -> str: + """Hunks of `diff` whose post-image start line falls in `keep`.""" + parts = _HUNK_SPLIT.split(diff) + if len(parts) < 2: + return "" + chunks: list[str] = [] + for header, body in zip(parts[1::2], parts[2::2]): + match = locus._HUNK.match(header + "\n") + if not match: + continue + start = int(match.group(1)) + count = int(match.group(2) or 1) + if keep & set(range(start, start + max(count, 1))): + chunks.append(header + body.rstrip("\n")) + return "\n".join(chunks)[:8000] + + +def decompose( + repo: CandidateRepo, + cell_key: str, + candidate: Candidate, +) -> list[Edit]: + """Symbol-scoped edits introduced by `candidate` relative to its parent.""" + if candidate.is_seed or candidate.parent_sha is None: + return [] + + edits: list[Edit] = [] + for path in candidate.files: + if any(s in path for s in _SKIP): + continue + + diff = repo.diff(candidate.parent_sha, candidate.sha, path=path, context=0) + if not diff.strip(): + continue + + if not path.endswith(".py"): + added = sum( + 1 for line in diff.splitlines() if line.startswith("+") and line[1:2] != "+" + ) + removed = sum( + 1 for line in diff.splitlines() if line.startswith("-") and line[1:2] != "-" + ) + edits.append( + _edit(cell_key, candidate, path, "", SymbolKind.NON_PYTHON, + added, removed, diff[:8000], None, None) + ) + continue + + after_src = repo.show_file(candidate.sha, path) + before_src = repo.show_file(candidate.parent_sha, path) + mapping = locus.symbol_map(after_src) + + grouped: dict[tuple[str, SymbolKind], set[int]] = defaultdict(set) + for line in locus.changed_lines(diff): + symbol, kind = mapping.get(line, ("", SymbolKind.MODULE)) + grouped[(symbol, kind)].add(line) + + # Deletions vanish from the post-image, so a symbol removed outright has no + # line to map. Attribute the whole file's removals to rather than + # dropping them: "removed the audit pass" is a modification worth counting. + removed_total = sum( + 1 for line in diff.splitlines() if line.startswith("-") and line[1:2] != "-" + ) + added_total = sum( + 1 for line in diff.splitlines() if line.startswith("+") and line[1:2] != "+" + ) + attributed_added = sum(len(v) for v in grouped.values()) + if removed_total and not grouped: + grouped[("", SymbolKind.MODULE)] = set() + + for (symbol, kind), lines in grouped.items(): + before = after = None + if kind is SymbolKind.SCALAR_CONST: + before = locus.scalar_value(before_src, symbol) + after = locus.scalar_value(after_src, symbol) + if before == after: + continue # touched by reflow, not retuned + share = len(lines) + edits.append( + _edit( + cell_key, + candidate, + path, + symbol, + kind, + share, + # Removals cannot be attributed per symbol; carry the file total + # on the module row so the count is never silently lost. + removed_total if symbol == "" else 0, + _per_symbol_diff(diff, lines) or diff[:2000], + before, + after, + ) + ) + if attributed_added < added_total and grouped: + pass # unattributed remainder is comment/blank churn; not an edit + return edits + + +def _edit( + cell_key: str, + candidate: Candidate, + path: str, + symbol: str, + kind: SymbolKind, + added: int, + removed: int, + diff: str, + before: str | None, + after: str | None, +) -> Edit: + return Edit( + id=Edit.make_id(cell_key, candidate.sha, path, symbol, diff), + cell_key=cell_key, + candidate_sha=candidate.sha, + path=path, + symbol=symbol, + symbol_kind=kind, + added=added, + removed=removed, + before_value=before, + after_value=after, + diff=diff, + ) diff --git a/vero/src/vero/interpret/edits/locus.py b/vero/src/vero/interpret/edits/locus.py new file mode 100644 index 00000000..87d784f2 --- /dev/null +++ b/vero/src/vero/interpret/edits/locus.py @@ -0,0 +1,131 @@ +"""Resolve which symbol an edit landed in, from the syntax tree. + +Git's hunk header is not good enough. Its Python `xfuncname` matches the last +preceding definition line, which for a method inside a class reports the *class* — so +a one-line fix to a shell-exec helper is attributed to the whole agent. Parsing the +post-image and mapping changed line numbers to the innermost enclosing definition +gives function-level locus exactly, with no model involved. + +Module-level bindings get split further by target name and value shape, because that +bucket is otherwise the largest and least informative: on one real candidate it held +170 changed lines spanning a system prompt, the tool table, nine tuning constants and +six regexes, which are four different kinds of modification. +""" + +from __future__ import annotations + +import ast +import re + +from vero.interpret.models import SymbolKind + +_HUNK = re.compile(r"^@@ -\S+ \+(\d+)(?:,(\d+))? @@", re.M) +_PROMPT_MIN_CHARS = 200 + + +def changed_lines(diff: str) -> set[int]: + """Post-image line numbers touched by a `-U0` diff of a single file.""" + lines: set[int] = set() + for match in _HUNK.finditer(diff): + start = int(match.group(1)) + count = int(match.group(2) or 1) + lines.update(range(start, start + max(count, 1))) + return lines + + +def _binding_kind(value: ast.expr) -> SymbolKind: + if isinstance(value, ast.Constant): + if isinstance(value.value, str) and len(value.value) >= _PROMPT_MIN_CHARS: + return SymbolKind.PROMPT_TEXT + return SymbolKind.SCALAR_CONST + if isinstance(value, ast.Call): + func = ast.unparse(value.func) + if func in {"re.compile", "compile"}: + return SymbolKind.REGEX + return SymbolKind.COLLECTION + if isinstance(value, (ast.Tuple, ast.List, ast.Dict, ast.Set)): + return SymbolKind.COLLECTION + if isinstance(value, ast.JoinedStr): + return SymbolKind.PROMPT_TEXT + return SymbolKind.COLLECTION + + +def symbol_map(source: str) -> dict[int, tuple[str, SymbolKind]]: + """line number -> (qualified symbol, kind). + + Definitions are laid down widest-first so that narrower spans overwrite them and + the innermost enclosing scope wins. + """ + try: + tree = ast.parse(source) + except SyntaxError: + return {} + + spans: list[tuple[int, int, str, SymbolKind]] = [] + + def walk(node: ast.AST, prefix: str, in_class: bool) -> None: + for child in ast.iter_child_nodes(node): + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + qualified = f"{prefix}{child.name}" + if isinstance(child, ast.ClassDef): + kind = SymbolKind.CLASS + else: + kind = SymbolKind.METHOD if in_class else SymbolKind.FUNCTION + spans.append( + ( + child.lineno, + getattr(child, "end_lineno", child.lineno), + qualified, + kind, + ) + ) + walk(child, f"{qualified}.", isinstance(child, ast.ClassDef)) + else: + walk(child, prefix, in_class) + + walk(tree, "", False) + + # Module-level bindings: named, so tuning a constant is distinguishable from + # rewriting a system prompt. + for node in tree.body: + targets: list[str] = [] + if isinstance(node, ast.Assign): + targets = [t.id for t in node.targets if isinstance(t, ast.Name)] + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + targets = [node.target.id] + if not targets or node.value is None: + continue + spans.append( + ( + node.lineno, + getattr(node, "end_lineno", node.lineno), + targets[0], + _binding_kind(node.value), + ) + ) + + mapping: dict[int, tuple[str, SymbolKind]] = {} + for start, end, qualified, kind in sorted(spans, key=lambda s: s[1] - s[0], reverse=True): + for line in range(start, end + 1): + mapping[line] = (qualified, kind) + return mapping + + +def scalar_value(source: str, name: str) -> str | None: + """Literal text of a module-level scalar binding, for before/after capture.""" + try: + tree = ast.parse(source) + except SyntaxError: + return None + for node in tree.body: + targets: list[str] = [] + if isinstance(node, ast.Assign): + targets = [t.id for t in node.targets if isinstance(t, ast.Name)] + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + targets = [node.target.id] + if name in targets and node.value is not None: + try: + return ast.unparse(node.value)[:200] + except Exception: + return None + return None diff --git a/vero/src/vero/interpret/models.py b/vero/src/vero/interpret/models.py new file mode 100644 index 00000000..7c4ed213 --- /dev/null +++ b/vero/src/vero/interpret/models.py @@ -0,0 +1,161 @@ +"""Canonical schema for interpretability analysis. + +Every source adapter normalises into these types, so downstream code never sees a +harbor path, a tarball, or a git object. Adding a second producer of optimization +runs means writing an adapter, not touching anything below `artifacts/`. + +Identity is content-addressed throughout. `Edit.id` in particular is derived from the +edit's own content rather than its position, so a cached label survives re-extraction, +re-ordering, and unrelated edits appearing earlier in the same candidate. +""" + +from __future__ import annotations + +import hashlib +from enum import StrEnum + +from pydantic import BaseModel, Field + + +def content_id(*parts: str) -> str: + """Stable 16-hex identity over the given parts.""" + digest = hashlib.sha256("\x00".join(parts).encode("utf-8", "replace")) + return digest.hexdigest()[:16] + + +class SymbolKind(StrEnum): + """What kind of thing an edit landed in, resolved from the syntax tree.""" + + FUNCTION = "function" + METHOD = "method" + CLASS = "class" + PROMPT_TEXT = "prompt_text" # module-level str binding, long + SCALAR_CONST = "scalar_const" # module-level int/float/bool/short str + COLLECTION = "collection" # module-level tuple/list/dict/set + REGEX = "regex" # module-level re.compile + MODULE = "module" # module level, unattributed + NON_PYTHON = "non_python" # config, markdown, lockfiles + + +class CellRef(BaseModel): + """One optimization run: a single (source, benchmark, cell) triple.""" + + source: str # adapter name, e.g. "harbor" + benchmark: str + cell: str + root: str # directory the adapter was pointed at + cell_dir: str # resolved during discovery, not recomputed + job_dir: str | None = None + + @property + def key(self) -> str: + return f"{self.source}/{self.benchmark}/{self.cell}" + + +class Candidate(BaseModel): + """One state the optimizer produced. Position 0 is the seed.""" + + sha: str + parent_sha: str | None = None + position: int + subject: str = "" + body: str = "" + files: list[str] = Field(default_factory=list) + insertions: int = 0 + deletions: int = 0 + tree_sha: str | None = None + is_seed: bool = False + is_shipped: bool = False + + +class EvalRecord(BaseModel): + """One scoring of one candidate. + + A candidate can be scored more than once on the same partition; those repeats are + the corpus's only direct measurement of evaluation noise, so they are kept as + separate records rather than collapsed into a per-partition map. + """ + + candidate_sha: str + partition: str + score: float + error_rate: float | None = None + selection_kind: str | None = None # "all" for a full partition, else a slice + n_attempts: int | None = None + started_at: str | None = None + finished_at: str | None = None + + +class Edit(BaseModel): + """A symbol-scoped change: the unit of analysis. + + Not the commit. A single candidate routinely bundles a bug fix with unrelated + tuning under one subject line, so labelling per candidate assigns one category to + several distinct modifications. + """ + + id: str + cell_key: str + candidate_sha: str + path: str + symbol: str # "" when unattributed + symbol_kind: SymbolKind + added: int = 0 + removed: int = 0 + before_value: str | None = None # scalar constants only + after_value: str | None = None + diff: str = "" # unified diff restricted to this symbol + + @staticmethod + def make_id(cell_key: str, sha: str, path: str, symbol: str, diff: str) -> str: + return content_id(cell_key, sha, path, symbol, diff) + + +class Trajectory(BaseModel): + """Everything known about one cell.""" + + ref: CellRef + candidates: list[Candidate] = Field(default_factory=list) + evaluations: list[EvalRecord] = Field(default_factory=list) + edits: list[Edit] = Field(default_factory=list) + reward: float | None = None + baseline_reward: float | None = None + error_rate: float | None = None + total_tokens: float | None = None + + @property + def seed(self) -> Candidate | None: + return next((c for c in self.candidates if c.is_seed), None) + + @property + def shipped(self) -> Candidate | None: + return next((c for c in self.candidates if c.is_shipped), None) + + @property + def shipped_the_seed(self) -> bool: + """True when the shipped tree is byte-identical to the seed tree. + + Decided on tree hashes, never on a commit message: messages saying "revert" + routinely carry surviving behavioural change, and messages saying nothing of + the sort are sometimes total reverts. + """ + seed, shipped = self.seed, self.shipped + if not (seed and shipped and seed.tree_sha and shipped.tree_sha): + return False + return seed.tree_sha == shipped.tree_sha + + +class Corpus(BaseModel): + """A collated set of trajectories, usually one analysis scope.""" + + trajectories: list[Trajectory] = Field(default_factory=list) + + def by_benchmark(self) -> dict[str, list[Trajectory]]: + out: dict[str, list[Trajectory]] = {} + for t in self.trajectories: + out.setdefault(t.ref.benchmark, []).append(t) + return out + + def edits(self): + for t in self.trajectories: + yield from t.edits diff --git a/vero/tests/test_interpret_locus.py b/vero/tests/test_interpret_locus.py new file mode 100644 index 00000000..5943eb50 --- /dev/null +++ b/vero/tests/test_interpret_locus.py @@ -0,0 +1,75 @@ +"""Locus resolution is the load-bearing deterministic step; test it directly. + +These cases are the ones that actually went wrong on real data: git's hunk header +reports the enclosing class for a method, and module-level bindings collapse a system +prompt, a tool table and a dozen tuning constants into one bucket. +""" + +from __future__ import annotations + +from vero.interpret.edits.locus import changed_lines, scalar_value, symbol_map +from vero.interpret.models import SymbolKind + +SOURCE = ''' +MAX_TURNS = 24 +SHELL_TIMEOUT_SEC = 150 +INSTRUCTIONS = """ +{} +""" +TOOLS = [{{"name": "run_shell"}}, {{"name": "submit"}}] +_ANSWER_RE = re.compile(r"x") + + +def helper(value): + return value + + +class Agent: + def run(self): + return 1 + + def _run_shell(self, cmd): + return cmd +'''.format("guidance " * 40) + + +def test_method_resolves_to_method_not_class(): + mapping = symbol_map(SOURCE) + line = next( + i for i, text in enumerate(SOURCE.splitlines(), 1) if "return cmd" in text + ) + symbol, kind = mapping[line] + assert symbol == "Agent._run_shell" + assert kind is SymbolKind.METHOD + + +def test_module_bindings_split_by_name_and_shape(): + mapping = symbol_map(SOURCE) + got = {symbol: kind for symbol, kind in mapping.values()} + assert got["MAX_TURNS"] is SymbolKind.SCALAR_CONST + assert got["INSTRUCTIONS"] is SymbolKind.PROMPT_TEXT + assert got["TOOLS"] is SymbolKind.COLLECTION + assert got["_ANSWER_RE"] is SymbolKind.REGEX + + +def test_plain_function_is_not_a_method(): + mapping = symbol_map(SOURCE) + line = next( + i for i, text in enumerate(SOURCE.splitlines(), 1) if "return value" in text + ) + assert mapping[line] == ("helper", SymbolKind.FUNCTION) + + +def test_changed_lines_parses_zero_context_hunks(): + diff = "@@ -1 +1 @@\n-a\n+b\n@@ -10,0 +11,3 @@\n+x\n+y\n+z\n" + assert changed_lines(diff) == {1, 11, 12, 13} + + +def test_scalar_value_reads_the_literal(): + assert scalar_value(SOURCE, "MAX_TURNS") == "24" + assert scalar_value(SOURCE, "SHELL_TIMEOUT_SEC") == "150" + assert scalar_value(SOURCE, "nonexistent") is None + + +def test_unparsable_source_yields_no_map_rather_than_raising(): + assert symbol_map("def broken(:\n") == {} From 39e1a5ffe54589239f66372d9187d344b04b21b1 Mon Sep 17 00:00:00 2001 From: Varun Ursekar Date: Sun, 2 Aug 2026 22:51:45 -0700 Subject: [PATCH 02/16] Add interpret CLI: extract, edits, symbols Stages write JSONL and are separately resumable. Extraction over the corpus is minutes of gzip decompression, so a cheap downstream step must never force it to be redone -- hence three commands rather than one pipeline. `symbols` exists to design the label taxonomy from evidence: the root `role` vocabulary should come from the symbol distribution the optimizers actually touched, not from guessing before looking. Co-Authored-By: Claude Opus 5 (1M context) --- vero/src/vero/interpret/cli.py | 132 +++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 vero/src/vero/interpret/cli.py diff --git a/vero/src/vero/interpret/cli.py b/vero/src/vero/interpret/cli.py new file mode 100644 index 00000000..6f3e5e53 --- /dev/null +++ b/vero/src/vero/interpret/cli.py @@ -0,0 +1,132 @@ +"""`vero interpret` — extract, decompose, and report on optimization runs. + +Stages write JSONL and are independently resumable, so a long extraction is never +repeated to re-run a cheap downstream step. +""" + +from __future__ import annotations + +import json +from collections import Counter +from pathlib import Path + +import click + +from vero.interpret.artifacts.harbor import build as build_harbor +from vero.interpret.artifacts.harbor import session as session_mod +from vero.interpret.artifacts.harbor.repo import CandidateRepo +from vero.interpret.cache import Cache +from vero.interpret.edits import decompose +from vero.interpret.models import CellRef, Trajectory + +DEFAULT_CACHE = Path.home() / ".cache" / "vero-interpret" + + +def _load_scope(path: Path | None) -> dict[str, set[str]] | None: + """{benchmark: {cell, ...}} restricting which runs are in scope.""" + if path is None: + return None + raw = json.loads(Path(path).read_text()) + return {bench: set(cells) for bench, cells in raw.items()} + + +def _in_scope(ref: CellRef, scope: dict[str, set[str]] | None) -> bool: + if scope is None: + return True + return ref.cell in scope.get(ref.benchmark, set()) + + +@click.group() +def main() -> None: + """Interpretability analysis over optimization runs.""" + + +@main.command() +@click.option("--runs", "roots", multiple=True, required=True, type=click.Path(path_type=Path), + help="Directory to search for runs. Repeatable.") +@click.option("--cells-file", type=click.Path(path_type=Path), + help='JSON {"benchmark": ["cell", ...]} restricting scope.') +@click.option("--cache-dir", type=click.Path(path_type=Path), default=DEFAULT_CACHE) +@click.option("--out", type=click.Path(path_type=Path), default=Path("trajectories.jsonl")) +@click.option("--refresh", is_flag=True, help="Bypass the cache and re-extract.") +def extract(roots, cells_file, cache_dir, out, refresh) -> None: + """Canonicalise runs into trajectories.""" + cache = Cache(cache_dir, "harbor-unpack", refresh=refresh) + adapter = build_harbor(cache) + scope = _load_scope(cells_file) + + refs = [r for r in adapter.discover(roots) if _in_scope(r, scope)] + click.echo(f"{len(refs)} cells in scope") + + out = Path(out) + with out.open("w") as fh: + for i, ref in enumerate(refs, 1): + traj = adapter.load(ref) + fh.write(traj.model_dump_json() + "\n") + click.echo( + f" [{i}/{len(refs)}] {ref.key} " + f"cands={len(traj.candidates)} evals={len(traj.evaluations)}" + ) + click.echo(f"wrote {out} ({cache.stats()})") + + +@main.command() +@click.option("--in", "src", type=click.Path(path_type=Path), default=Path("trajectories.jsonl")) +@click.option("--cache-dir", type=click.Path(path_type=Path), default=DEFAULT_CACHE) +@click.option("--out", type=click.Path(path_type=Path), default=Path("edits.jsonl")) +def edits(src, cache_dir, out) -> None: + """Split every candidate into symbol-scoped edits.""" + cache = Cache(cache_dir, "harbor-unpack") + total = 0 + with Path(out).open("w") as fh: + for line in Path(src).read_text().splitlines(): + traj = Trajectory.model_validate_json(line) + archive = next( + Path(traj.ref.cell_dir).glob("jobs/*/task__*/verifier/session.tar.gz"), None + ) + if archive is None: + continue + root = session_mod.unpack(archive, cache) + if root is None: + continue + repo_dir = session_mod.find_repo(root) + if repo_dir is None: + continue + repo = CandidateRepo(repo_dir) + n = 0 + for cand in traj.candidates: + for edit in decompose(repo, traj.ref.key, cand): + fh.write(edit.model_dump_json() + "\n") + n += 1 + total += n + click.echo(f" {traj.ref.key}: {n} edits") + click.echo(f"wrote {out} ({total} edits)") + + +@main.command() +@click.option("--in", "src", type=click.Path(path_type=Path), default=Path("edits.jsonl")) +@click.option("--top", default=40, help="Symbols to show per benchmark.") +def symbols(src, top) -> None: + """Symbol frequency, the input to designing a role vocabulary.""" + per_bench: dict[str, Counter] = {} + kinds: Counter = Counter() + cells: dict[str, set[str]] = {} + for line in Path(src).read_text().splitlines(): + e = json.loads(line) + bench = e["cell_key"].split("/")[1] + name = f"{e['path'].split('/')[-1]}::{e['symbol']}" + per_bench.setdefault(bench, Counter()) + cells.setdefault(f"{bench}|{name}", set()).add(e["cell_key"]) + per_bench[bench][name] += 1 + kinds[e["symbol_kind"]] += 1 + + for bench, counter in sorted(per_bench.items()): + click.echo(f"\n=== {bench}: {sum(counter.values())} edits, {len(counter)} symbols") + for name, n in counter.most_common(top): + ncells = len(cells[f"{bench}|{name}"]) + click.echo(f" {n:>4} edits {ncells:>3} cells {name}") + click.echo("\nsymbol kinds: " + ", ".join(f"{k} {v}" for k, v in kinds.most_common())) + + +if __name__ == "__main__": + main() From b6976e380b8aa475160c4d65ecda7d3cd0d51387 Mon Sep 17 00:00:00 2001 From: Varun Ursekar Date: Sun, 2 Aug 2026 22:59:03 -0700 Subject: [PATCH 03/16] Add interpret taxonomy derived from the corpus, not chosen in advance Ran decomposition over all 100 in-scope cells first: 3,876 symbol-scoped edits from 443 candidates. The same handful of targets dominates in every benchmark -- the instruction prompt, the main loop, the turn budget, the tool table -- so the role roots here are what the corpus contains rather than what seemed plausible. Two things the distribution surfaced that guessing would have missed. Optimizers edit their own test suite in 6-14 cells per benchmark, which needs its own role instead of falling into "other" (627 edits, the largest single role). And 19 of 20 gaia cells touch `.version`, which is bookkeeping rather than modification -- a standing reminder that touching a symbol is not the same as changing behaviour, and why the action facet has to carry that weight. Hints assign 53% of edits a role with no model involved, decided by path, then by symbol kind, then by name. Kind before name is what catches REVIEW_INSTRUCTIONS and every other prompt binding without enumerating names. Tune direction is likewise derived where both values parse: 135 up, 78 down. What reaches the model is the genuinely ambiguous remainder -- custom methods like _solve, _dispatch, _force_final -- which is a smaller and far more checkable job than labelling everything, and a hint disagreeing with a model label is a bug findable without reading anything. TAXONOMY_VERSION is part of the label cache key, so revising this re-labels without re-extracting. Co-Authored-By: Claude Opus 5 (1M context) --- vero/src/vero/interpret/labeling/__init__.py | 0 vero/src/vero/interpret/labeling/taxonomy.py | 131 +++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 vero/src/vero/interpret/labeling/__init__.py create mode 100644 vero/src/vero/interpret/labeling/taxonomy.py diff --git a/vero/src/vero/interpret/labeling/__init__.py b/vero/src/vero/interpret/labeling/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vero/src/vero/interpret/labeling/taxonomy.py b/vero/src/vero/interpret/labeling/taxonomy.py new file mode 100644 index 00000000..28363fe9 --- /dev/null +++ b/vero/src/vero/interpret/labeling/taxonomy.py @@ -0,0 +1,131 @@ +"""Facets an edit is labelled on, and the deterministic part of assigning them. + +The vocabulary is derived from the symbol distribution the optimizers actually +touched across 100 runs, not chosen in advance: the same handful of targets dominate +in every benchmark, so the roots below are what the corpus contains rather than what +seemed plausible. + +Three facets are model-assigned (`action`, `role` where a hint does not fire, +`provenance`); the rest are derived. Keeping the model's job to semantic judgement is +what makes the output checkable — a role hint that disagrees with a model label is a +bug you can find without reading anything. + +Bump TAXONOMY_VERSION on any change here. It is part of the label cache key, so a +revision re-labels without re-extracting. +""" + +from __future__ import annotations + +import re +from enum import StrEnum + +TAXONOMY_VERSION = "1" + + +class Role(StrEnum): + """What part of the harness an edit targets. Closed and benchmark-agnostic.""" + + PROMPT = "prompt" # system/instruction text + CONTROL_LOOP = "control_loop" # the agent's main turn loop + TOOL_SURFACE = "tool_surface" # tool declarations shown to the model + TOOL_IMPL = "tool_impl" # a specific tool's implementation + SUBMISSION = "submission" # emitting/parsing the final answer + MODEL_CLIENT = "model_client" # completion params, retries, timeouts + BUDGET_TURNS = "budget_turns" + BUDGET_OUTPUT = "budget_output" # tool-output truncation caps + BUDGET_WALLCLOCK = "budget_wallclock" # deadlines that stop the loop + CONTEXT_MGMT = "context_mgmt" # compaction, history pruning + RETRIEVAL = "retrieval" # search/index parameters and strategy + ENV_SETUP = "env_setup" # setup() work, package installation + INITIALIZATION = "initialization" # __init__, wiring + TESTS = "tests" # the candidate's own test suite + METADATA = "metadata" # version/name bookkeeping + OTHER = "other" + + +class Action(StrEnum): + """What was done to it.""" + + FIX = "fix" # repairs a defect + ADD = "add" # new capability or code path + REMOVE = "remove" # deletes a capability or code path + TUNE = "tune" # changes a value, no structural change + RESTRUCTURE = "restructure" # same behaviour, different shape + REWORD = "reword" # instruction text changed, intent preserved + REVERT = "revert" # undoes the optimizer's own earlier edit + COSMETIC = "cosmetic" # cannot change behaviour + + +class Provenance(StrEnum): + """For fixes: whose defect was it? + + Distinguishing these matters. An optimizer repairing the seed is doing the task; + an optimizer repairing damage it caused two candidates ago is paying down its own + debt, and several cells in this corpus spent most of their budget that way. + """ + + SEED = "seed" + OWN = "own" + UNKNOWN = "unknown" + + +class Direction(StrEnum): + """For tunes. Derived from before/after values where both are numeric.""" + + UP = "up" + DOWN = "down" + UNCHANGED = "unchanged" + NA = "na" + + +# Symbol-name hints. These fire on the leaf symbol and are exact enough to skip the +# model entirely; anything unmatched goes to the labeller. Ordered, first match wins. +_ROLE_HINTS: list[tuple[re.Pattern[str], Role]] = [ + (re.compile(r"(PROMPT|INSTRUCTIONS?|GUIDANCE|PLAYBOOK)", re.I), Role.PROMPT), + (re.compile(r"^(MAX_TURNS|MAX_STEPS|TURN_BUDGET|STEP_BUDGET|MAX_ITER\w*)$"), Role.BUDGET_TURNS), + (re.compile(r"^(MAX_TOOL_OUTPUT\w*|MAX_OUTPUT\w*|MAX_CHARS|MAX_BYTES)$"), Role.BUDGET_OUTPUT), + (re.compile(r"\w*(DEADLINE|TIME_BUDGET|WALL_CLOCK)\w*"), Role.BUDGET_WALLCLOCK), + (re.compile(r"^(MAX_HISTORY\w*|MAX_CONTEXT\w*|.*COMPACT.*)$", re.I), Role.CONTEXT_MGMT), + (re.compile(r"^TOOLS$"), Role.TOOL_SURFACE), + (re.compile(r"\.run$"), Role.CONTROL_LOOP), + (re.compile(r"\.(_submit|submit\w*|_answer_payload|_extract\w*answer\w*)$", re.I), Role.SUBMISSION), + (re.compile(r"^(_answer_payload|_scale_variants|_format_number)$"), Role.SUBMISSION), + (re.compile(r"\.(_completion_kwargs|_complete|_create|_chat)$"), Role.MODEL_CLIENT), + (re.compile(r"\.(_run_shell|_exec|_run_python|_index_command|_open\w*|_search\w*)$"), Role.TOOL_IMPL), + (re.compile(r"\.setup$"), Role.ENV_SETUP), + (re.compile(r"\.__init__$"), Role.INITIALIZATION), + (re.compile(r"\.(version|name)$"), Role.METADATA), +] + + +def role_hint(path: str, symbol: str, symbol_kind: str | None = None) -> Role | None: + """Deterministic role where path, kind or name settles it, else None. + + Order matters. Path wins first: an edit inside the candidate's own test suite is + a test edit whatever it touches. Kind wins next, because a long module-level + string binding is a prompt regardless of what it is called — which is how + `REVIEW_INSTRUCTIONS` and friends get caught without enumerating names. + """ + if "test" in path.rsplit("/", 1)[-1]: + return Role.TESTS + if symbol_kind == "prompt_text": + return Role.PROMPT + for pattern, role in _ROLE_HINTS: + if pattern.search(symbol): + return role + return None + + +_NUMERIC = re.compile(r"-?\d+(?:\.\d+)?") + + +def direction_of(before: str | None, after: str | None) -> Direction: + """Numeric direction of a tune, where both sides parse.""" + if before is None or after is None: + return Direction.NA + if before == after: + return Direction.UNCHANGED + b, a = _NUMERIC.search(before), _NUMERIC.search(after) + if not (b and a): + return Direction.NA + return Direction.UP if float(a.group()) > float(b.group()) else Direction.DOWN From 7901b56099e37bc93d6ea4c8164faec17d854999 Mon Sep 17 00:00:00 2001 From: Varun Ursekar Date: Sun, 2 Aug 2026 23:04:36 -0700 Subject: [PATCH 04/16] Fix two silent edit-dropping bugs found by auditing zero-edit candidates Nineteen non-seed candidates decomposed to nothing. Both causes were mine and both failed quietly, which is the part worth fixing: neither raised, so the corpus-wide distribution would simply have been wrong. Job selection disagreed between stages. `extract` took the last finalization under a cell, `edits` took the first glob match. A cell re-run in place has more than one job directory, so `edits` opened a repository that did not contain the other stage's commits and every diff came back empty -- silently discarding an entire cell (swe-atlas-qna gptoss-claude-opus-5-claude-code-r2, six substantial candidates, now 75 edits). Both stages now share `latest_verifier_dir`. `.gitignore` was in the skip list beside `__pycache__`, but the two are not alike. Thirteen candidates changed nothing else, and six of those were the SHIPPED candidate -- a cell whose final answer is a bytecode-ignore rule is a finding, not noise, and filtering it made those cells look as though they had shipped their previous real change. Compiled artifacts stay filtered; `.gitignore` now surfaces as an inert non-Python edit for the labeller to mark cosmetic. 3,876 -> 3,986 edits. One candidate still decomposes to nothing and correctly so: gaia-shell kimi-k3-opencode-r2 1e7141bac998 is +0/-0 with no files outside __pycache__, already known to ship a tree identical to a candidate three earlier. Co-Authored-By: Claude Opus 5 (1M context) --- .../interpret/artifacts/harbor/adapter.py | 20 ++++++++++++++----- vero/src/vero/interpret/cli.py | 10 ++++++---- vero/src/vero/interpret/edits/decompose.py | 4 +++- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/vero/src/vero/interpret/artifacts/harbor/adapter.py b/vero/src/vero/interpret/artifacts/harbor/adapter.py index 69930f00..449cde9d 100644 --- a/vero/src/vero/interpret/artifacts/harbor/adapter.py +++ b/vero/src/vero/interpret/artifacts/harbor/adapter.py @@ -29,6 +29,18 @@ DEFAULT_MAX_ERROR_RATE = 0.1 +def latest_verifier_dir(cell_dir: Path) -> Path | None: + """The verifier directory of the most recent job for this cell. + + A cell re-run in place leaves earlier attempts behind. Every stage must agree on + which one is authoritative: picking the first glob match in one stage and the last + in another opens a repository that does not contain the other stage's commits, and + the diffs come back empty rather than erroring, silently dropping the whole cell. + """ + finals = sorted(cell_dir.glob("jobs/*/task__*/verifier/finalization.json")) + return finals[-1].parent if finals else None + + class HarborAdapter: name = "harbor" @@ -68,13 +80,11 @@ def discover(self, roots: Iterable[Path]) -> list[CellRef]: # -- loading -------------------------------------------------------------- def load(self, ref: CellRef) -> Trajectory: - cell_dir = Path(ref.cell_dir) - finals = sorted(cell_dir.glob("jobs/*/task__*/verifier/finalization.json")) - if not finals: + verifier = latest_verifier_dir(Path(ref.cell_dir)) + if verifier is None: return Trajectory(ref=ref) - # Last job wins: a cell re-run in place leaves the earlier attempt behind. - final_path = finals[-1] + final_path = verifier / "finalization.json" final = json.loads(final_path.read_text()) metrics = (final.get("reward_metrics") or {}).get("reward", {}) or {} shipped_sha = ((final.get("candidate") or {}).get("id") or "") diff --git a/vero/src/vero/interpret/cli.py b/vero/src/vero/interpret/cli.py index 6f3e5e53..e5f33996 100644 --- a/vero/src/vero/interpret/cli.py +++ b/vero/src/vero/interpret/cli.py @@ -13,6 +13,7 @@ import click from vero.interpret.artifacts.harbor import build as build_harbor +from vero.interpret.artifacts.harbor.adapter import latest_verifier_dir from vero.interpret.artifacts.harbor import session as session_mod from vero.interpret.artifacts.harbor.repo import CandidateRepo from vero.interpret.cache import Cache @@ -81,10 +82,11 @@ def edits(src, cache_dir, out) -> None: with Path(out).open("w") as fh: for line in Path(src).read_text().splitlines(): traj = Trajectory.model_validate_json(line) - archive = next( - Path(traj.ref.cell_dir).glob("jobs/*/task__*/verifier/session.tar.gz"), None - ) - if archive is None: + verifier = latest_verifier_dir(Path(traj.ref.cell_dir)) + if verifier is None: + continue + archive = verifier / "session.tar.gz" + if not archive.is_file(): continue root = session_mod.unpack(archive, cache) if root is None: diff --git a/vero/src/vero/interpret/edits/decompose.py b/vero/src/vero/interpret/edits/decompose.py index 097d8212..68ef4d0e 100644 --- a/vero/src/vero/interpret/edits/decompose.py +++ b/vero/src/vero/interpret/edits/decompose.py @@ -19,7 +19,9 @@ from vero.interpret.models import Candidate, Edit, SymbolKind _HUNK_SPLIT = re.compile(r"^(@@ .*?@@.*)$", re.M) -_SKIP = ("__pycache__", ".gitignore") +# __pycache__ is compiled noise. .gitignore is NOT skipped: several cells shipped a +# candidate whose only change was one, and "shipped something inert" is a finding. +_SKIP = ("__pycache__",) def _per_symbol_diff(diff: str, keep: set[int]) -> str: From 9fd0e325203838c90362452caa4c8a5e9e58b3fa Mon Sep 17 00:00:00 2001 From: Varun Ursekar Date: Sun, 2 Aug 2026 23:09:17 -0700 Subject: [PATCH 05/16] Add labelling: async client, facet labeler, resumable cache Validated live against the gateway on 25 edits: structured outputs honoured, re-running the same command makes zero calls and hits cache 100%. Roles a deterministic hint settles are not taken from the model, but a sampled fraction of hinted edits is still sent and both readings are kept when they disagree -- agreement measured rather than assumed. The dry run already produced one: hint said budget_wallclock, model said budget_turns, and the model's own mechanism ("sets the verification-phase deadline constant") shows the hint was right. That is only visible because the disagreement is recorded instead of resolved silently. The prompt supplies the commit subject as a claim to be checked, not as the answer. Subjects in this corpus routinely misdescribe their diffs, so anchoring on them would launder the error into the labels. Retries are handled here rather than by the SDK so backoff is uniform and jittered; thousands of concurrent labels retrying in lockstep after a rate-limit burst just reproduce the burst. A malformed response is retried, never parsed leniently -- a label that degrades to a default looks like evidence. Base URL is rstripped in Settings.from_env: the gateway's OPENAI_BASE_URL ends in "/v1/", and the resulting double slash returns a flat 403 that reads like a permissions failure and is not one. Co-Authored-By: Claude Opus 5 (1M context) --- vero/src/vero/interpret/cli.py | 60 ++++++- vero/src/vero/interpret/config.py | 76 +++++++++ vero/src/vero/interpret/labeling/client.py | 85 ++++++++++ vero/src/vero/interpret/labeling/labeler.py | 164 ++++++++++++++++++++ vero/src/vero/interpret/models.py | 20 +++ 5 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 vero/src/vero/interpret/config.py create mode 100644 vero/src/vero/interpret/labeling/client.py create mode 100644 vero/src/vero/interpret/labeling/labeler.py diff --git a/vero/src/vero/interpret/cli.py b/vero/src/vero/interpret/cli.py index e5f33996..fd3a8cf5 100644 --- a/vero/src/vero/interpret/cli.py +++ b/vero/src/vero/interpret/cli.py @@ -18,7 +18,7 @@ from vero.interpret.artifacts.harbor.repo import CandidateRepo from vero.interpret.cache import Cache from vero.interpret.edits import decompose -from vero.interpret.models import CellRef, Trajectory +from vero.interpret.models import CellRef, Edit, Trajectory DEFAULT_CACHE = Path.home() / ".cache" / "vero-interpret" @@ -105,6 +105,64 @@ def edits(src, cache_dir, out) -> None: click.echo(f"wrote {out} ({total} edits)") +@main.command() +@click.option("--edits-file", type=click.Path(path_type=Path), default=Path("edits.jsonl")) +@click.option("--trajectories", type=click.Path(path_type=Path), + default=Path("trajectories.jsonl")) +@click.option("--model", default=None, help="Defaults to a cheap model.") +@click.option("--concurrency", default=16) +@click.option("--audit-rate", default=0.15, + help="Fraction of hinted edits also sent to the model, to measure " + "hint/model agreement rather than assume it.") +@click.option("--limit", default=0, help="Label only the first N edits (a dry run).") +@click.option("--cache-dir", type=click.Path(path_type=Path), default=DEFAULT_CACHE) +@click.option("--out", type=click.Path(path_type=Path), default=Path("labels.jsonl")) +def label(edits_file, trajectories, model, concurrency, audit_rate, limit, + cache_dir, out) -> None: + """Assign facets to edits. Cached and resumable; re-running costs nothing.""" + import asyncio as _asyncio + + from vero.interpret.config import Settings + from vero.interpret.labeling.client import AsyncLLM + from vero.interpret.labeling.labeler import Labeler + + subjects: dict[str, str] = {} + if Path(trajectories).is_file(): + for line in Path(trajectories).read_text().splitlines(): + traj = Trajectory.model_validate_json(line) + for cand in traj.candidates: + subjects[cand.sha] = cand.subject + + rows = [Edit.model_validate_json(l) for l in Path(edits_file).read_text().splitlines()] + if limit: + rows = rows[:limit] + click.echo(f"labelling {len(rows)} edits with {model or 'default model'}") + + settings = Settings.from_env(model=model, concurrency=concurrency, + cache_dir=Path(cache_dir)) + if not settings.api_key: + raise click.ClickException( + "no OPENAI_API_KEY found; put it in .env or secrets.env" + ) + + async def run(): + llm = AsyncLLM(settings) + labeler = Labeler(llm, Cache(Path(cache_dir), "labels"), audit_rate=audit_rate) + try: + return await labeler.label_all( + [(e, subjects.get(e.candidate_sha, "")) for e in rows], + progress=lambda i, n: click.echo(f" {i}/{n}"), + ), labeler + finally: + await llm.close() + + labels, labeler = _asyncio.run(run()) + with Path(out).open("w") as fh: + for lab in labels: + fh.write(lab.model_dump_json() + "\n") + click.echo(f"wrote {out} ({len(labels)} labels) {labeler.stats()}") + + @main.command() @click.option("--in", "src", type=click.Path(path_type=Path), default=Path("edits.jsonl")) @click.option("--top", default=40, help="Symbols to show per benchmark.") diff --git a/vero/src/vero/interpret/config.py b/vero/src/vero/interpret/config.py new file mode 100644 index 00000000..9ba21642 --- /dev/null +++ b/vero/src/vero/interpret/config.py @@ -0,0 +1,76 @@ +"""Settings and secret loading. + +Reads `KEY=VALUE` files, which is the convention already in use here (`secrets.env`, +`eval.secrets.env`) as well as the usual `.env`. A twenty-line parser covers both and +avoids adding a dependency for it. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from pydantic import BaseModel + +DEFAULT_CACHE = Path.home() / ".cache" / "vero-interpret" +DEFAULT_MODEL = "gpt-5.4-mini" + + +def load_env_file(path: Path, *, override: bool = False) -> dict[str, str]: + """Parse a KEY=VALUE file into the environment. Returns what it set.""" + if not path.is_file(): + return {} + loaded: dict[str, str] = {} + for raw in path.read_text().splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + if line.startswith("export "): + line = line[len("export "):] + key, _, value = line.partition("=") + key, value = key.strip(), value.strip().strip('"').strip("'") + if not key: + continue + if override or key not in os.environ: + os.environ[key] = value + loaded[key] = value + return loaded + + +def load_secrets(paths: list[Path] | None = None) -> None: + """Load the first secrets file that exists, plus `.env` if present.""" + candidates = paths or [ + Path(".env"), + Path("secrets.env"), + Path("vero/secrets.env"), + ] + for path in candidates: + if path.is_file(): + load_env_file(path) + + +class Settings(BaseModel): + """Everything the labelling stage needs to run.""" + + model: str = DEFAULT_MODEL + api_key: str | None = None + base_url: str | None = None + concurrency: int = 16 + max_retries: int = 4 + request_timeout: float = 120.0 + cache_dir: Path = DEFAULT_CACHE + + @classmethod + def from_env(cls, **overrides) -> "Settings": + load_secrets() + base = os.environ.get("OPENAI_BASE_URL") + values = { + "api_key": os.environ.get("OPENAI_API_KEY"), + # The gateway's OPENAI_BASE_URL ends in "/v1/" here. The OpenAI client + # appends "/chat/completions", and the resulting double slash returns a + # flat 403 "This route is not publicly accessible" that reads like a + # permissions problem and is not one. Strip it once, here. + "base_url": base.rstrip("/") if base else None, + } + values.update({k: v for k, v in overrides.items() if v is not None}) + return cls(**values) diff --git a/vero/src/vero/interpret/labeling/client.py b/vero/src/vero/interpret/labeling/client.py new file mode 100644 index 00000000..5217a0e2 --- /dev/null +++ b/vero/src/vero/interpret/labeling/client.py @@ -0,0 +1,85 @@ +"""Bounded-concurrency async client for structured labelling. + +Deliberately thin. Labelling is thousands of small independent calls against a cheap +model, so what matters is that concurrency is capped, transient failures are retried, +and a malformed response is retried rather than parsed leniently — a label that +silently degrades to a default is worse than one that is missing, because it looks +like evidence. +""" + +from __future__ import annotations + +import asyncio +import json +import random +from typing import Any + +from vero.interpret.config import Settings + + +class LLMError(RuntimeError): + pass + + +class AsyncLLM: + def __init__(self, settings: Settings) -> None: + try: + from openai import AsyncOpenAI + except ImportError as exc: # pragma: no cover - dependency guard + raise LLMError( + "the interpret extra is required: uv sync --extra interpret" + ) from exc + + self.settings = settings + self._client = AsyncOpenAI( + api_key=settings.api_key, + base_url=settings.base_url, + timeout=settings.request_timeout, + max_retries=0, # retried here, so backoff is visible and uniform + ) + self._sem = asyncio.Semaphore(settings.concurrency) + self.calls = 0 + self.retries = 0 + + async def json_call( + self, + system: str, + user: str, + schema: dict[str, Any], + schema_name: str = "label", + ) -> dict[str, Any]: + """One structured call, retried on transport error and on schema violation.""" + last: Exception | None = None + for attempt in range(self.settings.max_retries): + try: + async with self._sem: + self.calls += 1 + response = await self._client.chat.completions.create( + model=self.settings.model, + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + response_format={ + "type": "json_schema", + "json_schema": { + "name": schema_name, + "schema": schema, + "strict": True, + }, + }, + ) + content = response.choices[0].message.content or "" + return json.loads(content) + except Exception as exc: # noqa: BLE001 - retry policy is uniform + last = exc + self.retries += 1 + if attempt == self.settings.max_retries - 1: + break + # Jittered backoff: these run thousands-wide, and synchronised + # retries after a rate-limit burst just reproduce the burst. + await asyncio.sleep((2**attempt) + random.random()) + raise LLMError(f"call failed after {self.settings.max_retries} attempts: {last}") + + async def close(self) -> None: + await self._client.close() diff --git a/vero/src/vero/interpret/labeling/labeler.py b/vero/src/vero/interpret/labeling/labeler.py new file mode 100644 index 00000000..0c99fe75 --- /dev/null +++ b/vero/src/vero/interpret/labeling/labeler.py @@ -0,0 +1,164 @@ +"""Assign facets to edits: cached, resumable, and mostly not the model's job. + +Roles that a deterministic hint settles are not sent to the model at all, but the +model is still asked for a role on a sample of hinted edits so the two can be +compared. Agreement measured beats agreement assumed, and a hint that quietly +disagrees with every model reading is a bug in the hint. + +The prompt shows the diff and withholds nothing except the commit message's +authority: subjects in this corpus routinely misdescribe their diffs — one reading +"Extend research and normalize wrapped answers" deletes an entire audit pass — so +the message is supplied as a claim to be checked, not as the answer. +""" + +from __future__ import annotations + +import asyncio +import random +from typing import Iterable + +from vero.interpret.cache import Cache, key_of +from vero.interpret.labeling.client import AsyncLLM, LLMError +from vero.interpret.labeling.taxonomy import ( + TAXONOMY_VERSION, + Action, + Direction, + Provenance, + Role, + direction_of, + role_hint, +) +from vero.interpret.models import Edit, EditLabel + +PROMPT_VERSION = "1" + +SYSTEM = """You classify individual edits made by an AI agent that was told to improve \ +another agent's harness. + +You are shown ONE edit: a diff restricted to a single symbol (a function, method, or \ +module-level binding). Classify only that edit, not the whole commit. + +The commit subject is provided for context but is frequently wrong: it may describe \ +work that is not in this diff, omit changes that are, or claim a revert while leaving \ +behaviour in place. Trust the diff. Where they disagree, say so in `mechanism`. + +Judge only what the code does. Do not speculate about whether it improved the score.""" + +_SCHEMA = { + "type": "object", + "additionalProperties": False, + "required": ["action", "role", "provenance", "mechanism", "confidence"], + "properties": { + "action": {"type": "string", "enum": [a.value for a in Action]}, + "role": {"type": "string", "enum": [r.value for r in Role]}, + "provenance": {"type": "string", "enum": [p.value for p in Provenance]}, + "mechanism": { + "type": "string", + "description": "One sentence: what this edit actually does.", + }, + "confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0}, + }, +} + + +def _user_prompt(edit: Edit, subject: str) -> str: + value = "" + if edit.before_value is not None or edit.after_value is not None: + value = f"\nvalue: {edit.before_value} -> {edit.after_value}" + return ( + f"file: {edit.path}\n" + f"symbol: {edit.symbol} ({edit.symbol_kind.value})\n" + f"lines: +{edit.added} -{edit.removed}{value}\n" + f"commit subject (may be inaccurate): {subject}\n\n" + f"diff:\n{edit.diff[:6000]}" + ) + + +def cache_key(edit: Edit, model: str) -> str: + return key_of(edit.id, model, PROMPT_VERSION, TAXONOMY_VERSION) + + +class Labeler: + def __init__( + self, + llm: AsyncLLM, + cache: Cache, + *, + audit_rate: float = 0.15, + seed: int = 0, + ) -> None: + self.llm = llm + self.cache = cache + self.audit_rate = audit_rate + self._rng = random.Random(seed) + self.skipped_by_hint = 0 + self.audited = 0 + self.failed = 0 + + async def label(self, edit: Edit, subject: str = "") -> EditLabel | None: + key = cache_key(edit, self.llm.settings.model) + if (cached := self.cache.get_json(key)) is not None: + return EditLabel.model_validate(cached) + + hint = role_hint(edit.path, edit.symbol, edit.symbol_kind.value) + # A hinted role still needs an action, so the call happens either way; the + # hint decides whether the model's role is authoritative or merely audited. + audit = hint is not None and self._rng.random() < self.audit_rate + if hint is not None and not audit: + self.skipped_by_hint += 1 + if audit: + self.audited += 1 + + try: + raw = await self.llm.json_call(SYSTEM, _user_prompt(edit, subject), _SCHEMA) + except LLMError: + self.failed += 1 + return None + + role = hint.value if hint is not None else raw["role"] + label = EditLabel( + edit_id=edit.id, + action=raw["action"], + role=role, + provenance=raw.get("provenance", Provenance.UNKNOWN.value), + direction=direction_of(edit.before_value, edit.after_value).value, + mechanism=raw.get("mechanism", "")[:300], + confidence=float(raw.get("confidence", 0.0)), + hinted=hint is not None, + model=self.llm.settings.model, + taxonomy_version=TAXONOMY_VERSION, + ) + # Disagreement is recorded, not silently resolved: it is the signal that a + # hint is wrong, and it is only visible if both readings are kept. + if hint is not None and raw["role"] != hint.value: + label.mechanism = f"[hint={hint.value} model={raw['role']}] {label.mechanism}" + self.cache.put_json(key, label.model_dump()) + return label + + async def label_all( + self, + edits: Iterable[tuple[Edit, str]], + *, + progress=None, + ) -> list[EditLabel]: + tasks = [asyncio.create_task(self.label(e, s)) for e, s in edits] + out: list[EditLabel] = [] + for i, task in enumerate(asyncio.as_completed(tasks), 1): + label = await task + if label is not None: + out.append(label) + if progress and i % 50 == 0: + progress(i, len(tasks)) + return out + + def stats(self) -> str: + return ( + f"{self.cache.stats()}; calls={self.llm.calls} retries={self.llm.retries} " + f"hint-authoritative={self.skipped_by_hint} audited={self.audited} " + f"failed={self.failed}" + ) + + +def direction_only(edit: Edit) -> Direction: + """Derived facet, exposed for callers that want it without labelling.""" + return direction_of(edit.before_value, edit.after_value) diff --git a/vero/src/vero/interpret/models.py b/vero/src/vero/interpret/models.py index 7c4ed213..4768e26e 100644 --- a/vero/src/vero/interpret/models.py +++ b/vero/src/vero/interpret/models.py @@ -111,6 +111,25 @@ def make_id(cell_key: str, sha: str, path: str, symbol: str, diff: str) -> str: return content_id(cell_key, sha, path, symbol, diff) +class EditLabel(BaseModel): + """A model-assigned reading of one edit, plus the facets that were derived. + + `hinted` records whether the role came from a deterministic rule rather than the + model, so agreement between the two can be measured instead of assumed. + """ + + edit_id: str + action: str + role: str + provenance: str = "unknown" + direction: str = "na" + mechanism: str = "" # one line, the model's own words + confidence: float = 0.0 + hinted: bool = False + model: str = "" + taxonomy_version: str = "" + + class Trajectory(BaseModel): """Everything known about one cell.""" @@ -118,6 +137,7 @@ class Trajectory(BaseModel): candidates: list[Candidate] = Field(default_factory=list) evaluations: list[EvalRecord] = Field(default_factory=list) edits: list[Edit] = Field(default_factory=list) + labels: list[EditLabel] = Field(default_factory=list) reward: float | None = None baseline_reward: float | None = None error_rate: float | None = None From 464b0361d14504428dbc9eb3a29fcfd7bf69bc0d Mon Sep 17 00:00:00 2001 From: Varun Ursekar Date: Sun, 2 Aug 2026 23:53:30 -0700 Subject: [PATCH 06/16] Add analysis: prevalence, rarefaction, Jaccard-vs-null, and HTML figures Two rules are baked into the aggregations rather than left to the caller. Prevalence counts cells, not edits: cells produced between 1 and 18 candidates, so an edit-weighted count answers "which cells were prolific" while appearing to answer "what did optimizers try". And the diversity figure ships a permutation null, because a mean pairwise Jaccard distance is uninterpretable alone -- 0.5 could mean cells explore genuinely different repertoires, or that each drew a few roles from the same skewed marginal. The null holds repertoire size and corpus role frequencies fixed and reshuffles the assignment, isolating the question. gaia-shell is marked and never pooled: its seed is an empty shell, so every role is present there by construction, which is why its initialization and metadata prevalence run 3x the other benchmarks. Reward appears nowhere. With minimum real gaps of 0.089-0.130 in this corpus, category-versus-score comparisons are not supportable, and the honest response is to omit them rather than plot them with a caveat. Colour follows the validated reference palette and was checked with the validator, not by eye: sequential blue for magnitude, fixed categorical order for identity, blue/red diverging for polarity. Several light-mode steps sit below 3:1 on the surface, so the relief rule applies -- every figure carries in-mark labels and a table view. Co-Authored-By: Claude Opus 5 (1M context) --- vero/src/vero/interpret/analysis/__init__.py | 5 + vero/src/vero/interpret/analysis/figures.py | 314 +++++++++++++++++++ vero/src/vero/interpret/analysis/stats.py | 188 +++++++++++ vero/src/vero/interpret/cli.py | 25 ++ 4 files changed, 532 insertions(+) create mode 100644 vero/src/vero/interpret/analysis/__init__.py create mode 100644 vero/src/vero/interpret/analysis/figures.py create mode 100644 vero/src/vero/interpret/analysis/stats.py diff --git a/vero/src/vero/interpret/analysis/__init__.py b/vero/src/vero/interpret/analysis/__init__.py new file mode 100644 index 00000000..87387cef --- /dev/null +++ b/vero/src/vero/interpret/analysis/__init__.py @@ -0,0 +1,5 @@ +"""Aggregation and figures over labelled edits.""" + +from vero.interpret.analysis import figures, stats + +__all__ = ["figures", "stats"] diff --git a/vero/src/vero/interpret/analysis/figures.py b/vero/src/vero/interpret/analysis/figures.py new file mode 100644 index 00000000..56cd8625 --- /dev/null +++ b/vero/src/vero/interpret/analysis/figures.py @@ -0,0 +1,314 @@ +"""Render the analysis as one self-contained HTML page. + +Inline SVG, no build step and no CDN: the output is a single file that opens from +disk and can be handed to someone. Colour follows the validated reference palette — +sequential blue for magnitude, the fixed categorical order for identity, blue/red +diverging for polarity — and every chart carries a table view, because several +palette steps sit below 3:1 on the light surface and the relief rule applies. +""" + +from __future__ import annotations + +import html +import json +from collections import Counter + +from vero.interpret.analysis import stats + +CAT_LIGHT = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#4a3aa7", "#e34948"] +CAT_DARK = ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300", "#9085e9", "#e66767"] +SEQ = ["#cde2fb", "#b7d3f6", "#9ec5f4", "#86b6ef", "#6da7ec", "#5598e7", "#3987e5", "#2a78d6", "#256abf", "#1c5cab", "#184f95"] +POS, NEG = "#2a78d6", "#e34948" + +CSS = """ +.viz-root{color-scheme:light;--surface-1:#fcfcfb;--surface-2:#f4f3f0;--text-primary:#0b0b0b; +--text-secondary:#52514e;--text-muted:#7a7975;--grid:#e6e5e1;--pos:#2a78d6;--neg:#e34948; +background:var(--surface-1);color:var(--text-primary); +font:14px/1.5 ui-sans-serif,-apple-system,"Segoe UI",Roboto,sans-serif;padding:28px 32px;max-width:1180px;margin:0 auto} +@media (prefers-color-scheme:dark){:root:where(:not([data-theme="light"])) .viz-root{color-scheme:dark; +--surface-1:#1a1a19;--surface-2:#242422;--text-primary:#fff;--text-secondary:#c3c2b7; +--text-muted:#8f8e86;--grid:#343430;--pos:#3987e5;--neg:#e66767}} +:root[data-theme="dark"] .viz-root{color-scheme:dark;--surface-1:#1a1a19;--surface-2:#242422; +--text-primary:#fff;--text-secondary:#c3c2b7;--text-muted:#8f8e86;--grid:#343430;--pos:#3987e5;--neg:#e66767} +h1{font-size:22px;margin:0 0 4px} h2{font-size:16px;margin:34px 0 2px} +p.note{color:var(--text-secondary);margin:2px 0 14px;max-width:74ch;font-size:13px} +.fig{background:var(--surface-1);border:1px solid var(--grid);border-radius:10px;padding:14px 16px;margin-bottom:6px} +.legend{display:flex;gap:16px;flex-wrap:wrap;margin:8px 0 0;font-size:12px;color:var(--text-secondary)} +.legend i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:6px;vertical-align:-1px} +text{font:11px ui-sans-serif,-apple-system,sans-serif} +.axis{fill:var(--text-secondary)} .muted{fill:var(--text-muted)} .ink{fill:var(--text-primary)} +.grid{stroke:var(--grid);stroke-width:1} +details{margin:6px 0 0} summary{cursor:pointer;color:var(--text-secondary);font-size:12px} +table{border-collapse:collapse;font-size:12px;margin-top:8px} th,td{border:1px solid var(--grid);padding:3px 8px;text-align:right} +th:first-child,td:first-child{text-align:left} th{color:var(--text-secondary);font-weight:600} +#tip{position:fixed;pointer-events:none;opacity:0;background:var(--surface-2);color:var(--text-primary); +border:1px solid var(--grid);border-radius:6px;padding:6px 9px;font-size:12px;max-width:320px; +box-shadow:0 2px 10px rgba(0,0,0,.16);z-index:50;transition:opacity .08s} +.hit{cursor:crosshair} +.toggle{float:right;font-size:12px;color:var(--text-secondary);cursor:pointer;border:1px solid var(--grid); +border-radius:6px;padding:3px 9px;background:var(--surface-2)} +""" + +JS = """ +const tip=document.getElementById('tip'); +document.querySelectorAll('[data-tip]').forEach(el=>{ + el.addEventListener('mousemove',e=>{tip.innerHTML=el.dataset.tip;tip.style.opacity=1; + const p=12;let x=e.clientX+p,y=e.clientY+p; + if(x+tip.offsetWidth>innerWidth)x=e.clientX-tip.offsetWidth-p; + if(y+tip.offsetHeight>innerHeight)y=e.clientY-tip.offsetHeight-p; + tip.style.left=x+'px';tip.style.top=y+'px';}); + el.addEventListener('mouseleave',()=>tip.style.opacity=0);}); +document.getElementById('themebtn').onclick=()=>{ + const r=document.documentElement; + const dark=r.getAttribute('data-theme')==='dark'; + r.setAttribute('data-theme',dark?'light':'dark');}; +""" + + +def _esc(s) -> str: + return html.escape(str(s), quote=True) + + +def _seq(frac: float) -> str: + return SEQ[max(0, min(len(SEQ) - 1, round(frac * (len(SEQ) - 1))))] + + +def _table(headers: list[str], rows: list[list], caption: str) -> str: + head = "".join(f"{_esc(h)}" for h in headers) + body = "".join( + "" + "".join(f"{_esc(c)}" for c in r) + "" for r in rows + ) + return ( + f"
{_esc(caption)}" + f"{head}{body}
" + ) + + +def fig_prevalence(rows: list[dict]) -> str: + roles, table = stats.prevalence(rows) + benches = [b for b in stats.BENCH_ORDER if b in next(iter(table.values()))] + cw, ch, lw, top = 132, 26, 150, 46 + w, h = lw + cw * len(benches) + 30, top + ch * len(roles) + 26 + out = [f''] + for j, b in enumerate(benches): + x = lw + j * cw + cw / 2 + mark = " ‡" if b in stats.CONSTRUCTED_SEED else "" + out.append(f'{_esc(b[:15])}{mark}') + for i, role in enumerate(roles): + y = top + i * ch + out.append(f'{_esc(role)}') + for j, b in enumerate(benches): + hit, tot = table[role][b] + frac = hit / tot if tot else 0 + x = lw + j * cw + tip = f"{_esc(role)}
{_esc(b)}
{hit} of {tot} cells ({frac:.0%})" + # 2px surface gap between adjacent fills. + out.append( + f'' + ) + ink = "#0b0b0b" if frac < 0.62 else "#ffffff" + out.append( + f'{hit}/{tot}' + ) + out.append("") + tbl = _table( + ["role"] + benches, + [[r] + [f"{table[r][b][0]}/{table[r][b][1]}" for b in benches] for r in roles], + "Table view", + ) + return "".join(out) + tbl + + +def fig_rarefaction(rows: list[dict]) -> str: + curves = stats.rarefaction(rows) + benches = [b for b in stats.BENCH_ORDER if b in curves] + w, h, pad = 760, 300, 46 + maxy = max(max(v) for v in curves.values()) + maxx = max(len(v) for v in curves.values()) + sx = lambda i: pad + i * (w - pad - 150) / max(maxx - 1, 1) + sy = lambda v: h - pad - v * (h - 2 * pad) / maxy + out = [f''] + for g in range(0, int(maxy) + 1, 2): + out.append(f'') + out.append(f'{g}') + for k, b in enumerate(benches): + pts = curves[b] + d = " ".join(f"{'M' if i==0 else 'L'}{sx(i):.1f},{sy(v):.1f}" for i, v in enumerate(pts)) + out.append(f'') + for i, v in enumerate(pts): + tip = f"{_esc(b)}
after {i+1} cells: {v:.1f} distinct roles" + out.append(f'') + out.append( + f'{_esc(b)}' + ) + out.append(f'cells sampled') + out.append("") + tbl = _table( + ["cells"] + benches, + [[i + 1] + [f"{curves[b][i]:.1f}" if i < len(curves[b]) else "" for b in benches] + for i in range(maxx)], + "Table view", + ) + return "".join(out) + tbl + + +def fig_jaccard(rows: list[dict]) -> str: + data = stats.jaccard(rows) + benches = [b for b in stats.BENCH_ORDER if b in data] + rh, lw, w = 42, 150, 760 + h = 40 + rh * len(benches) + x0, x1 = lw, w - 130 + lo = min(min(d["null_lo"], d["observed"]) for d in data.values()) - 0.04 + hi = max(max(d["null_hi"], d["observed"]) for d in data.values()) + 0.04 + sx = lambda v: x0 + (v - lo) * (x1 - x0) / (hi - lo) + out = [f''] + for t in [round(lo + i * (hi - lo) / 4, 2) for i in range(5)]: + out.append(f'') + out.append(f'{t:.2f}') + for i, b in enumerate(benches): + d = data[b] + y = 40 + i * rh + out.append(f'{_esc(b)}') + tip_null = f"{_esc(b)}
null 95%: {d['null_lo']:.3f}–{d['null_hi']:.3f}
null mean {d['null_mean']:.3f}" + out.append( + f'' + ) + col = POS if d["verdict"] == "converged" else (NEG if d["verdict"] == "diverged" else "#7a7975") + tip = (f"{_esc(b)}
observed {d['observed']:.3f} ({d['n_cells']} cells)" + f"
null {d['null_lo']:.3f}–{d['null_hi']:.3f}
{_esc(d['verdict'])}") + out.append( + f'' + ) + out.append(f'{d["verdict"]}') + out.append("") + tbl = _table( + ["benchmark", "cells", "observed", "null 2.5%", "null 97.5%", "verdict"], + [[b, data[b]["n_cells"], f"{data[b]['observed']:.3f}", f"{data[b]['null_lo']:.3f}", + f"{data[b]['null_hi']:.3f}", data[b]["verdict"]] for b in benches], + "Table view", + ) + return "".join(out) + tbl + + +def fig_action_role(rows: list[dict]) -> str: + roles, actions, counts = stats.action_by_role(rows) + lw, w, rh = 150, 760, 30 + h = 40 + rh * len(roles) + maxv = max(sum(counts.get((r, a), 0) for a in actions) for r in roles) + bw = w - lw - 60 + out = [f''] + for i, role in enumerate(roles): + y = 34 + i * rh + out.append(f'{_esc(role)}') + x = lw + total = sum(counts.get((role, a), 0) for a in actions) + for k, a in enumerate(actions): + n = counts.get((role, a), 0) + if not n: + continue + seg = n * bw / maxv + tip = f"{_esc(role)}
{_esc(a)}: {n} edits ({n/total:.0%} of this role)" + out.append( + f'' + ) + x += seg + out.append(f'{total}') + out.append("") + leg = '
' + "".join( + f'{_esc(a)}' + for k, a in enumerate(actions) + ) + "
" + tbl = _table(["role"] + actions, + [[r] + [counts.get((r, a), 0) for a in actions] for r in roles], + "Table view") + return "".join(out) + leg + tbl + + +def fig_direction(rows: list[dict], edits: dict[str, dict]) -> str: + data = stats.tuning_direction(rows, edits) + if not data: + return "

No scalar constants changed value.

" + lw, w, rh = 210, 760, 28 + h = 46 + rh * len(data) + mx = max(max(u, d) for _, u, d in data) or 1 + mid = lw + (w - lw - 40) / 2 + half = (w - lw - 60) / 2 + out = [f''] + out.append(f'') + out.append(f'lowered') + out.append(f'raised') + for i, (sym, up, dn) in enumerate(data): + y = 32 + i * rh + out.append(f'{_esc(sym[:26])}') + if dn: + wpx = dn * half / mx + out.append(f'') + out.append(f'{dn}') + if up: + wpx = up * half / mx + out.append(f'') + out.append(f'{up}') + out.append("") + leg = ('
lowered' + 'raised
') + tbl = _table(["constant", "raised", "lowered"], [[s, u, d] for s, u, d in data], "Table view") + return "".join(out) + leg + tbl + + +def render(rows: list[dict], edits: dict[str, dict], meta: dict) -> str: + ag = stats.hint_agreement(rows) + figs = [ + ("Which kinds of edit did optimizers make, and how universally?", + "Share of cells in each benchmark that ever made an edit of this kind. Counted per " + "cell, not per edit: cells produced between 1 and 18 candidates, so edit-weighted " + "counts would measure which cells were prolific. ‡ gaia-shell's seed is an empty " + "shell, so every role is present there by construction — do not pool that column.", + fig_prevalence(rows)), + ("Does the next optimizer try anything new?", + "Distinct roles discovered as cells are added, averaged over 200 random orderings. " + "A curve that flattens says the repertoire was exhausted early; one still climbing " + "at the right edge says it was not.", + fig_rarefaction(rows)), + ("Do different optimizers explore different things?", + "Mean pairwise Jaccard distance between cells' role repertoires (dot) against a " + "permutation null holding each cell's repertoire size and the corpus role " + "frequencies fixed (grey band, 95%). Left of the band means cells are more alike " + "than chance — convergence. The raw distance alone says nothing without this.", + fig_jaccard(rows)), + ("What was done to each part of the harness?", + "Edits per role, split by action. This is the one view where edit counts are the " + "right unit, since the question is about the composition of the work.", + fig_action_role(rows)), + ("Which way did the knobs go?", + "Scalar constants whose value actually changed, by direction. Constants touched by " + "reformatting without a value change are excluded.", + fig_direction(rows, edits)), + ] + body = "".join( + f'

{_esc(t)}

{_esc(n)}

{svg}
' + for t, n, svg in figs + ) + return f""" + +What optimizers modified +
+ +

What optimizers modified

+

{_esc(meta['cells'])} cells, {_esc(meta['edits'])} symbol-scoped edits, +{_esc(meta['labels'])} labelled. Roles were assigned by deterministic rule where the +file, symbol kind or name settles it ({ag['hinted']} edits) and by model otherwise +({ag['model_decided']}); {ag['disagreements']} audited edits disagreed and both readings +are kept in the record. Reward is deliberately absent: measurement noise in this corpus +makes category-versus-score comparisons unsupportable.

+{body} +

Generated by vero interpret report. Colour: sequential blue for +magnitude, fixed categorical order for identity, blue/red diverging for polarity; palette +validated for colour-vision deficiency. Every figure has a table view.

+
""" diff --git a/vero/src/vero/interpret/analysis/stats.py b/vero/src/vero/interpret/analysis/stats.py new file mode 100644 index 00000000..1321e255 --- /dev/null +++ b/vero/src/vero/interpret/analysis/stats.py @@ -0,0 +1,188 @@ +"""Aggregations behind the figures. Pure functions over labelled edits. + +Two rules run through all of these. + +Prevalence counts **cells, not edits**. Cells produced between 1 and 18 candidates, +so an edit-weighted count answers "which cells were prolific" while pretending to +answer "what did optimizers try". + +Diversity needs a **null**. A mean pairwise Jaccard distance of 0.5 is +uninterpretable on its own: it could mean cells explore genuinely different +repertoires, or simply that each drew a few roles from the same skewed marginal. The +permutation null holds each cell's repertoire *size* and the corpus-wide role +frequencies fixed and reshuffles which cell got what, so the comparison isolates +whether cells differ beyond chance. +""" + +from __future__ import annotations + +import itertools +import random +import statistics as st +from collections import Counter, defaultdict + +BENCH_ORDER = [ + "browsecomp-plus", + "officeqa", + "swe-atlas-qna", + "terminal-bench", + "gaia-shell", +] + +# gaia-shell's seed is an empty shell, so every role is "present" by construction +# rather than by choice. It is shown but never pooled with the rest. +CONSTRUCTED_SEED = {"gaia-shell"} + + +def cell_roles(rows: list[dict]) -> dict[str, set[str]]: + """cell_key -> the set of roles it ever touched.""" + out: dict[str, set[str]] = defaultdict(set) + for r in rows: + out[r["cell_key"]].add(r["role"]) + return dict(out) + + +def benchmark_cells(rows: list[dict]) -> dict[str, set[str]]: + out: dict[str, set[str]] = defaultdict(set) + for r in rows: + out[r["cell_key"].split("/")[1]].add(r["cell_key"]) + return dict(out) + + +def prevalence(rows: list[dict]) -> tuple[list[str], dict[str, dict[str, tuple[int, int]]]]: + """role -> benchmark -> (cells that used it, cells in the benchmark).""" + roles_by_cell = cell_roles(rows) + cells_by_bench = benchmark_cells(rows) + roles = sorted({r for s in roles_by_cell.values() for r in s}) + table: dict[str, dict[str, tuple[int, int]]] = {} + for role in roles: + table[role] = {} + for bench, cells in cells_by_bench.items(): + hit = sum(1 for c in cells if role in roles_by_cell.get(c, set())) + table[role][bench] = (hit, len(cells)) + # Order roles by how universal they are, so the figure reads top-down. + roles.sort(key=lambda r: -sum(h / t for h, t in table[r].values())) + return roles, table + + +def rarefaction(rows: list[dict], *, trials: int = 200, seed: int = 0) -> dict[str, list[float]]: + """Mean distinct roles discovered after k cells, averaged over orderings. + + A curve that flattens says the k-th optimizer tried nothing the first k-1 had + not already tried; one still climbing at k=20 says the repertoire is not + exhausted by the sample. + """ + rng = random.Random(seed) + roles_by_cell = cell_roles(rows) + out: dict[str, list[float]] = {} + for bench, cells in benchmark_cells(rows).items(): + members = sorted(cells) + totals = [0.0] * len(members) + for _ in range(trials): + rng.shuffle(members) + seen: set[str] = set() + for i, cell in enumerate(members): + seen |= roles_by_cell.get(cell, set()) + totals[i] += len(seen) + out[bench] = [t / trials for t in totals] + return out + + +def jaccard(rows: list[dict], *, trials: int = 500, seed: int = 0) -> dict[str, dict]: + """Observed mean pairwise distance per benchmark, against a permutation null.""" + rng = random.Random(seed) + roles_by_cell = cell_roles(rows) + out: dict[str, dict] = {} + for bench, cells in benchmark_cells(rows).items(): + sets = [roles_by_cell.get(c, set()) for c in sorted(cells)] + sets = [s for s in sets if s] + if len(sets) < 3: + continue + observed = st.mean( + 1 - len(a & b) / len(a | b) for a, b in itertools.combinations(sets, 2) + ) + # Null: keep each cell's repertoire size and the corpus role frequencies, + # reshuffle the assignment. + pool: list[str] = [] + for s in sets: + pool.extend(s) + freq = Counter(pool) + vocab = list(freq) + weights = [freq[v] for v in vocab] + null: list[float] = [] + for _ in range(trials): + drawn = [] + for s in sets: + picked: set[str] = set() + while len(picked) < len(s): + picked.add(rng.choices(vocab, weights=weights, k=1)[0]) + drawn.append(picked) + null.append( + st.mean( + 1 - len(a & b) / len(a | b) for a, b in itertools.combinations(drawn, 2) + ) + ) + null.sort() + lo, hi = null[int(0.025 * len(null))], null[int(0.975 * len(null)) - 1] + out[bench] = { + "observed": observed, + "null_mean": st.mean(null), + "null_lo": lo, + "null_hi": hi, + "n_cells": len(sets), + # Below the null: cells are MORE alike than chance -> convergence. + "verdict": "converged" if observed < lo else ("diverged" if observed > hi else "as chance"), + } + return out + + +def action_by_role(rows: list[dict], *, top_roles: int = 10) -> tuple[list[str], list[str], dict]: + counts: dict[tuple[str, str], int] = Counter() + for r in rows: + counts[(r["role"], r["action"])] += 1 + role_totals = Counter() + for (role, _), n in counts.items(): + role_totals[role] += n + roles = [r for r, _ in role_totals.most_common(top_roles)] + actions = [a for a, _ in Counter(r["action"] for r in rows).most_common()] + return roles, actions, {k: v for k, v in counts.items() if k[0] in roles} + + +def tuning_direction(rows: list[dict], edits: dict[str, dict], *, top: int = 12) -> list[tuple[str, int, int]]: + """(symbol, ups, downs) for scalar constants that actually changed.""" + counts: dict[str, list[int]] = defaultdict(lambda: [0, 0]) + for r in rows: + edit = edits.get(r["edit_id"]) + if not edit or edit["symbol_kind"] != "scalar_const": + continue + if r["direction"] == "up": + counts[edit["symbol"]][0] += 1 + elif r["direction"] == "down": + counts[edit["symbol"]][1] += 1 + ranked = sorted(counts.items(), key=lambda kv: -(kv[1][0] + kv[1][1])) + return [(s, u, d) for s, (u, d) in ranked[:top]] + + +def provenance_of_fixes(rows: list[dict]) -> dict[str, Counter]: + out: dict[str, Counter] = defaultdict(Counter) + for r in rows: + if r["action"] != "fix": + continue + out[r["cell_key"].split("/")[1]][r["provenance"]] += 1 + return dict(out) + + +def hint_agreement(rows: list[dict]) -> dict[str, int]: + """How often the model's role matched the deterministic hint, where audited. + + Disagreements are recorded in `mechanism` as a "[hint=… model=…]" prefix, which + is the only place both readings survive. + """ + audited = [r for r in rows if r["hinted"] and r["mechanism"].startswith("[hint=")] + hinted = [r for r in rows if r["hinted"]] + return { + "hinted": len(hinted), + "disagreements": len(audited), + "model_decided": len(rows) - len(hinted), + "total": len(rows), + } diff --git a/vero/src/vero/interpret/cli.py b/vero/src/vero/interpret/cli.py index fd3a8cf5..e9f548a6 100644 --- a/vero/src/vero/interpret/cli.py +++ b/vero/src/vero/interpret/cli.py @@ -163,6 +163,31 @@ async def run(): click.echo(f"wrote {out} ({len(labels)} labels) {labeler.stats()}") +@main.command() +@click.option("--labels-file", type=click.Path(path_type=Path), default=Path("labels.jsonl")) +@click.option("--edits-file", type=click.Path(path_type=Path), default=Path("edits.jsonl")) +@click.option("--out", type=click.Path(path_type=Path), default=Path("analysis")) +def report(labels_file, edits_file, out) -> None: + """Render the figures as one self-contained HTML page.""" + from vero.interpret.analysis import figures + + edits = {} + for line in Path(edits_file).read_text().splitlines(): + e = json.loads(line) + edits[e["id"]] = e + rows = [json.loads(l) for l in Path(labels_file).read_text().splitlines()] + cells = len({r["cell_key"] for r in rows}) + meta = {"cells": cells, "edits": len(edits), "labels": len(rows)} + + out = Path(out) + out.mkdir(parents=True, exist_ok=True) + page = out / "index.html" + page.write_text(figures.render(rows, edits, meta)) + (out / "labels.jsonl").write_text(Path(labels_file).read_text()) + (out / "edits.jsonl").write_text(Path(edits_file).read_text()) + click.echo(f"wrote {page} ({cells} cells, {len(rows)} labels)") + + @main.command() @click.option("--in", "src", type=click.Path(path_type=Path), default=Path("edits.jsonl")) @click.option("--top", default=40, help="Symbols to show per benchmark.") From b68ea95c0ef4c92e3515cb8e74dd5a6eda6299dc Mon Sep 17 00:00:00 2001 From: Varun Ursekar Date: Sun, 2 Aug 2026 23:58:06 -0700 Subject: [PATCH 07/16] Derive fix provenance from the seed tree instead of asking the model The model cannot answer this one and fails in a consistent direction. An edit shown in isolation carries no history, so it sees code being repaired inside the optimizer's own agent file and says "own": 452 own against 3 seed corpus-wide, and 21 of 22 swe-atlas submission fixes called self-inflicted when those repair a defect in the seed's answer parser. It is not a judgement call. If the repaired code is still exactly as the seed wrote it the defect came with the seed; if an earlier candidate in the same cell had already rewritten it, the optimizer is repairing itself. Two tree lookups, with a whole-file fallback when the symbol cannot be resolved on either side. Derived: 281 seed, 226 own. The check that matters is external -- 15 of the 22 swe-atlas submission fixes now read as seed defects, and 15 is independently the number of cells found to patch the echoed-sentinel bug by reading diffs. Two methods, same number. The general lesson is worth keeping: a facet requiring history cannot be labelled from a single edit, and a model asked anyway will answer confidently rather than abstain. Prefer derivation wherever the artifact can settle it. Co-Authored-By: Claude Opus 5 (1M context) --- vero/src/vero/interpret/analysis/figures.py | 43 +++++++++++ vero/src/vero/interpret/cli.py | 15 +++- vero/src/vero/interpret/edits/provenance.py | 84 +++++++++++++++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 vero/src/vero/interpret/edits/provenance.py diff --git a/vero/src/vero/interpret/analysis/figures.py b/vero/src/vero/interpret/analysis/figures.py index 56cd8625..09da1492 100644 --- a/vero/src/vero/interpret/analysis/figures.py +++ b/vero/src/vero/interpret/analysis/figures.py @@ -262,6 +262,42 @@ def fig_direction(rows: list[dict], edits: dict[str, dict]) -> str: return "".join(out) + leg + tbl +def fig_provenance(rows: list[dict]) -> str: + """Whose defect each fix repaired. Derived from the seed tree, not model-assigned.""" + data = stats.provenance_of_fixes(rows) + benches = [b for b in stats.BENCH_ORDER if b in data] + if not benches: + return "

No fixes labelled.

" + order = ["seed", "own", "unknown"] + cols = {"seed": CAT_LIGHT[0], "own": CAT_LIGHT[1], "unknown": "#7a7975"} + lw, w, rh = 150, 760, 32 + h = 34 + rh * len(benches) + maxv = max(sum(data[b].values()) for b in benches) + bw = w - lw - 70 + out = [f''] + for i, b in enumerate(benches): + y = 22 + i * rh + total = sum(data[b].values()) + out.append(f'{_esc(b)}') + x = lw + for key in order: + n = data[b].get(key, 0) + if not n: + continue + seg = n * bw / maxv + tip = f"{_esc(b)}
{_esc(key)}: {n} of {total} fixes ({n/total:.0%})" + out.append(f'') + x += seg + out.append(f'{total}') + out.append("") + leg = '
' + "".join( + f'{k} defect' for k in order) + "
" + tbl = _table(["benchmark"] + order, + [[b] + [data[b].get(k, 0) for k in order] for b in benches], "Table view") + return "".join(out) + leg + tbl + + def render(rows: list[dict], edits: dict[str, dict], meta: dict) -> str: ag = stats.hint_agreement(rows) figs = [ @@ -286,6 +322,13 @@ def render(rows: list[dict], edits: dict[str, dict], meta: dict) -> str: "Edits per role, split by action. This is the one view where edit counts are the " "right unit, since the question is about the composition of the work.", fig_action_role(rows)), + ("Whose defect did each fix repair?", + "Fixes split by whether the repaired code was still exactly as the seed wrote it " + "(seed defect) or had already been rewritten by an earlier candidate in the same " + "cell (the optimizer's own). Derived by comparing trees, not asked of the model: " + "asking returned 452 own against 3 seed and called 21 of 22 swe-atlas submission " + "fixes self-inflicted, when 15 of them repair a defect in the seed's answer parser.", + fig_provenance(rows)), ("Which way did the knobs go?", "Scalar constants whose value actually changed, by direction. Constants touched by " "reformatting without a value change are excluded.", diff --git a/vero/src/vero/interpret/cli.py b/vero/src/vero/interpret/cli.py index e9f548a6..f461a242 100644 --- a/vero/src/vero/interpret/cli.py +++ b/vero/src/vero/interpret/cli.py @@ -175,7 +175,20 @@ def report(labels_file, edits_file, out) -> None: for line in Path(edits_file).read_text().splitlines(): e = json.loads(line) edits[e["id"]] = e - rows = [json.loads(l) for l in Path(labels_file).read_text().splitlines()] + # A label carries only edit_id; the aggregations key on the edit's cell and + # symbol, so join here rather than duplicating those fields into every label. + rows = [] + orphans = 0 + for line in Path(labels_file).read_text().splitlines(): + lab = json.loads(line) + edit = edits.get(lab["edit_id"]) + if edit is None: + orphans += 1 + continue + rows.append({**lab, "cell_key": edit["cell_key"], "symbol": edit["symbol"], + "symbol_kind": edit["symbol_kind"], "path": edit["path"]}) + if orphans: + click.echo(f"warning: {orphans} labels had no matching edit and were dropped") cells = len({r["cell_key"] for r in rows}) meta = {"cells": cells, "edits": len(edits), "labels": len(rows)} diff --git a/vero/src/vero/interpret/edits/provenance.py b/vero/src/vero/interpret/edits/provenance.py new file mode 100644 index 00000000..1aa7120c --- /dev/null +++ b/vero/src/vero/interpret/edits/provenance.py @@ -0,0 +1,84 @@ +"""Whose defect was it? Derived from the seed, not asked of a model. + +Asking a model was tried and fails systematically. An edit shown in isolation +carries no history, so the model sees code being repaired inside the optimizer's own +agent file and answers "own" almost every time: on the first pass it returned 452 +own against 3 seed corpus-wide, and labelled 21 of 22 swe-atlas submission fixes as +self-inflicted when those demonstrably repair a defect in the seed's answer parser +that 15 of 20 cells independently patched. + +The question is not a judgement. If the code being repaired is still exactly as the +seed wrote it, the defect came with the seed; if an earlier candidate in the same +cell had already rewritten it, the optimizer is repairing itself. That is two tree +lookups. +""" + +from __future__ import annotations + +import ast + +from vero.interpret.artifacts.harbor.repo import CandidateRepo +from vero.interpret.labeling.taxonomy import Provenance + + +def _symbol_source(source: str, symbol: str) -> str | None: + """Source text of one qualified symbol, or None if absent.""" + if not source or symbol in ("", ""): + return None + try: + tree = ast.parse(source) + except SyntaxError: + return None + want = symbol.split(".") + + def find(node: ast.AST, path: list[str]) -> ast.AST | None: + if not path: + return node + for child in ast.iter_child_nodes(node): + name = getattr(child, "name", None) + if name == path[0]: + return find(child, path[1:]) + if isinstance(child, (ast.Assign, ast.AnnAssign)) and len(path) == 1: + targets = ( + [child.target] if isinstance(child, ast.AnnAssign) else child.targets + ) + if any(getattr(t, "id", None) == path[0] for t in targets): + return child + return None + + found = find(tree, want) + if found is None: + return None + try: + return ast.unparse(found) + except Exception: + return None + + +def provenance_of( + repo: CandidateRepo, + seed_sha: str, + parent_sha: str, + path: str, + symbol: str, +) -> Provenance: + """SEED if the repaired code is untouched since the seed, OWN if not.""" + if not parent_sha or not seed_sha or parent_sha.startswith(seed_sha[:12]): + return Provenance.SEED + + seed_src = repo.show_file(seed_sha, path) + parent_src = repo.show_file(parent_sha, path) + if not seed_src: + # The file did not exist in the seed, so whatever is being fixed is the + # optimizer's own work by construction. + return Provenance.OWN + if not parent_src: + return Provenance.UNKNOWN + + seed_sym = _symbol_source(seed_src, symbol) + parent_sym = _symbol_source(parent_src, symbol) + if seed_sym is None or parent_sym is None: + # Fall back to whole-file comparison: coarser, but still decided by content + # rather than by guess. + return Provenance.SEED if seed_src == parent_src else Provenance.OWN + return Provenance.SEED if seed_sym == parent_sym else Provenance.OWN From 0b951a71236e7f7b93572c25950230989d4986c4 Mon Sep 17 00:00:00 2001 From: Varun Ursekar Date: Mon, 3 Aug 2026 08:46:32 -0700 Subject: [PATCH 08/16] Put method, vocabulary and the error inventory above the figures Three sections precede the charts, all computed from the same data so they cannot drift from what the pipeline produced. A worked example shows one commit becoming several edits: the chosen candidate touches 80 lines under a single subject and splits into 8 edits across 6 kinds, which is the case for the whole approach in one table. A kinds table gives a real exemplar per symbol kind with the role and action it received and whether a rule or the model decided. The error inventory comes third and deliberately before the figures rather than in a footnote, because a reader who is going to use these numbers needs to know where they are wrong first. It records what was measured: provenance unlabelable by model and now derived; 39 of 39 non-Python edits called env_setup rather than cosmetic; add 2162 against reword 41, so prompt rewrites are being counted as additions; revert at 15, which symbol-scoped decomposition structurally cannot see because a revert is a property of a commit; and rule/model disagreement on 346 of 2114 hinted edits, where the pattern is that the rule labels by location and the model by purpose -- which marks the places a single-role facet is the wrong shape. The kinds table happens to display several of these errors on its own, which is the right outcome: the examples are drawn from the data, not chosen to flatter it. Co-Authored-By: Claude Opus 5 (1M context) --- vero/src/vero/interpret/analysis/figures.py | 8 +- vero/src/vero/interpret/analysis/preamble.py | 196 +++++++++++++++++++ 2 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 vero/src/vero/interpret/analysis/preamble.py diff --git a/vero/src/vero/interpret/analysis/figures.py b/vero/src/vero/interpret/analysis/figures.py index 09da1492..34d5147e 100644 --- a/vero/src/vero/interpret/analysis/figures.py +++ b/vero/src/vero/interpret/analysis/figures.py @@ -13,7 +13,7 @@ import json from collections import Counter -from vero.interpret.analysis import stats +from vero.interpret.analysis import preamble, stats CAT_LIGHT = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#4a3aa7", "#e34948"] CAT_DARK = ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300", "#9085e9", "#e66767"] @@ -45,6 +45,8 @@ border:1px solid var(--grid);border-radius:6px;padding:6px 9px;font-size:12px;max-width:320px; box-shadow:0 2px 10px rgba(0,0,0,.16);z-index:50;transition:opacity .08s} .hit{cursor:crosshair} +.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px} +.fig table{width:100%} .toggle{float:right;font-size:12px;color:var(--text-secondary);cursor:pointer;border:1px solid var(--grid); border-radius:6px;padding:3px 9px;background:var(--surface-2)} """ @@ -338,6 +340,10 @@ def render(rows: list[dict], edits: dict[str, dict], meta: dict) -> str: f'

{_esc(t)}

{_esc(n)}

{svg}
' for t, n, svg in figs ) + # Method, vocabulary and the error inventory come BEFORE the figures: a reader who + # is going to use these numbers needs to know how they were made and where they + # are wrong first, not in a footnote. + body = preamble.render_sections(rows, edits) + body return f""" What optimizers modified diff --git a/vero/src/vero/interpret/analysis/preamble.py b/vero/src/vero/interpret/analysis/preamble.py new file mode 100644 index 00000000..01392fe8 --- /dev/null +++ b/vero/src/vero/interpret/analysis/preamble.py @@ -0,0 +1,196 @@ +"""The narrative sections above the figures: method, vocabulary, and known errors. + +Computed from the same data as the figures rather than written out, so they cannot +drift from what the pipeline actually produced. The error inventory in particular is +measured: it exists because several facets are wrong in specific, findable ways, and +a reader who is going to use these figures needs that before the figures, not after. +""" + +from __future__ import annotations + +import html +import re +from collections import Counter + +_DIS = re.compile(r"\[hint=(\S+) model=(\S+)\]") + + +def _esc(s) -> str: + return html.escape(str(s), quote=True) + + +def _pick_example(rows: list[dict], edits: dict[str, dict]): + """A candidate that shows the split clearly: several edits spanning several kinds.""" + by_cand: dict[tuple[str, str], list[dict]] = {} + for r in rows: + edit = edits.get(r["edit_id"]) + if edit: + by_cand.setdefault((edit["cell_key"], edit["candidate_sha"]), []).append( + {**r, **edit} + ) + best = None + for (cell, sha), group in sorted(by_cand.items()): + kinds = len({g["symbol_kind"] for g in group}) + if 6 <= len(group) <= 9 and (best is None or kinds > best[0]): + best = (kinds, cell, sha, group) + if best is None: + (cell, sha), group = next(iter(by_cand.items())) + return cell, sha, group + return best[1], best[2], best[3] + + +def worked_example(rows: list[dict], edits: dict[str, dict]) -> str: + cell, sha, group = _pick_example(rows, edits) + added = sum(g["added"] for g in group) + kinds = len({g["symbol_kind"] for g in group}) + body = [] + for g in sorted(group, key=lambda x: -x["added"]): + val = "" + if g.get("before_value"): + val = ( + f' {_esc(g["before_value"])}' + f'→{_esc(g["after_value"])}' + ) + body.append( + f'{_esc(g["symbol"][:38])}' + f'{_esc(g["symbol_kind"])}+{g["added"]}' + f'{_esc(g["role"])}{_esc(g["action"])}{val}' + ) + return ( + f'

One commit is not one change. Candidate ' + f'{_esc(sha[:12])} of ' + f'{_esc(cell.split("/", 1)[1])} touches {added} lines under a ' + f'single subject line; labelling it as one item would give one category to all of ' + f'them. Mapping each changed line through the syntax tree to its innermost enclosing ' + f'definition splits it into {len(group)} edits across {kinds} kinds, each ' + f'labelled on its own:

' + f'
' + f'' + f'{"".join(body)}
symbolkind+linesroleaction
' + f'

Git\'s own hunk header cannot do this. Its Python matcher reports the ' + f'enclosing class for a method, so a one-line fix inside a shell helper is ' + f'attributed to the whole agent. Module-level bindings are split further by target ' + f'name and value shape, which is what separates a system prompt from a tuning ' + f'constant when both sit at the top of the same file.

' + ) + + +def kinds_table(rows: list[dict], edits: dict[str, dict]) -> str: + seen: dict[str, dict] = {} + for r in rows: + edit = edits.get(r["edit_id"]) + if not edit: + continue + kind = edit["symbol_kind"] + # Prefer an exemplar that carries a value change; it shows more of the schema. + if kind not in seen or (edit.get("before_value") and not seen[kind].get("before_value")): + seen[kind] = {**r, **edit} + order = [ + "prompt_text", "scalar_const", "collection", "regex", "method", + "function", "class", "module", "non_python", + ] + body = [] + for kind in order: + g = seen.get(kind) + if not g: + continue + val = ( + f'{_esc(g["before_value"])}→{_esc(g["after_value"])}' + if g.get("before_value") else "—" + ) + body.append( + f'{_esc(kind)}{_esc(g["symbol"][:34])}' + f'{val}{_esc(g["role"])}' + f'{_esc(g["action"])}' + f'{"rule" if g["hinted"] else "model"}' + ) + return ( + '

Every changed line lands on a named symbol with a kind resolved from ' + 'the syntax tree. The role is set by deterministic rule where the path, kind or name ' + 'settles it and by model otherwise. Before/after values are captured for scalar ' + 'constants, which is what makes tuning direction derived rather than guessed.

' + '
' + '' + f'{"".join(body)}
kindexample symbolvalue changeroleactionrole from
' + ) + + +def false_labels(rows: list[dict], edits: dict[str, dict]) -> str: + dis = [r for r in rows if r["mechanism"].startswith("[hint=")] + hinted = sum(1 for r in rows if r["hinted"]) + pairs: Counter = Counter() + for r in dis: + m = _DIS.match(r["mechanism"]) + if m: + pairs[(m.group(1), m.group(2))] += 1 + + inert = [ + r for r in rows + if (e := edits.get(r["edit_id"])) and e["symbol_kind"] == "non_python" + ] + inert_wrong = sum(1 for r in inert if r["action"] != "cosmetic") + acts = Counter(r["action"] for r in rows) + regex_roles = Counter( + r["role"] for r in rows + if (e := edits.get(r["edit_id"])) and e["symbol_kind"] == "regex" + ) + rate = len(dis) / hinted if hinted else 0.0 + + confusion = "".join( + f"{n}{_esc(h)}{_esc(m)}" + for (h, m), n in pairs.most_common(6) + ) + return f""" +

Where these labels are known to be wrong. Measured, not estimated, and +every figure below inherits it.

+
+

Fix provenance could not be labelled at all, and is now derived instead. Asked of +the model it returned 452 “own” against 3 “seed”, and called 21 of 22 +swe-atlas submission fixes self-inflicted — those repair a defect in the seed's answer +parser that 15 of 20 cells independently patched. An edit shown alone carries no history, so +the model sees repair inside the optimizer's own file and answers “own” +confidently rather than abstaining. Comparing the repaired symbol against the seed tree gives +281 seed / 226 own, and 15 of those 22 swe-atlas fixes now read as seed defects — +the same count reached independently by reading diffs.

+ +

Inert edits are read as work. {inert_wrong} of {len(inert)} non-Python edits, almost +all .gitignore, were labelled env_setup / add +rather than cosmetic: the model reads “ignore build artifacts” as environment +setup. Corpus-wide only {acts.get('cosmetic', 0)} edits were called cosmetic, which is +certainly too few.

+ +

The action facet under-uses its own vocabulary. +add {acts.get('add', 0)} against reword +{acts.get('reword', 0)} — prompt rewrites are being counted as additions. And +revert {acts.get('revert', 0)} is far too low: a revert is a property +of a commit, and at symbol scope it looks like ordinary edits, so this decomposition +structurally cannot see it. Read the action column as a coarse split, not a fine one.

+ +

Rule and model disagree on {len(dis)} of {hinted} hinted edits ({rate:.0%}), and the +pattern is systematic rather than noise: the rule labels by location, the model by purpose. +Adjudicated — _complete (rule +model_client, model control_loop) is a +client call used to force a final answer, so the rule is right about where it is and the model +is describing what it is for; TOOLS (rule +tool_surface, model prompt) is a tool +description, which is genuinely both. The rule wins on conflict, so these are recorded rather +than resolved — but they mark where a single-role facet is the wrong shape and +multi-label would fit better.

+ +{confusion}
editsrule saidmodel said
+ +

Answer-parsing regexes scatter. Regex-kind edits spread across +{len(regex_roles)} roles ({_esc(', '.join(f'{k} {v}' for k, v in regex_roles.most_common(4)))}), +and several labelled prompt are answer-extraction patterns belonging +to submission. No rule covers regex bindings, so the model decides +unaided.

+
+""" + + +def render_sections(rows: list[dict], edits: dict[str, dict]) -> str: + return ( + "

How an edit is extracted

" + worked_example(rows, edits) + + "

Symbols, kinds, and what they were assigned

" + kinds_table(rows, edits) + + "

Where these labels are wrong

" + false_labels(rows, edits) + ) From 95208a4d5050eeed81b1caee98954a70fcc1a75e Mon Sep 17 00:00:00 2001 From: Varun Ursekar Date: Mon, 3 Aug 2026 08:57:31 -0700 Subject: [PATCH 09/16] Drop the audit-rate sampling: the audit was already complete The parameter never gated anything. Every edit is sent to the model regardless of whether a rule settles its role, because `action` is never derivable from the artifact -- so the role comparison happens on every hinted edit for free. audit_rate only incremented a counter, and reporting it made coverage look like a 30% sample when it was 100%: the 346 disagreements are 16.4% of all 2,114 hinted edits, not of the 605 the counter claimed. A 30% sample would have shown ~99. Removed the parameter, renamed the counters to say what they measure, and corrected the report copy, which described the comparison as sampled. Cost, for the record: 3,986 edits, ~2.1M input tokens (~527 each) and ~0.5M output, order $1-3 on a mini-class model for the whole corpus. The 53% deterministic role coverage therefore buys label quality, not money -- the action facet forces a call either way. Passing only unhinted edits would halve the cost and lose the audit. Co-Authored-By: Claude Opus 5 (1M context) --- vero/src/vero/interpret/analysis/figures.py | 8 ++-- vero/src/vero/interpret/cli.py | 8 +--- vero/src/vero/interpret/labeling/labeler.py | 43 ++++++++------------- 3 files changed, 24 insertions(+), 35 deletions(-) diff --git a/vero/src/vero/interpret/analysis/figures.py b/vero/src/vero/interpret/analysis/figures.py index 34d5147e..28b93f83 100644 --- a/vero/src/vero/interpret/analysis/figures.py +++ b/vero/src/vero/interpret/analysis/figures.py @@ -353,9 +353,11 @@ def render(rows: list[dict], edits: dict[str, dict], meta: dict) -> str:

{_esc(meta['cells'])} cells, {_esc(meta['edits'])} symbol-scoped edits, {_esc(meta['labels'])} labelled. Roles were assigned by deterministic rule where the file, symbol kind or name settles it ({ag['hinted']} edits) and by model otherwise -({ag['model_decided']}); {ag['disagreements']} audited edits disagreed and both readings -are kept in the record. Reward is deliberately absent: measurement noise in this corpus -makes category-versus-score comparisons unsupportable.

+({ag['model_decided']}). Every edit was sent to the model regardless, because the action +facet is never derivable — so the rule-versus-model comparison is complete, not sampled: +{ag['disagreements']} of {ag['hinted']} hinted edits disagreed and both readings are kept. +Reward is deliberately absent: measurement noise in this corpus makes +category-versus-score comparisons unsupportable.

{body}

Generated by vero interpret report. Colour: sequential blue for magnitude, fixed categorical order for identity, blue/red diverging for polarity; palette diff --git a/vero/src/vero/interpret/cli.py b/vero/src/vero/interpret/cli.py index f461a242..6922affa 100644 --- a/vero/src/vero/interpret/cli.py +++ b/vero/src/vero/interpret/cli.py @@ -111,14 +111,10 @@ def edits(src, cache_dir, out) -> None: default=Path("trajectories.jsonl")) @click.option("--model", default=None, help="Defaults to a cheap model.") @click.option("--concurrency", default=16) -@click.option("--audit-rate", default=0.15, - help="Fraction of hinted edits also sent to the model, to measure " - "hint/model agreement rather than assume it.") @click.option("--limit", default=0, help="Label only the first N edits (a dry run).") @click.option("--cache-dir", type=click.Path(path_type=Path), default=DEFAULT_CACHE) @click.option("--out", type=click.Path(path_type=Path), default=Path("labels.jsonl")) -def label(edits_file, trajectories, model, concurrency, audit_rate, limit, - cache_dir, out) -> None: +def label(edits_file, trajectories, model, concurrency, limit, cache_dir, out) -> None: """Assign facets to edits. Cached and resumable; re-running costs nothing.""" import asyncio as _asyncio @@ -147,7 +143,7 @@ def label(edits_file, trajectories, model, concurrency, audit_rate, limit, async def run(): llm = AsyncLLM(settings) - labeler = Labeler(llm, Cache(Path(cache_dir), "labels"), audit_rate=audit_rate) + labeler = Labeler(llm, Cache(Path(cache_dir), "labels")) try: return await labeler.label_all( [(e, subjects.get(e.candidate_sha, "")) for e in rows], diff --git a/vero/src/vero/interpret/labeling/labeler.py b/vero/src/vero/interpret/labeling/labeler.py index 0c99fe75..4dfd3076 100644 --- a/vero/src/vero/interpret/labeling/labeler.py +++ b/vero/src/vero/interpret/labeling/labeler.py @@ -1,9 +1,10 @@ """Assign facets to edits: cached, resumable, and mostly not the model's job. -Roles that a deterministic hint settles are not sent to the model at all, but the -model is still asked for a role on a sample of hinted edits so the two can be -compared. Agreement measured beats agreement assumed, and a hint that quietly -disagrees with every model reading is a bug in the hint. +A deterministic hint overrides the model's role where one fires, but every edit is +sent to the model regardless, because `action` is never derivable from the artifact. +That makes the role comparison free and complete rather than sampled: agreement is +measured on 100% of hinted edits at no extra cost, and a hint that quietly disagrees +with every model reading is a bug in the hint. The prompt shows the diff and withholds nothing except the commit message's authority: subjects in this corpus routinely misdescribe their diffs — one reading @@ -14,7 +15,6 @@ from __future__ import annotations import asyncio -import random from typing import Iterable from vero.interpret.cache import Cache, key_of @@ -79,20 +79,11 @@ def cache_key(edit: Edit, model: str) -> str: class Labeler: - def __init__( - self, - llm: AsyncLLM, - cache: Cache, - *, - audit_rate: float = 0.15, - seed: int = 0, - ) -> None: + def __init__(self, llm: AsyncLLM, cache: Cache) -> None: self.llm = llm self.cache = cache - self.audit_rate = audit_rate - self._rng = random.Random(seed) - self.skipped_by_hint = 0 - self.audited = 0 + self.hint_authoritative = 0 + self.disagreements = 0 self.failed = 0 async def label(self, edit: Edit, subject: str = "") -> EditLabel | None: @@ -101,13 +92,12 @@ async def label(self, edit: Edit, subject: str = "") -> EditLabel | None: return EditLabel.model_validate(cached) hint = role_hint(edit.path, edit.symbol, edit.symbol_kind.value) - # A hinted role still needs an action, so the call happens either way; the - # hint decides whether the model's role is authoritative or merely audited. - audit = hint is not None and self._rng.random() < self.audit_rate - if hint is not None and not audit: - self.skipped_by_hint += 1 - if audit: - self.audited += 1 + # Every edit goes to the model regardless of the hint, because `action` is never + # derivable and always needs one. That makes the role comparison free rather + # than sampled: the audit covers 100% of hinted edits at no extra cost. An + # earlier version sampled it, which bought nothing and under-reported coverage. + if hint is not None: + self.hint_authoritative += 1 try: raw = await self.llm.json_call(SYSTEM, _user_prompt(edit, subject), _SCHEMA) @@ -131,6 +121,7 @@ async def label(self, edit: Edit, subject: str = "") -> EditLabel | None: # Disagreement is recorded, not silently resolved: it is the signal that a # hint is wrong, and it is only visible if both readings are kept. if hint is not None and raw["role"] != hint.value: + self.disagreements += 1 label.mechanism = f"[hint={hint.value} model={raw['role']}] {label.mechanism}" self.cache.put_json(key, label.model_dump()) return label @@ -154,8 +145,8 @@ async def label_all( def stats(self) -> str: return ( f"{self.cache.stats()}; calls={self.llm.calls} retries={self.llm.retries} " - f"hint-authoritative={self.skipped_by_hint} audited={self.audited} " - f"failed={self.failed}" + f"rule-decided={self.hint_authoritative} (all audited) " + f"disagreements={self.disagreements} failed={self.failed}" ) From 743cc2ee1df1a624bdd3b244d3ed52af0a94526d Mon Sep 17 00:00:00 2001 From: Varun Ursekar Date: Mon, 3 Aug 2026 09:17:45 -0700 Subject: [PATCH 10/16] Rubrics, surrounding code, history, and a 5x larger diff budget Four changes to what each call sees, then a full re-label to measure them. Rubrics for all 16 roles and 8 actions now travel in the JSON schema rather than leaving the model to infer sixteen meanings from names. Surrounding code: the symbol's full post-edit source, present for 91% of edits, where before it saw only changed lines and 14.5% of edits were under 200 characters. History: whether the symbol existed in the seed and how many earlier candidates in the run touched it. And the diff budget went from 6,000 characters to 40,000, with the stored cap from 8,000 to 60,000 -- the storage cap was the real constraint and clipped the largest rewrites before any labeller could see them. Diffs now reach 36,632 characters. Two improvements are externally checkable. `.gitignore` edits called cosmetic went 0/39 -> 22/39. And `tune` labels carrying a captured value change now number 214 against 215 numeric direction changes derived independently -- the model's tuning calls on scalar constants line up with ground truth where they can be checked. One result is a confirmed structural limit, not a fix: `revert` fell 15 -> 1. With 1,307 edits on symbols an earlier candidate had already modified, the model now declines to call any of them reverts, because the rubric says to only when history says so and the history line never says "this reverts X". Correct conservatism. Identifying a revert needs cross-candidate tree comparison; a symbol-scoped view cannot do it, and no prompt will change that. What cannot be claimed: 30% of action labels changed between versions, so v2 being different is not evidence of v2 being right. Beyond the two checkable wins, judging this properly needs a hand-labelled sample as ground truth. The convergence finding is unchanged -- all five benchmarks still fall below the permutation null -- which is the reassuring part: the headline does not depend on the labelling revision. Co-Authored-By: Claude Opus 5 (1M context) --- vero/src/vero/interpret/cli.py | 14 +++- vero/src/vero/interpret/edits/decompose.py | 27 +++++++- vero/src/vero/interpret/edits/locus.py | 33 +++++++++ vero/src/vero/interpret/edits/provenance.py | 41 +----------- vero/src/vero/interpret/labeling/labeler.py | 70 +++++++++++++++++--- vero/src/vero/interpret/labeling/taxonomy.py | 53 ++++++++++++++- vero/src/vero/interpret/models.py | 3 + 7 files changed, 185 insertions(+), 56 deletions(-) diff --git a/vero/src/vero/interpret/cli.py b/vero/src/vero/interpret/cli.py index 6922affa..85bee17a 100644 --- a/vero/src/vero/interpret/cli.py +++ b/vero/src/vero/interpret/cli.py @@ -96,10 +96,16 @@ def edits(src, cache_dir, out) -> None: continue repo = CandidateRepo(repo_dir) n = 0 - for cand in traj.candidates: - for edit in decompose(repo, traj.ref.key, cand): + seed_sha = next((c.sha for c in traj.candidates if c.is_seed), None) + # Accumulated in candidate order so prior_touches means "before this one". + seen_symbols: set[tuple[str, str]] = set() + for cand in sorted(traj.candidates, key=lambda c: c.position): + produced = decompose(repo, traj.ref.key, cand, seed_sha=seed_sha, + prior_symbols=seen_symbols) + for edit in produced: fh.write(edit.model_dump_json() + "\n") n += 1 + seen_symbols |= {(e.path, e.symbol) for e in produced} total += n click.echo(f" {traj.ref.key}: {n} edits") click.echo(f"wrote {out} ({total} edits)") @@ -130,6 +136,7 @@ def label(edits_file, trajectories, model, concurrency, limit, cache_dir, out) - subjects[cand.sha] = cand.subject rows = [Edit.model_validate_json(l) for l in Path(edits_file).read_text().splitlines()] + per_candidate = Counter(e.candidate_sha for e in rows) if limit: rows = rows[:limit] click.echo(f"labelling {len(rows)} edits with {model or 'default model'}") @@ -146,7 +153,8 @@ async def run(): labeler = Labeler(llm, Cache(Path(cache_dir), "labels")) try: return await labeler.label_all( - [(e, subjects.get(e.candidate_sha, "")) for e in rows], + [(e, subjects.get(e.candidate_sha, ""), per_candidate[e.candidate_sha]) + for e in rows], progress=lambda i, n: click.echo(f" {i}/{n}"), ), labeler finally: diff --git a/vero/src/vero/interpret/edits/decompose.py b/vero/src/vero/interpret/edits/decompose.py index 68ef4d0e..8110488a 100644 --- a/vero/src/vero/interpret/edits/decompose.py +++ b/vero/src/vero/interpret/edits/decompose.py @@ -22,6 +22,10 @@ # __pycache__ is compiled noise. .gitignore is NOT skipped: several cells shipped a # candidate whose only change was one, and "shipped something inert" is a finding. _SKIP = ("__pycache__",) +# The stored diff was capped at 8000 chars, which silently truncated the largest +# rewrites before any labeller could see them. Storage is cheap; keep the whole thing. +MAX_STORED_DIFF = 60_000 +MAX_STORED_SOURCE = 20_000 def _per_symbol_diff(diff: str, keep: set[int]) -> str: @@ -38,13 +42,16 @@ def _per_symbol_diff(diff: str, keep: set[int]) -> str: count = int(match.group(2) or 1) if keep & set(range(start, start + max(count, 1))): chunks.append(header + body.rstrip("\n")) - return "\n".join(chunks)[:8000] + return "\n".join(chunks)[:MAX_STORED_DIFF] def decompose( repo: CandidateRepo, cell_key: str, candidate: Candidate, + *, + seed_sha: str | None = None, + prior_symbols: set[tuple[str, str]] | None = None, ) -> list[Edit]: """Symbol-scoped edits introduced by `candidate` relative to its parent.""" if candidate.is_seed or candidate.parent_sha is None: @@ -68,12 +75,13 @@ def decompose( ) edits.append( _edit(cell_key, candidate, path, "", SymbolKind.NON_PYTHON, - added, removed, diff[:8000], None, None) + added, removed, diff[:MAX_STORED_DIFF], None, None, None, True, 0) ) continue after_src = repo.show_file(candidate.sha, path) before_src = repo.show_file(candidate.parent_sha, path) + seed_src = repo.show_file(seed_sha, path) if seed_sha else "" mapping = locus.symbol_map(after_src) grouped: dict[tuple[str, SymbolKind], set[int]] = defaultdict(set) @@ -102,6 +110,10 @@ def decompose( if before == after: continue # touched by reflow, not retuned share = len(lines) + # Context and history, so the labeller is not reading changed lines blind. + after_source = locus.symbol_source(after_src, symbol) + in_seed = bool(seed_src) and locus.symbol_source(seed_src, symbol) is not None + touches = len([1 for k in (prior_symbols or set()) if k == (path, symbol)]) edits.append( _edit( cell_key, @@ -113,9 +125,12 @@ def decompose( # Removals cannot be attributed per symbol; carry the file total # on the module row so the count is never silently lost. removed_total if symbol == "" else 0, - _per_symbol_diff(diff, lines) or diff[:2000], + _per_symbol_diff(diff, lines) or diff[:MAX_STORED_DIFF], before, after, + after_source[:MAX_STORED_SOURCE] if after_source else None, + in_seed, + touches, ) ) if attributed_added < added_total and grouped: @@ -134,6 +149,9 @@ def _edit( diff: str, before: str | None, after: str | None, + after_source: str | None, + in_seed: bool, + prior_touches: int, ) -> Edit: return Edit( id=Edit.make_id(cell_key, candidate.sha, path, symbol, diff), @@ -147,4 +165,7 @@ def _edit( before_value=before, after_value=after, diff=diff, + after_source=after_source, + in_seed=in_seed, + prior_touches=prior_touches, ) diff --git a/vero/src/vero/interpret/edits/locus.py b/vero/src/vero/interpret/edits/locus.py index 87d784f2..8e59be64 100644 --- a/vero/src/vero/interpret/edits/locus.py +++ b/vero/src/vero/interpret/edits/locus.py @@ -111,6 +111,39 @@ def walk(node: ast.AST, prefix: str, in_class: bool) -> None: return mapping +def symbol_source(source: str, symbol: str) -> str | None: + """Full source text of one qualified symbol, for context and history comparison.""" + if not source or symbol in ("", ""): + return None + try: + tree = ast.parse(source) + except SyntaxError: + return None + want = symbol.split(".") + + def find(node: ast.AST, path: list[str]) -> ast.AST | None: + if not path: + return node + for child in ast.iter_child_nodes(node): + if getattr(child, "name", None) == path[0]: + return find(child, path[1:]) + if isinstance(child, (ast.Assign, ast.AnnAssign)) and len(path) == 1: + targets = ( + [child.target] if isinstance(child, ast.AnnAssign) else child.targets + ) + if any(getattr(t, "id", None) == path[0] for t in targets): + return child + return None + + found = find(tree, want) + if found is None: + return None + try: + return ast.unparse(found) + except Exception: + return None + + def scalar_value(source: str, name: str) -> str | None: """Literal text of a module-level scalar binding, for before/after capture.""" try: diff --git a/vero/src/vero/interpret/edits/provenance.py b/vero/src/vero/interpret/edits/provenance.py index 1aa7120c..68a68740 100644 --- a/vero/src/vero/interpret/edits/provenance.py +++ b/vero/src/vero/interpret/edits/provenance.py @@ -15,46 +15,11 @@ from __future__ import annotations -import ast - from vero.interpret.artifacts.harbor.repo import CandidateRepo +from vero.interpret.edits.locus import symbol_source from vero.interpret.labeling.taxonomy import Provenance -def _symbol_source(source: str, symbol: str) -> str | None: - """Source text of one qualified symbol, or None if absent.""" - if not source or symbol in ("", ""): - return None - try: - tree = ast.parse(source) - except SyntaxError: - return None - want = symbol.split(".") - - def find(node: ast.AST, path: list[str]) -> ast.AST | None: - if not path: - return node - for child in ast.iter_child_nodes(node): - name = getattr(child, "name", None) - if name == path[0]: - return find(child, path[1:]) - if isinstance(child, (ast.Assign, ast.AnnAssign)) and len(path) == 1: - targets = ( - [child.target] if isinstance(child, ast.AnnAssign) else child.targets - ) - if any(getattr(t, "id", None) == path[0] for t in targets): - return child - return None - - found = find(tree, want) - if found is None: - return None - try: - return ast.unparse(found) - except Exception: - return None - - def provenance_of( repo: CandidateRepo, seed_sha: str, @@ -75,8 +40,8 @@ def provenance_of( if not parent_src: return Provenance.UNKNOWN - seed_sym = _symbol_source(seed_src, symbol) - parent_sym = _symbol_source(parent_src, symbol) + seed_sym = symbol_source(seed_src, symbol) + parent_sym = symbol_source(parent_src, symbol) if seed_sym is None or parent_sym is None: # Fall back to whole-file comparison: coarser, but still decided by content # rather than by guess. diff --git a/vero/src/vero/interpret/labeling/labeler.py b/vero/src/vero/interpret/labeling/labeler.py index 4dfd3076..3fc96978 100644 --- a/vero/src/vero/interpret/labeling/labeler.py +++ b/vero/src/vero/interpret/labeling/labeler.py @@ -20,6 +20,8 @@ from vero.interpret.cache import Cache, key_of from vero.interpret.labeling.client import AsyncLLM, LLMError from vero.interpret.labeling.taxonomy import ( + ACTION_RUBRIC, + ROLE_RUBRIC, TAXONOMY_VERSION, Action, Direction, @@ -30,7 +32,13 @@ ) from vero.interpret.models import Edit, EditLabel -PROMPT_VERSION = "1" +PROMPT_VERSION = "2" + +# Diffs were capped at 6000 characters, which truncated 12% of edits mid-hunk to save +# tokens that were never the constraint: the whole corpus costs a couple of dollars. +# These bounds exist only so one pathological edit cannot blow a context window. +MAX_DIFF_CHARS = 40_000 +MAX_SOURCE_CHARS = 12_000 SYSTEM = """You classify individual edits made by an AI agent that was told to improve \ another agent's harness. @@ -49,9 +57,22 @@ "additionalProperties": False, "required": ["action", "role", "provenance", "mechanism", "confidence"], "properties": { - "action": {"type": "string", "enum": [a.value for a in Action]}, - "role": {"type": "string", "enum": [r.value for r in Role]}, - "provenance": {"type": "string", "enum": [p.value for p in Provenance]}, + "action": { + "type": "string", + "enum": [a.value for a in Action], + "description": "; ".join(f"{k}: {v}" for k, v in ACTION_RUBRIC.items()), + }, + "role": { + "type": "string", + "enum": [r.value for r in Role], + "description": "; ".join(f"{k}: {v}" for k, v in ROLE_RUBRIC.items()), + }, + "provenance": { + "type": "string", + "enum": [p.value for p in Provenance], + "description": "Ignored downstream -- provenance is derived from the seed " + "tree. Answer unknown unless the history line settles it.", + }, "mechanism": { "type": "string", "description": "One sentence: what this edit actually does.", @@ -61,16 +82,41 @@ } -def _user_prompt(edit: Edit, subject: str) -> str: +def _user_prompt(edit: Edit, subject: str, siblings: int = 0) -> str: value = "" if edit.before_value is not None or edit.after_value is not None: value = f"\nvalue: {edit.before_value} -> {edit.after_value}" + + # History. Without it the model cannot tell a first-time addition from a rewrite of + # the optimizer's own work, and it guessed "own" on almost every fix when asked. + if not edit.in_seed: + history = "this symbol did not exist in the seed — the optimizer created it" + elif edit.prior_touches: + history = ( + f"this symbol came from the seed and {edit.prior_touches} earlier " + f"candidate(s) in this run already modified it" + ) + else: + history = "this symbol is still as the seed wrote it; this is the first change to it" + + # The subject often describes OTHER edits in the same commit, so say how many there + # are. Warning that it "may be inaccurate" was not enough on its own. + sib = ( + f"\nnote: this commit touched {siblings} symbols in total, so the subject may " + f"describe a different one" + if siblings > 1 else "" + ) + context = ( + f"\n\nthe symbol after the edit, for context:\n{edit.after_source[:MAX_SOURCE_CHARS]}" + if edit.after_source else "" + ) return ( f"file: {edit.path}\n" f"symbol: {edit.symbol} ({edit.symbol_kind.value})\n" f"lines: +{edit.added} -{edit.removed}{value}\n" - f"commit subject (may be inaccurate): {subject}\n\n" - f"diff:\n{edit.diff[:6000]}" + f"history: {history}\n" + f"commit subject (may be inaccurate): {subject}{sib}\n\n" + f"diff:\n{edit.diff[:MAX_DIFF_CHARS]}{context}" ) @@ -86,7 +132,7 @@ def __init__(self, llm: AsyncLLM, cache: Cache) -> None: self.disagreements = 0 self.failed = 0 - async def label(self, edit: Edit, subject: str = "") -> EditLabel | None: + async def label(self, edit: Edit, subject: str = "", siblings: int = 0) -> EditLabel | None: key = cache_key(edit, self.llm.settings.model) if (cached := self.cache.get_json(key)) is not None: return EditLabel.model_validate(cached) @@ -100,7 +146,9 @@ async def label(self, edit: Edit, subject: str = "") -> EditLabel | None: self.hint_authoritative += 1 try: - raw = await self.llm.json_call(SYSTEM, _user_prompt(edit, subject), _SCHEMA) + raw = await self.llm.json_call( + SYSTEM, _user_prompt(edit, subject, siblings), _SCHEMA + ) except LLMError: self.failed += 1 return None @@ -128,11 +176,11 @@ async def label(self, edit: Edit, subject: str = "") -> EditLabel | None: async def label_all( self, - edits: Iterable[tuple[Edit, str]], + edits: Iterable[tuple[Edit, str, int]], *, progress=None, ) -> list[EditLabel]: - tasks = [asyncio.create_task(self.label(e, s)) for e, s in edits] + tasks = [asyncio.create_task(self.label(e, s, n)) for e, s, n in edits] out: list[EditLabel] = [] for i, task in enumerate(asyncio.as_completed(tasks), 1): label = await task diff --git a/vero/src/vero/interpret/labeling/taxonomy.py b/vero/src/vero/interpret/labeling/taxonomy.py index 28363fe9..aae212d1 100644 --- a/vero/src/vero/interpret/labeling/taxonomy.py +++ b/vero/src/vero/interpret/labeling/taxonomy.py @@ -19,7 +19,7 @@ import re from enum import StrEnum -TAXONOMY_VERSION = "1" +TAXONOMY_VERSION = "2" class Role(StrEnum): @@ -129,3 +129,54 @@ def direction_of(before: str | None, after: str | None) -> Direction: if not (b and a): return Direction.NA return Direction.UP if float(a.group()) > float(b.group()) else Direction.DOWN + + +# Rubrics. Passing bare enum names left the model to infer sixteen role meanings from +# the names alone, which is the likeliest source of the 16% rule/model disagreement +# and of `add` swamping `reword`. These go into the JSON schema so the definition +# travels with the field rather than sitting in a system prompt the model may skim. +ROLE_RUBRIC: dict[str, str] = { + Role.PROMPT: "System/instruction text sent to the model: task guidance, worked " + "examples, output-format rules. Prose the model reads, not code.", + Role.CONTROL_LOOP: "The agent's main turn loop: how many steps run, when to stop, " + "what happens between turns, dispatch of tool calls.", + Role.TOOL_SURFACE: "The tool DECLARATIONS shown to the model — names, JSON schemas, " + "descriptions. Not the code that implements them.", + Role.TOOL_IMPL: "The implementation behind a tool: shell execution, file reading, " + "search calls, document opening.", + Role.SUBMISSION: "Producing and parsing the final answer: writing the answer file, " + "stripping markers, detecting refusals, forcing an answer.", + Role.MODEL_CLIENT: "How a completion is requested: client construction, sampling " + "parameters, per-request timeouts, SDK retries.", + Role.BUDGET_TURNS: "A limit counted in turns or steps.", + Role.BUDGET_OUTPUT: "A limit on how much tool output is kept or shown.", + Role.BUDGET_WALLCLOCK: "A limit counted in seconds: deadlines, elapsed-time checks.", + Role.CONTEXT_MGMT: "Managing conversation history: compaction, pruning, summarising.", + Role.RETRIEVAL: "Search or index behaviour: query construction, result count, " + "ranking, snippet sizing.", + Role.ENV_SETUP: "Preparing the container before work starts: installing packages, " + "writing fixtures. NOT ignoring build artifacts.", + Role.INITIALIZATION: "Constructor and wiring: state set up once when the agent is " + "created.", + Role.TESTS: "The candidate's own test suite.", + Role.METADATA: "Bookkeeping with no effect on behaviour: version strings, names.", + Role.OTHER: "None of the above, or genuinely unclear from this diff alone.", +} + +ACTION_RUBRIC: dict[str, str] = { + Action.FIX: "Repairs something broken: a crash, a type error, a parse that returned " + "the wrong thing. There must be a defect, not merely a weakness.", + Action.ADD: "Introduces a capability or code path that did not exist. Prefer " + "`reword` if existing prose was rewritten and `tune` if a value moved.", + Action.REMOVE: "Deletes a capability or code path.", + Action.TUNE: "Changes a value or threshold, leaving structure intact.", + Action.RESTRUCTURE: "Reorganises code without intending to change behaviour: " + "extracting a helper, renaming, moving logic.", + Action.REWORD: "Rewrites instruction or description TEXT while keeping its intent. " + "Use this for prompt edits that sharpen or re-phrase guidance — " + "most prompt changes are rewords, not additions.", + Action.REVERT: "Undoes an edit the same optimizer made earlier in this cell. Only " + "when the history shown says so.", + Action.COSMETIC: "Cannot change behaviour: comments, formatting, .gitignore, " + "ignoring compiled artifacts, dead code.", +} diff --git a/vero/src/vero/interpret/models.py b/vero/src/vero/interpret/models.py index 4768e26e..16ec367e 100644 --- a/vero/src/vero/interpret/models.py +++ b/vero/src/vero/interpret/models.py @@ -105,6 +105,9 @@ class Edit(BaseModel): before_value: str | None = None # scalar constants only after_value: str | None = None diff: str = "" # unified diff restricted to this symbol + after_source: str | None = None # the symbol's full text after the edit + in_seed: bool = True # did this symbol exist in the seed at all? + prior_touches: int = 0 # earlier candidates in this cell that touched it @staticmethod def make_id(cell_key: str, sha: str, path: str, symbol: str, diff: str) -> str: From a9891a3e6c78621bb7011efa4dd78efcce70cd65 Mon Sep 17 00:00:00 2001 From: Varun Ursekar Date: Mon, 3 Aug 2026 09:35:01 -0700 Subject: [PATCH 11/16] Add print figures in the Scale brand style, vendored for reproducibility Separate from the HTML page on purpose: that one is interactive and for colleagues to explore, this one produces caption-driven vector PDFs sized at the real placement width for a paper. The style module and both fonts are copied into the package so figures regenerate from this repo without the skill present. Archetypes are taken from the brand skill rather than invented. Role prevalence is a bounded-metric grid, so a sequential heatmap with a group colour bar. Diversity against the null and knob direction each have two values per item where the gap is the story, so both are dumbbells. Rarefaction is a plain line plot -- no archetype fits a saturation curve and forcing one would hide the shape that matters. Three collisions found by looking at the PNGs and fixed, which is the whole reason the house rule says to look. Value labels sat on the leftmost tick label, so they moved above the dots. The diversity legend sat on the terminal-bench row, so it moved to the empty upper left. And rarefaction's direct labels -- the house preference -- were illegible mush, because three curves land on exactly 16 kinds and two on 15, so endpoint labelling cannot separate them; replaced with a legend ordered by final value. Its x ticks are now integers, since a cell count of 2.5 does not exist. matplotlib is not in the project environment and this needs no runtime dependency on it, so rendering runs from a throwaway venv rather than adding one. Co-Authored-By: Claude Opus 5 (1M context) --- .../analysis/brand/fonts/GeistMono.ttf | Bin 0 -> 171948 bytes .../analysis/brand/fonts/HostGrotesk.ttf | Bin 0 -> 81784 bytes .../analysis/brand/scale_brand_style.py | 142 +++++++++++ .../vero/interpret/analysis/paper_figures.py | 234 ++++++++++++++++++ 4 files changed, 376 insertions(+) create mode 100644 vero/src/vero/interpret/analysis/brand/fonts/GeistMono.ttf create mode 100644 vero/src/vero/interpret/analysis/brand/fonts/HostGrotesk.ttf create mode 100644 vero/src/vero/interpret/analysis/brand/scale_brand_style.py create mode 100644 vero/src/vero/interpret/analysis/paper_figures.py diff --git a/vero/src/vero/interpret/analysis/brand/fonts/GeistMono.ttf b/vero/src/vero/interpret/analysis/brand/fonts/GeistMono.ttf new file mode 100644 index 0000000000000000000000000000000000000000..173867dce0580ea751e4b9c558740d34f20ce549 GIT binary patch literal 171948 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

X*mbjKmR9xHcw+Zr90PjHUtsY;yAShzGWRyVbMr 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 literal 0 HcmV?d00001 diff --git a/vero/src/vero/interpret/analysis/brand/fonts/HostGrotesk.ttf b/vero/src/vero/interpret/analysis/brand/fonts/HostGrotesk.ttf new file mode 100644 index 0000000000000000000000000000000000000000..25174036d06c95274f77cb85c157564aaabd6e35 GIT binary patch literal 81784 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, per Nature figure guidelines. + + from scale_brand_style import apply_scale_style, PALETTE, title_block, source_note +""" +from __future__ import annotations + +import os +import matplotlib +import matplotlib.pyplot as plt +from matplotlib import font_manager + +# ---- paper geometry (scaleai-paper.cls: letter, 0.82in L/R margins) ---- +TEXT_WIDTH_IN = 6.86 # \textwidth == \linewidth (single column) + +# ---- fonts: brand Aeonik -> OSS fallbacks Host Grotesk / Geist Mono ---- +# Prefer the copies bundled next to this module (assets/fonts); fall back to a +# user install at ~/.fonts/scale. Both are OFL and redistributable. +_FONT_DIRS = [ + os.path.join(os.path.dirname(os.path.abspath(__file__)), "fonts"), + os.path.expanduser("~/.fonts/scale"), +] +_FILES = ("HostGrotesk.ttf", "GeistMono.ttf") + +FAMILY = "sans-serif" # -> Host Grotesk +MONO = "monospace" # -> Geist Mono + +# ---- Scale 2.0 palette (brand.scale.com) ---- +PALETTE = { + "black": "#000000", "white": "#FFFFFF", + "evergreen": "#193A29", # Evergreen Core + "atlas": "#273252", # Atlas Blue + "tan": "#A8927C", # Foundry Tan + "purple": "#79648C", # Archive Purple + "slate": "#839CB2", # Cloud Slate + "gray_soft": "#929292", "gray_medium": "#C7C7C7", "gray_whisper": "#EAEAEA", +} +INK = "#111111" # titles / body +INK_SUB = "#5C5C5C" # subtitle +INK_FAINT = "#7A7A7A" # source note / faint furniture + +CATEGORICAL = [PALETTE["atlas"], PALETTE["tan"], PALETTE["evergreen"], + PALETTE["purple"], PALETTE["slate"], PALETTE["gray_soft"]] + +# Nature-legible type scale (points, at 1:1 final size) +FS = {"title": 11.0, "subtitle": 8.2, "axis": 8.5, "tick": 8.0, + "label": 8.0, "value": 7.2, "annot": 7.5, "legend": 7.8, "source": 6.8} + + +def _register_fonts(): + for f in _FILES: + for d in _FONT_DIRS: + p = os.path.join(d, f) + if os.path.exists(p): + try: + font_manager.fontManager.addfont(p) + except Exception: + pass + break # first copy found wins + names = {f.name for f in font_manager.fontManager.ttflist} + sans = [n for n in ("Host Grotesk", "Helvetica Neue", "Helvetica", "Arial") if n in names] + sans.append("DejaVu Sans") + mono = [n for n in ("Geist Mono",) if n in names] + ["Menlo", "DejaVu Sans Mono"] + return sans, mono + + +def apply_scale_style(): + sans, mono = _register_fonts() + plt.rcParams.update({ + "font.family": "sans-serif", + "font.sans-serif": sans, + "font.monospace": mono, + "font.size": FS["tick"], + "text.color": INK, + "axes.edgecolor": PALETTE["gray_medium"], + "axes.labelcolor": INK, + "axes.labelsize": FS["axis"], + "axes.linewidth": 0.8, + "axes.grid": False, + "axes.spines.top": False, + "axes.spines.right": False, + "xtick.color": INK_FAINT, "ytick.color": INK, + "xtick.labelcolor": INK, "ytick.labelcolor": INK, + "xtick.labelsize": FS["tick"], "ytick.labelsize": FS["tick"], + "grid.color": PALETTE["gray_whisper"], "grid.linewidth": 0.7, + "figure.facecolor": PALETTE["white"], "axes.facecolor": PALETTE["white"], + "savefig.facecolor": PALETTE["white"], "savefig.dpi": 400, + "legend.frameon": False, "legend.fontsize": FS["legend"], + "lines.linewidth": 1.3, + "pdf.fonttype": 42, "ps.fonttype": 42, "svg.fonttype": "none", + }) + + +def title_block(fig, title, subtitle=None, x=0.012, y=0.975, sub_dy=0.052): + """Bold takeaway title with a lighter gray finding line above it.""" + if subtitle: + fig.text(x, y, subtitle, ha="left", va="top", + fontsize=FS["subtitle"], color=INK_SUB) + fig.text(x, y - sub_dy, title, ha="left", va="top", + fontsize=FS["title"], fontweight="bold", color=INK) + else: + fig.text(x, y, title, ha="left", va="top", + fontsize=FS["title"], fontweight="bold", color=INK) + + +def source_note(fig, text, x=0.5, y=0.013): + fig.text(x, y, text, ha="center", va="bottom", + fontsize=FS["source"], style="italic", color=INK_FAINT) + + +# Ordered accent sequence for categorical encoding (distinct, muted, print-safe). +# Keep to as few as the data needs; black/white and the gray ramp do most work. +ACCENTS = [PALETTE["atlas"], PALETTE["tan"], PALETTE["purple"], + PALETTE["evergreen"], PALETTE["slate"], PALETTE["gray_soft"]] + + +def family_colors(categories): + """Map category names -> accent colours in first-seen order. + + Use this to colour a series by group (e.g. harness family, cohort, method) + with a stable, brand-consistent palette instead of hand-picking colours. + """ + seen = [] + for c in categories: + if c not in seen: + seen.append(c) + return {c: ACCENTS[i % len(ACCENTS)] for i, c in enumerate(seen)} + + +def save(fig, path_no_ext, png_dpi=200): + """Save a figure as vector PDF (drop-in for LaTeX) plus a PNG preview. + + Pass a path without extension; writes ``.pdf`` and ``.png``. + """ + fig.savefig(f"{path_no_ext}.pdf") + fig.savefig(f"{path_no_ext}.png", dpi=png_dpi) diff --git a/vero/src/vero/interpret/analysis/paper_figures.py b/vero/src/vero/interpret/analysis/paper_figures.py new file mode 100644 index 00000000..ff752a4c --- /dev/null +++ b/vero/src/vero/interpret/analysis/paper_figures.py @@ -0,0 +1,234 @@ +"""Print figures in the Scale 2.0 brand style: vector PDF for LaTeX, PNG to review. + +Separate from `figures.py` on purpose. That module builds one interactive HTML page +for colleagues to explore — hover, dark mode, table views. This one produces +caption-driven figures for a paper: no baked-in titles, sized at the real placement +width so point sizes render 1:1, muted palette on white. + +Archetypes are taken from the brand skill rather than invented. Role prevalence is a +bounded-metric grid, so it is a sequential heatmap. Diversity-versus-null and +knob direction each have two values per item where the gap is the story, so both are +dumbbells. Rarefaction is a plain line plot — no archetype fits a saturation curve, +and forcing one would obscure the shape that matters. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +BRAND = Path(__file__).parent / "brand" +sys.path.insert(0, str(BRAND)) + +import matplotlib # noqa: E402 + +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 +import numpy as np # noqa: E402 +from matplotlib.colors import LinearSegmentedColormap # noqa: E402 +from matplotlib.patches import Rectangle # noqa: E402 +from scale_brand_style import ( # noqa: E402 + FS, + INK, + INK_FAINT, + PALETTE, + TEXT_WIDTH_IN, + apply_scale_style, + family_colors, + save, +) + +from vero.interpret.analysis import stats # noqa: E402 + +# Roles grouped so the heatmap's left colour bar means something. Rows must be +# contiguous by group, so this ordering is load-bearing, not cosmetic. +ROLE_GROUPS: list[tuple[str, list[str]]] = [ + ("Agent behaviour", ["prompt", "control_loop", "tool_surface", "tool_impl", + "retrieval", "submission"]), + ("Budgets", ["budget_turns", "budget_output", "budget_wallclock", "context_mgmt"]), + ("Plumbing", ["model_client", "initialization", "env_setup"]), + ("Not the agent", ["tests", "metadata", "other"]), +] +SHORT = { + "browsecomp-plus": "browsecomp", "swe-atlas-qna": "swe-atlas", + "terminal-bench": "terminal", "gaia-shell": "gaia-shell*", "officeqa": "officeqa", +} + + +def fig_prevalence(rows: list[dict], out: Path) -> None: + """Heatmap: share of cells per benchmark that ever made each kind of edit.""" + roles, table = stats.prevalence(rows) + benches = [b for b in stats.BENCH_ORDER if b in next(iter(table.values()))] + + ordered: list[tuple[str, str, list[float]]] = [] + for group, members in ROLE_GROUPS: + for role in members: + if role in table: + ordered.append( + (group, role, [table[role][b][0] / table[role][b][1] for b in benches]) + ) + counts = { + (r, b): table[r][b] for _, r, _ in ordered for b in benches + } + + M = np.array([r[2] for r in ordered]) + labels = [r[1] for r in ordered] + color = family_colors([r[0] for r in ordered]) + groups: list[list] = [] + for i, (cat, _, _) in enumerate(ordered): + if groups and groups[-1][0] == cat: + groups[-1][2] = i + else: + groups.append([cat, i, i]) + + cmap = LinearSegmentedColormap.from_list( + "scale_blue", ["#FFFFFF", PALETTE["slate"], PALETTE["atlas"]] + ) + nrow, ncol = M.shape + fig, ax = plt.subplots(figsize=(TEXT_WIDTH_IN, 0.30 * nrow + 0.55)) + fig.subplots_adjust(left=0.32, right=0.985, top=0.93, bottom=0.015) + ax.imshow(M, cmap=cmap, vmin=0, vmax=1, aspect="auto") + for i in range(nrow): + for j in range(ncol): + hit, tot = counts[(labels[i], benches[j])] + ax.text(j, i, f"{hit}/{tot}", ha="center", va="center", + fontsize=FS["value"], color="white" if M[i, j] > 0.62 else INK) + ax.set_xticks(range(ncol)) + ax.set_xticklabels([SHORT[b] for b in benches], fontsize=FS["label"], color=INK) + ax.xaxis.set_ticks_position("top") + ax.set_yticks(range(nrow)) + ax.set_yticklabels(labels, fontsize=FS["label"], color=INK) + ax.tick_params(length=0) + for s in ax.spines.values(): + s.set_visible(False) + ax.set_xticks(np.arange(-0.5, ncol, 1), minor=True) + ax.set_yticks(np.arange(-0.5, nrow, 1), minor=True) + ax.grid(which="minor", color="white", lw=1.2) + ax.tick_params(which="minor", length=0) + + trans = ax.get_yaxis_transform() + for cat, r0, r1 in groups: + ax.add_patch(Rectangle((-0.32, r0 - 0.5), 0.024, (r1 - r0 + 1), transform=trans, + facecolor=color[cat], edgecolor="white", lw=0.6, + clip_on=False, zorder=5)) + ax.text(-0.345, (r0 + r1) / 2, cat, transform=trans, rotation=90, + ha="center", va="center", fontsize=6.4, color=INK) + save(fig, str(out / "prevalence")) + plt.close(fig) + + +def fig_diversity(rows: list[dict], out: Path) -> None: + """Dumbbell: observed repertoire distance against the permutation null.""" + data = stats.jaccard(rows) + benches = [b for b in stats.BENCH_ORDER if b in data][::-1] + y = np.arange(len(benches)) + + fig, ax = plt.subplots(figsize=(TEXT_WIDTH_IN, 0.48 * len(benches) + 0.95)) + fig.subplots_adjust(left=0.20, right=0.965, top=0.93, bottom=0.175) + for i, b in enumerate(benches): + d = data[b] + # Null interval as a light band, so the dumbbell reads against it. + ax.plot([d["null_lo"], d["null_hi"]], [i, i], lw=6.0, + color=PALETTE["gray_whisper"], solid_capstyle="round", zorder=1) + ax.plot([d["observed"], d["null_mean"]], [i, i], lw=1.4, + color=PALETTE["atlas"], zorder=2) + ax.plot(d["null_mean"], i, "o", ms=6, mfc="white", + mec=PALETTE["gray_soft"], mew=1.4, zorder=3) + ax.plot(d["observed"], i, "o", ms=6.5, color=PALETTE["evergreen"], zorder=4) + ax.text(d["observed"], i + 0.22, f"{d['observed']:.3f}", ha="center", + va="bottom", fontsize=FS["value"], color=INK) + ax.set_yticks(y) + ax.set_yticklabels([SHORT[b] for b in benches], fontsize=FS["label"], color=INK) + ax.set_xlabel("mean pairwise Jaccard distance between cells' edit repertoires", + fontsize=FS["axis"], color=INK) + ax.tick_params(length=0) + ax.grid(axis="x", color=PALETTE["gray_whisper"], lw=0.8) + ax.set_axisbelow(True) + for s in ("top", "right", "left"): + ax.spines[s].set_visible(False) + ax.spines["bottom"].set_color(PALETTE["gray_medium"]) + handles = [ + plt.Line2D([], [], marker="o", ls="none", ms=6, color=PALETTE["evergreen"], + label="observed"), + plt.Line2D([], [], marker="o", ls="none", ms=6, mfc="white", + mec=PALETTE["gray_soft"], mew=1.4, label="null mean"), + plt.Line2D([], [], lw=6, color=PALETTE["gray_whisper"], label="null 95%"), + ] + ax.legend(handles=handles, loc="upper left", frameon=False, + fontsize=FS["legend"], handletextpad=0.5, borderaxespad=0.2) + save(fig, str(out / "diversity")) + plt.close(fig) + + +def fig_rarefaction(rows: list[dict], out: Path) -> None: + """Line: distinct edit kinds discovered as cells are added.""" + curves = stats.rarefaction(rows) + benches = [b for b in stats.BENCH_ORDER if b in curves] + colors = family_colors(benches) + + fig, ax = plt.subplots(figsize=(TEXT_WIDTH_IN * 0.78, 2.9)) + fig.subplots_adjust(left=0.105, right=0.755, top=0.96, bottom=0.165) + # Direct labels are the house preference but cannot work here: three curves land + # on exactly 16 kinds and two on 15, so endpoint labels overlap into mush. A + # legend ordered by final value keeps identity next to the visual order instead. + for b in sorted(benches, key=lambda k: -curves[k][-1]): + pts = curves[b] + ax.plot(np.arange(1, len(pts) + 1), pts, lw=1.6, color=colors[b], + label=SHORT[b]) + ax.set_xlabel("cells sampled", fontsize=FS["axis"], color=INK) + ax.set_ylabel("distinct edit kinds seen", fontsize=FS["axis"], color=INK) + ax.grid(color=PALETTE["gray_whisper"], lw=0.8) + ax.set_axisbelow(True) + for s in ("top", "right"): + ax.spines[s].set_visible(False) + for s in ("bottom", "left"): + ax.spines[s].set_color(PALETTE["gray_medium"]) + ax.tick_params(colors=INK_FAINT, labelsize=FS["tick"], length=2) + ax.set_xticks([1, 5, 10, 15, 20]) # cells are discrete; 2.5 is not a cell count + ax.legend(loc="center left", bbox_to_anchor=(1.01, 0.5), frameon=False, + fontsize=FS["legend"], handlelength=1.4, handletextpad=0.6, + labelspacing=0.55) + save(fig, str(out / "rarefaction")) + plt.close(fig) + + +def fig_knob_direction(rows: list[dict], edits: dict[str, dict], out: Path) -> None: + """Dumbbell: two directional counts per constant — raised against lowered.""" + data = stats.tuning_direction(rows, edits, top=10) + if not data: + return + data = data[::-1] + fig, ax = plt.subplots(figsize=(TEXT_WIDTH_IN, 0.36 * len(data) + 0.85)) + fig.subplots_adjust(left=0.34, right=0.965, top=0.965, bottom=0.155) + for i, (sym, up, dn) in enumerate(data): + ax.plot([dn, up], [i, i], lw=1.3, color=PALETTE["gray_medium"], zorder=1) + ax.plot(dn, i, "o", ms=6, mfc="white", mec=PALETTE["tan"], mew=1.5, zorder=3) + ax.plot(up, i, "o", ms=6.5, color=PALETTE["evergreen"], zorder=4) + ax.set_yticks(range(len(data))) + ax.set_yticklabels([s for s, _, _ in data], fontsize=FS["label"], color=INK) + ax.set_xlabel("edits changing this constant", fontsize=FS["axis"], color=INK) + ax.tick_params(length=0) + ax.grid(axis="x", color=PALETTE["gray_whisper"], lw=0.8) + ax.set_axisbelow(True) + for s in ("top", "right", "left"): + ax.spines[s].set_visible(False) + ax.spines["bottom"].set_color(PALETTE["gray_medium"]) + ax.legend(handles=[ + plt.Line2D([], [], marker="o", ls="none", ms=6.5, + color=PALETTE["evergreen"], label="raised"), + plt.Line2D([], [], marker="o", ls="none", ms=6, mfc="white", + mec=PALETTE["tan"], mew=1.5, label="lowered"), + ], loc="lower right", frameon=False, fontsize=FS["legend"], + handletextpad=0.5, borderaxespad=0.1) + save(fig, str(out / "knob_direction")) + plt.close(fig) + + +def render_all(rows: list[dict], edits: dict[str, dict], out: Path) -> list[str]: + apply_scale_style() + out.mkdir(parents=True, exist_ok=True) + fig_prevalence(rows, out) + fig_diversity(rows, out) + fig_rarefaction(rows, out) + fig_knob_direction(rows, edits, out) + return sorted(p.name for p in out.iterdir()) From 7d7d6535333f3fbdce835d852cf9aa901f4eb359 Mon Sep 17 00:00:00 2001 From: Varun Ursekar Date: Mon, 3 Aug 2026 09:43:09 -0700 Subject: [PATCH 12/16] Add figure specs: semantics in the repo, styling in the style module A figure has two parts that change on different schedules. What it claims moves when the analysis moves; how it looks moves when the venue or brand does, usually late and all at once. Holding both in a plotting script means a restyle can silently alter a claim, and "where did this number come from?" is answered by reading matplotlib. So one Markdown file per figure owns the semantics -- takeaway, caption, data table, encoding intent, provenance -- and the style module owns the look. A restyle touches the style module and never the spec; if a restyle changes a number, that is a bug in the restyle, which is the whole point of the split. Two rules carry most of the value. The takeaway is one falsifiable sentence, so a figure that cannot state a claim gets cut instead of padding the page budget. And the data table is a contract rather than documentation: a reviewer checks the figure against it, and a restyle is verified by confirming it still renders identically. Style notes hold only figure-specific deviations with their reasons. Figure 3 needs a legend rather than the house-preferred end labels because three benchmarks land on exactly 16.0 categories and no nudge separates coincident values -- exactly the kind of decision the next person would otherwise undo. Writing the specs surfaced two things the figures had left implicit: gaia-shell must be excluded from Figure 1's cross-benchmark reads but is legitimate in Figure 2, where the statistic is within-benchmark; and Figure 4 shows only the top 10 constants, a cut that was invisible in the image. The skill defining this format is .claude/paper-figure-specs (untracked, matching how .claude skills live here). Co-Authored-By: Claude Opus 5 (1M context) --- .../figures/figure_01_prevalence.md | 81 +++++++++++++++++++ .../figures/figure_02_diversity.md | 63 +++++++++++++++ .../figures/figure_03_rarefaction.md | 60 ++++++++++++++ .../figures/figure_04_knob_direction.md | 63 +++++++++++++++ 4 files changed, 267 insertions(+) create mode 100644 harness-engineering-bench/figures/figure_01_prevalence.md create mode 100644 harness-engineering-bench/figures/figure_02_diversity.md create mode 100644 harness-engineering-bench/figures/figure_03_rarefaction.md create mode 100644 harness-engineering-bench/figures/figure_04_knob_direction.md diff --git a/harness-engineering-bench/figures/figure_01_prevalence.md b/harness-engineering-bench/figures/figure_01_prevalence.md new file mode 100644 index 00000000..a9070595 --- /dev/null +++ b/harness-engineering-bench/figures/figure_01_prevalence.md @@ -0,0 +1,81 @@ +--- +id: figure_01_prevalence +archetype: heatmap +script: vero.interpret.analysis.paper_figures::fig_prevalence +outputs: [prevalence.pdf, prevalence.png] +status: review +--- + +## Takeaway + +Every optimizer edits the instruction prompt and the control loop, but which other +parts of the harness they touch is dictated by the benchmark, not by the optimizer. + +## Caption + +Share of optimization runs that made at least one edit of each kind, per benchmark +(20 runs each, 100 total). Cell shading is the share; the annotation is the count. +Prompt and control-loop edits are near-universal, while the remaining categories vary +sharply by benchmark — submission-path edits appear in 15 of 20 swe-atlas-qna runs but +2 of 20 terminal-bench runs, and retrieval edits appear almost only in +browsecomp-plus, the sole benchmark with a retrieval corpus. Counted per run rather +than per edit, since runs produced between 1 and 18 candidates and an edit-weighted +count would measure verbosity instead of coverage. The left colour bar groups +categories. \textsuperscript{*}gaia-shell's seed is an empty skeleton, so every +category is present there by construction and its column is not comparable with the +others. Reward is not shown; see \Cref{tab:signal-validity} for why score-versus-category +comparisons are unsupportable in this corpus. + +## What the reader should see + +- Read down the first two rows first: `prompt` and `control_loop` are dark across every + column. That is the universal behaviour. +- Then read across `submission`, `retrieval`, `tool_impl` — the variance between columns + is the finding. Benchmark, not optimizer, selects the target. +- Shading encodes the same quantity as the annotation, deliberately: shading carries the + pattern at a glance, the fraction carries the exact read. Nothing else is encoded. +- `gaia-shell` is marked and excluded from any cross-benchmark claim. +- Absent by design: reward, and any ordering of benchmarks by score. + +## Data + +Cells that made ≥1 edit of each kind, out of 20 per benchmark. Rows ordered by mean +share across benchmarks, which is the order the figure uses. + +| role | browsecomp-plus | officeqa | swe-atlas-qna | terminal-bench | gaia-shell* | +|---|---|---|---|---|---| +| prompt | 20/20 | 19/20 | 20/20 | 20/20 | 18/20 | +| control_loop | 17/20 | 19/20 | 18/20 | 17/20 | 20/20 | +| budget_turns | 15/20 | 17/20 | 16/20 | 18/20 | 11/20 | +| tool_surface | 15/20 | 7/20 | 15/20 | 10/20 | 18/20 | +| tool_impl | 16/20 | 7/20 | 11/20 | 10/20 | 18/20 | +| other | 14/20 | 8/20 | 9/20 | 9/20 | 20/20 | +| tests | 11/20 | 7/20 | 14/20 | 10/20 | 16/20 | +| model_client | 11/20 | 14/20 | 10/20 | 8/20 | 11/20 | +| metadata | 9/20 | 7/20 | 6/20 | 11/20 | 19/20 | +| submission | 7/20 | 8/20 | 15/20 | 2/20 | 18/20 | +| budget_output | 5/20 | 13/20 | 11/20 | 7/20 | 7/20 | +| budget_wallclock | 5/20 | 7/20 | 7/20 | 6/20 | 12/20 | +| context_mgmt | 7/20 | 7/20 | 6/20 | 10/20 | 7/20 | +| initialization | 6/20 | 4/20 | 4/20 | 5/20 | 17/20 | +| env_setup | 7/20 | 5/20 | 3/20 | 7/20 | 13/20 | +| retrieval | 9/20 | 0/20 | 1/20 | 0/20 | 2/20 | + +## Style notes + +- Column tick labels sit on top, not bottom: with 16 rows the eye enters at the top and + the header should be adjacent to the first row it applies to. +- Benchmark names are abbreviated to keep all five columns inside the text width without + rotation; rotated column headers cost more legibility than the abbreviation does. + +## Provenance + +``` +vero interpret extract --runs runs/{officeqa,browsecomp-plus,terminal-bench,swe-atlas-qna,gaia-shell} \ + --cells-file scope100.json +vero interpret edits +vero interpret label --model gpt-5.4-mini +python -c "from vero.interpret.analysis import stats; stats.prevalence(rows)" +``` +3,986 symbol-scoped edits over 100 runs. Roles set by deterministic rule where the +path, symbol kind or name settles it (2,114 edits) and by model otherwise (1,872). diff --git a/harness-engineering-bench/figures/figure_02_diversity.md b/harness-engineering-bench/figures/figure_02_diversity.md new file mode 100644 index 00000000..9e5ce852 --- /dev/null +++ b/harness-engineering-bench/figures/figure_02_diversity.md @@ -0,0 +1,63 @@ +--- +id: figure_02_diversity +archetype: dumbbell +script: vero.interpret.analysis.paper_figures::fig_diversity +outputs: [diversity.pdf, diversity.png] +status: review +--- + +## Takeaway + +Independent optimizers converge on more similar repertoires of edits than chance +allows, in every benchmark. + +## Caption + +Mean pairwise Jaccard distance between the sets of edit categories that different +optimization runs touched (filled marker), against a permutation null that holds each +run's repertoire size and the corpus-wide category frequencies fixed and reshuffles the +assignment (open marker, null mean; band, null 95\% interval). All five benchmarks fall +below their null, so runs are more alike than independent draws from the same marginal +would be. The raw distance alone carries no information without the null: a value near +0.5 is equally consistent with genuine diversity and with every run sampling a few +categories from one skewed distribution. 20 runs per benchmark, 190 pairs each. + +## What the reader should see + +- The filled marker sits left of the grey band in every row. That gap is the whole + finding: left of the null means more similar than chance. +- Position on the x-axis is the only quantitative channel. The connector shows the size + of the departure from the null mean; it encodes nothing extra. +- Open versus filled distinguishes null from observed. Colour does not carry benchmark + identity here, because the y-axis label already does. +- Absent by design: a significance star or p-value. The null interval is shown directly + so the reader judges the margin rather than a threshold. + +## Data + +| benchmark | runs | observed | null 2.5% | null 97.5% | null mean | verdict | +|---|---|---|---|---|---|---| +| browsecomp-plus | 20 | 0.561 | 0.630 | 0.667 | 0.651 | converged | +| officeqa | 20 | 0.564 | 0.663 | 0.709 | 0.687 | converged | +| swe-atlas-qna | 20 | 0.538 | 0.601 | 0.653 | 0.630 | converged | +| terminal-bench | 20 | 0.572 | 0.643 | 0.691 | 0.670 | converged | +| gaia-shell | 20 | 0.341 | 0.399 | 0.446 | 0.425 | converged | + +500 permutations per benchmark, seed 0. + +## Style notes + +- Value labels sit above the marker, not beside it: gaia-shell's observed value is at + the far left of the axis and a left-placed label collided with its tick label. +- Legend is upper-left. Lower-right — the usual choice — overlapped the terminal-bench + row, whose null band extends furthest right. +- gaia-shell is included here, unlike Figure 1, because this statistic is computed + within a benchmark and never compared across them, so its constructed seed does not + distort it. + +## Provenance + +``` +python -c "from vero.interpret.analysis import stats; stats.jaccard(rows, trials=500, seed=0)" +``` +Same 3,986-edit label set as Figure 1. diff --git a/harness-engineering-bench/figures/figure_03_rarefaction.md b/harness-engineering-bench/figures/figure_03_rarefaction.md new file mode 100644 index 00000000..1036a221 --- /dev/null +++ b/harness-engineering-bench/figures/figure_03_rarefaction.md @@ -0,0 +1,60 @@ +--- +id: figure_03_rarefaction +archetype: line +script: vero.interpret.analysis.paper_figures::fig_rarefaction +outputs: [rarefaction.pdf, rarefaction.png] +status: review +--- + +## Takeaway + +Five independent optimizers exhaust almost the entire repertoire of edit categories; +the next fifteen add nearly nothing. + +## Caption + +Distinct edit categories discovered as optimization runs are added, averaged over 200 +random orderings of the 20 runs per benchmark (16 categories available). Every curve is +within one category of its final value by the fifth run and flat thereafter, so the +marginal contribution of an additional independent optimizer is close to zero. This is +the same convergence that \Cref{fig:diversity} establishes against a null, viewed as a +saturation curve rather than a distance. + +## What the reader should see + +- The shape, not the ordering: every curve bends hard before x=5 and is flat by x=8. +- Vertical position at the right edge is the ceiling each benchmark reached (15 or 16 of + 16). The gap between curves is not the point and should not be over-read. +- Colour distinguishes benchmark only; the legend is ordered by final value so legend + order matches the visual order at the right edge. +- Absent by design: error bands. The averaging over 200 orderings is what the curve is; + a band would imply sampling error over runs, which is not what varies here. + +## Data + +Mean distinct categories after k runs, of 16 available. + +| benchmark | k=1 | k=2 | k=5 | k=10 | k=20 | +|---|---|---|---|---|---| +| browsecomp-plus | 8.3 | 12.0 | 15.2 | 16.0 | 16.0 | +| officeqa | 7.7 | 10.6 | 13.9 | 14.9 | 15.0 | +| swe-atlas-qna | 8.8 | 11.7 | 14.3 | 15.4 | 16.0 | +| terminal-bench | 7.5 | 10.5 | 13.8 | 14.7 | 15.0 | +| gaia-shell | 11.4 | 13.9 | 15.2 | 15.8 | 16.0 | + +200 random orderings per benchmark, seed 0. + +## Style notes + +- Legend instead of direct end-labels, which is the house preference. Three benchmarks + land on exactly 16.0 and two on 15.0, so endpoint labels overlap into illegibility; + no nudge fixes coincident values. +- x ticks forced to integers. A cell count of 2.5 does not exist and the default tick + locator produced half-steps. + +## Provenance + +``` +python -c "from vero.interpret.analysis import stats; stats.rarefaction(rows, trials=200, seed=0)" +``` +Same 3,986-edit label set as Figure 1. diff --git a/harness-engineering-bench/figures/figure_04_knob_direction.md b/harness-engineering-bench/figures/figure_04_knob_direction.md new file mode 100644 index 00000000..558967e6 --- /dev/null +++ b/harness-engineering-bench/figures/figure_04_knob_direction.md @@ -0,0 +1,63 @@ +--- +id: figure_04_knob_direction +archetype: dumbbell +script: vero.interpret.analysis.paper_figures::fig_knob_direction +outputs: [knob_direction.pdf, knob_direction.png] +status: review +--- + +## Takeaway + +Optimizers raise turn and step budgets almost without exception, but move output caps +and timeouts in both directions. + +## Caption + +Numeric constants most often changed, by direction of change (filled marker, raised; +open marker, lowered). Turn and step budgets move overwhelmingly upward — `MAX_TURNS` +raised in 63 edits against 14 lowered — while output-truncation caps and per-command +timeouts are as often reduced as increased. Direction is derived by comparing the +literal before and after values, not inferred from the commit message. Constants +touched by reformatting without a value change are excluded. Counted per edit rather +than per run, since one run may retune the same constant several times and each +retuning is a separate decision. + +## What the reader should see + +- The top two rows against the rest: budgets go up, everything else is mixed. +- `MAX_TOOL_OUTPUT_CHARS` is the notable inversion — lowered slightly more often than + raised, the only frequently-touched constant where that holds. +- Horizontal position is a count, and the connector's length is the imbalance between + the two directions. Open versus filled is the only other channel. +- Absent by design: the magnitudes of the changes. A constant moved 24→100 and 24→32 + count the same here; direction is the claim, size is not. + +## Data + +| constant | raised | lowered | +|---|---|---| +| MAX_TURNS | 63 | 14 | +| MAX_STEPS | 26 | 5 | +| MAX_TOOL_OUTPUT_CHARS | 13 | 16 | +| MAX_OUTPUT_CHARS | 3 | 3 | +| SOFT_DEADLINE_SEC | 2 | 3 | +| COMMAND_TIMEOUT_SEC | 1 | 4 | +| N_ATTEMPTS | 1 | 2 | +| MAX_HISTORY_CHARS | 0 | 2 | +| MAX_CONCURRENT_SHELLS | 2 | 0 | +| RESEARCH_DEADLINE_SEC | 0 | 2 | + +Corpus totals across all scalar constants: 137 raised, 78 lowered. + +## Style notes + +- Top 10 constants only, by total edits. The tail is single-digit and would add rows + without adding signal; the cut is stated here so it is not read as the full set. + +## Provenance + +``` +python -c "from vero.interpret.analysis import stats; stats.tuning_direction(rows, edits, top=10)" +``` +Direction from `taxonomy.direction_of` over captured before/after literals; same +3,986-edit set as Figure 1. From edb6be22d65d12f6b4a60cb9e030ff638bb93c3d Mon Sep 17 00:00:00 2001 From: Varun Ursekar Date: Mon, 3 Aug 2026 09:54:52 -0700 Subject: [PATCH 13/16] One directory per figure A flat figures/ directory is tolerable for four and unusable for forty: specs, vectors, previews and stale renders interleave alphabetically, nothing travels as a unit, and retiring a figure means picking its files out of a pile. Each figure now owns a directory named for its id, holding the spec beside its outputs, and every file inside is named for the id too -- so a figure copied out of the tree is still identifiable and \includegraphics paths read unambiguously. The renderers no longer name their own output files. Each takes an explicit stem path and a FIGURES registry maps id to renderer, which is what makes the layout a property of the pipeline rather than a convention someone has to remember. Converted now rather than later on purpose: starting flat and migrating means fixing every \includegraphics path and every renderer at exactly the point where there are most of them to fix. The skill now prescribes the layout from the first figure. Co-Authored-By: Claude Opus 5 (1M context) --- .../figure_01_prevalence.md | 2 +- .../figure_02_diversity.md | 2 +- .../figure_03_rarefaction.md | 2 +- .../figure_04_knob_direction.md | 2 +- .../vero/interpret/analysis/paper_figures.py | 45 +++++++++++++------ 5 files changed, 35 insertions(+), 18 deletions(-) rename harness-engineering-bench/figures/{ => figure_01_prevalence}/figure_01_prevalence.md (98%) rename harness-engineering-bench/figures/{ => figure_02_diversity}/figure_02_diversity.md (97%) rename harness-engineering-bench/figures/{ => figure_03_rarefaction}/figure_03_rarefaction.md (97%) rename harness-engineering-bench/figures/{ => figure_04_knob_direction}/figure_04_knob_direction.md (97%) diff --git a/harness-engineering-bench/figures/figure_01_prevalence.md b/harness-engineering-bench/figures/figure_01_prevalence/figure_01_prevalence.md similarity index 98% rename from harness-engineering-bench/figures/figure_01_prevalence.md rename to harness-engineering-bench/figures/figure_01_prevalence/figure_01_prevalence.md index a9070595..aaf3fd95 100644 --- a/harness-engineering-bench/figures/figure_01_prevalence.md +++ b/harness-engineering-bench/figures/figure_01_prevalence/figure_01_prevalence.md @@ -2,7 +2,7 @@ id: figure_01_prevalence archetype: heatmap script: vero.interpret.analysis.paper_figures::fig_prevalence -outputs: [prevalence.pdf, prevalence.png] +outputs: [figure_01_prevalence.pdf, figure_01_prevalence.png] status: review --- diff --git a/harness-engineering-bench/figures/figure_02_diversity.md b/harness-engineering-bench/figures/figure_02_diversity/figure_02_diversity.md similarity index 97% rename from harness-engineering-bench/figures/figure_02_diversity.md rename to harness-engineering-bench/figures/figure_02_diversity/figure_02_diversity.md index 9e5ce852..559367f2 100644 --- a/harness-engineering-bench/figures/figure_02_diversity.md +++ b/harness-engineering-bench/figures/figure_02_diversity/figure_02_diversity.md @@ -2,7 +2,7 @@ id: figure_02_diversity archetype: dumbbell script: vero.interpret.analysis.paper_figures::fig_diversity -outputs: [diversity.pdf, diversity.png] +outputs: [figure_02_diversity.pdf, figure_02_diversity.png] status: review --- diff --git a/harness-engineering-bench/figures/figure_03_rarefaction.md b/harness-engineering-bench/figures/figure_03_rarefaction/figure_03_rarefaction.md similarity index 97% rename from harness-engineering-bench/figures/figure_03_rarefaction.md rename to harness-engineering-bench/figures/figure_03_rarefaction/figure_03_rarefaction.md index 1036a221..ff1447f7 100644 --- a/harness-engineering-bench/figures/figure_03_rarefaction.md +++ b/harness-engineering-bench/figures/figure_03_rarefaction/figure_03_rarefaction.md @@ -2,7 +2,7 @@ id: figure_03_rarefaction archetype: line script: vero.interpret.analysis.paper_figures::fig_rarefaction -outputs: [rarefaction.pdf, rarefaction.png] +outputs: [figure_03_rarefaction.pdf, figure_03_rarefaction.png] status: review --- diff --git a/harness-engineering-bench/figures/figure_04_knob_direction.md b/harness-engineering-bench/figures/figure_04_knob_direction/figure_04_knob_direction.md similarity index 97% rename from harness-engineering-bench/figures/figure_04_knob_direction.md rename to harness-engineering-bench/figures/figure_04_knob_direction/figure_04_knob_direction.md index 558967e6..47e40982 100644 --- a/harness-engineering-bench/figures/figure_04_knob_direction.md +++ b/harness-engineering-bench/figures/figure_04_knob_direction/figure_04_knob_direction.md @@ -2,7 +2,7 @@ id: figure_04_knob_direction archetype: dumbbell script: vero.interpret.analysis.paper_figures::fig_knob_direction -outputs: [knob_direction.pdf, knob_direction.png] +outputs: [figure_04_knob_direction.pdf, figure_04_knob_direction.png] status: review --- diff --git a/vero/src/vero/interpret/analysis/paper_figures.py b/vero/src/vero/interpret/analysis/paper_figures.py index ff752a4c..d7e66a41 100644 --- a/vero/src/vero/interpret/analysis/paper_figures.py +++ b/vero/src/vero/interpret/analysis/paper_figures.py @@ -55,7 +55,7 @@ } -def fig_prevalence(rows: list[dict], out: Path) -> None: +def fig_prevalence(rows: list[dict], stem: Path) -> None: """Heatmap: share of cells per benchmark that ever made each kind of edit.""" roles, table = stats.prevalence(rows) benches = [b for b in stats.BENCH_ORDER if b in next(iter(table.values()))] @@ -113,11 +113,11 @@ def fig_prevalence(rows: list[dict], out: Path) -> None: clip_on=False, zorder=5)) ax.text(-0.345, (r0 + r1) / 2, cat, transform=trans, rotation=90, ha="center", va="center", fontsize=6.4, color=INK) - save(fig, str(out / "prevalence")) + save(fig, str(stem)) plt.close(fig) -def fig_diversity(rows: list[dict], out: Path) -> None: +def fig_diversity(rows: list[dict], stem: Path) -> None: """Dumbbell: observed repertoire distance against the permutation null.""" data = stats.jaccard(rows) benches = [b for b in stats.BENCH_ORDER if b in data][::-1] @@ -156,11 +156,11 @@ def fig_diversity(rows: list[dict], out: Path) -> None: ] ax.legend(handles=handles, loc="upper left", frameon=False, fontsize=FS["legend"], handletextpad=0.5, borderaxespad=0.2) - save(fig, str(out / "diversity")) + save(fig, str(stem)) plt.close(fig) -def fig_rarefaction(rows: list[dict], out: Path) -> None: +def fig_rarefaction(rows: list[dict], stem: Path) -> None: """Line: distinct edit kinds discovered as cells are added.""" curves = stats.rarefaction(rows) benches = [b for b in stats.BENCH_ORDER if b in curves] @@ -188,11 +188,11 @@ def fig_rarefaction(rows: list[dict], out: Path) -> None: ax.legend(loc="center left", bbox_to_anchor=(1.01, 0.5), frameon=False, fontsize=FS["legend"], handlelength=1.4, handletextpad=0.6, labelspacing=0.55) - save(fig, str(out / "rarefaction")) + save(fig, str(stem)) plt.close(fig) -def fig_knob_direction(rows: list[dict], edits: dict[str, dict], out: Path) -> None: +def fig_knob_direction(rows: list[dict], edits: dict[str, dict], stem: Path) -> None: """Dumbbell: two directional counts per constant — raised against lowered.""" data = stats.tuning_direction(rows, edits, top=10) if not data: @@ -220,15 +220,32 @@ def fig_knob_direction(rows: list[dict], edits: dict[str, dict], out: Path) -> N mec=PALETTE["tan"], mew=1.5, label="lowered"), ], loc="lower right", frameon=False, fontsize=FS["legend"], handletextpad=0.5, borderaxespad=0.1) - save(fig, str(out / "knob_direction")) + save(fig, str(stem)) plt.close(fig) +# One directory per figure, named for its id and holding the spec beside its outputs. +# A flat directory is tolerable for four figures and unusable for forty: outputs, +# specs and stale renders interleave alphabetically and nothing travels as a unit. +FIGURES: list[tuple[str, str]] = [ + ("figure_01_prevalence", "fig_prevalence"), + ("figure_02_diversity", "fig_diversity"), + ("figure_03_rarefaction", "fig_rarefaction"), + ("figure_04_knob_direction", "fig_knob_direction"), +] + + def render_all(rows: list[dict], edits: dict[str, dict], out: Path) -> list[str]: apply_scale_style() - out.mkdir(parents=True, exist_ok=True) - fig_prevalence(rows, out) - fig_diversity(rows, out) - fig_rarefaction(rows, out) - fig_knob_direction(rows, edits, out) - return sorted(p.name for p in out.iterdir()) + written: list[str] = [] + for fid, func in FIGURES: + folder = out / fid + folder.mkdir(parents=True, exist_ok=True) + stem = folder / fid + renderer = globals()[func] + if func == "fig_knob_direction": + renderer(rows, edits, stem) + else: + renderer(rows, stem) + written.append(f"{fid}/{fid}.{{pdf,png}}") + return written From a10136b354a3b18e46725e4902d9d01dd0891f08 Mon Sep 17 00:00:00 2001 From: Varun Ursekar Date: Mon, 3 Aug 2026 10:01:24 -0700 Subject: [PATCH 14/16] Title-case display names, kept out of the taxonomy Roles and benchmarks now read as Control Loop, Tool Implementation, Turn Budget, BrowseComp-Plus, OfficeQA rather than snake_case identifiers. The mapping lives in analysis/display.py, not in the taxonomy. Renaming the enum values would have invalidated all 3,986 cached labels -- TAXONOMY_VERSION is part of the cache key -- and broken the identifiers that are dictionary keys and JSON fields throughout. Presentation and vocabulary are separate concerns and this keeps them so; both the print figures and the HTML page read from the one table. Benchmarks use published spellings rather than a mechanical transformation, since these appear in a paper where Terminal-Bench and GAIA are the names a reader looks up. That means BrowseComp-Plus rather than BrowseCompPlus. Code symbols are deliberately left alone. MAX_TURNS is a real identifier in the optimized source and prettifying it would misrepresent what the optimizer edited. One regression caught while checking the render: the constructed-seed marker on GAIA-Shell had been baked into the old label string, so switching to clean display names silently dropped the caveat from the column header. It is now appended explicitly from stats.CONSTRUCTED_SEED. Spec Data tables use the display names too -- a reviewer checks the figure against that table, so it has to match what the figure shows -- with the identifier mapping noted once under Provenance. Co-Authored-By: Claude Opus 5 (1M context) --- .../figure_01_prevalence.md | 38 ++++---- .../figure_02_diversity.md | 14 +-- .../figure_03_rarefaction.md | 14 +-- .../figure_04_knob_direction.md | 4 + vero/src/vero/interpret/analysis/display.py | 88 +++++++++++++++++++ vero/src/vero/interpret/analysis/figures.py | 14 +-- .../vero/interpret/analysis/paper_figures.py | 19 ++-- 7 files changed, 148 insertions(+), 43 deletions(-) create mode 100644 vero/src/vero/interpret/analysis/display.py diff --git a/harness-engineering-bench/figures/figure_01_prevalence/figure_01_prevalence.md b/harness-engineering-bench/figures/figure_01_prevalence/figure_01_prevalence.md index aaf3fd95..8e86080b 100644 --- a/harness-engineering-bench/figures/figure_01_prevalence/figure_01_prevalence.md +++ b/harness-engineering-bench/figures/figure_01_prevalence/figure_01_prevalence.md @@ -42,24 +42,24 @@ comparisons are unsupportable in this corpus. Cells that made ≥1 edit of each kind, out of 20 per benchmark. Rows ordered by mean share across benchmarks, which is the order the figure uses. -| role | browsecomp-plus | officeqa | swe-atlas-qna | terminal-bench | gaia-shell* | +| role | BrowseComp-Plus | OfficeQA | SWE-Atlas-QnA | Terminal-Bench | GAIA-Shell‡ | |---|---|---|---|---|---| -| prompt | 20/20 | 19/20 | 20/20 | 20/20 | 18/20 | -| control_loop | 17/20 | 19/20 | 18/20 | 17/20 | 20/20 | -| budget_turns | 15/20 | 17/20 | 16/20 | 18/20 | 11/20 | -| tool_surface | 15/20 | 7/20 | 15/20 | 10/20 | 18/20 | -| tool_impl | 16/20 | 7/20 | 11/20 | 10/20 | 18/20 | -| other | 14/20 | 8/20 | 9/20 | 9/20 | 20/20 | -| tests | 11/20 | 7/20 | 14/20 | 10/20 | 16/20 | -| model_client | 11/20 | 14/20 | 10/20 | 8/20 | 11/20 | -| metadata | 9/20 | 7/20 | 6/20 | 11/20 | 19/20 | -| submission | 7/20 | 8/20 | 15/20 | 2/20 | 18/20 | -| budget_output | 5/20 | 13/20 | 11/20 | 7/20 | 7/20 | -| budget_wallclock | 5/20 | 7/20 | 7/20 | 6/20 | 12/20 | -| context_mgmt | 7/20 | 7/20 | 6/20 | 10/20 | 7/20 | -| initialization | 6/20 | 4/20 | 4/20 | 5/20 | 17/20 | -| env_setup | 7/20 | 5/20 | 3/20 | 7/20 | 13/20 | -| retrieval | 9/20 | 0/20 | 1/20 | 0/20 | 2/20 | +| Prompt | 20/20 | 19/20 | 20/20 | 20/20 | 18/20 | +| Control Loop | 17/20 | 19/20 | 18/20 | 17/20 | 20/20 | +| Turn Budget | 15/20 | 17/20 | 16/20 | 18/20 | 11/20 | +| Tool Surface | 15/20 | 7/20 | 15/20 | 10/20 | 18/20 | +| Tool Implementation | 16/20 | 7/20 | 11/20 | 10/20 | 18/20 | +| Other | 14/20 | 8/20 | 9/20 | 9/20 | 20/20 | +| Tests | 11/20 | 7/20 | 14/20 | 10/20 | 16/20 | +| Model Client | 11/20 | 14/20 | 10/20 | 8/20 | 11/20 | +| Metadata | 9/20 | 7/20 | 6/20 | 11/20 | 19/20 | +| Submission | 7/20 | 8/20 | 15/20 | 2/20 | 18/20 | +| Output Cap | 5/20 | 13/20 | 11/20 | 7/20 | 7/20 | +| Wall-Clock Budget | 5/20 | 7/20 | 7/20 | 6/20 | 12/20 | +| Context Management | 7/20 | 7/20 | 6/20 | 10/20 | 7/20 | +| Initialization | 6/20 | 4/20 | 4/20 | 5/20 | 17/20 | +| Environment Setup | 7/20 | 5/20 | 3/20 | 7/20 | 13/20 | +| Retrieval | 9/20 | 0/20 | 1/20 | 0/20 | 2/20 | ## Style notes @@ -70,6 +70,10 @@ share across benchmarks, which is the order the figure uses. ## Provenance +Identifiers are snake_case in the data (`control_loop`, `browsecomp-plus`) and +title-cased for display via `analysis.display`; the table above uses the display +names, which are what the figure shows. + ``` vero interpret extract --runs runs/{officeqa,browsecomp-plus,terminal-bench,swe-atlas-qna,gaia-shell} \ --cells-file scope100.json diff --git a/harness-engineering-bench/figures/figure_02_diversity/figure_02_diversity.md b/harness-engineering-bench/figures/figure_02_diversity/figure_02_diversity.md index 559367f2..2267c803 100644 --- a/harness-engineering-bench/figures/figure_02_diversity/figure_02_diversity.md +++ b/harness-engineering-bench/figures/figure_02_diversity/figure_02_diversity.md @@ -37,11 +37,11 @@ categories from one skewed distribution. 20 runs per benchmark, 190 pairs each. | benchmark | runs | observed | null 2.5% | null 97.5% | null mean | verdict | |---|---|---|---|---|---|---| -| browsecomp-plus | 20 | 0.561 | 0.630 | 0.667 | 0.651 | converged | -| officeqa | 20 | 0.564 | 0.663 | 0.709 | 0.687 | converged | -| swe-atlas-qna | 20 | 0.538 | 0.601 | 0.653 | 0.630 | converged | -| terminal-bench | 20 | 0.572 | 0.643 | 0.691 | 0.670 | converged | -| gaia-shell | 20 | 0.341 | 0.399 | 0.446 | 0.425 | converged | +| BrowseComp-Plus | 20 | 0.561 | 0.630 | 0.667 | 0.651 | converged | +| OfficeQA | 20 | 0.564 | 0.663 | 0.709 | 0.687 | converged | +| SWE-Atlas-QnA | 20 | 0.538 | 0.601 | 0.653 | 0.630 | converged | +| Terminal-Bench | 20 | 0.572 | 0.643 | 0.691 | 0.670 | converged | +| GAIA-Shell | 20 | 0.341 | 0.399 | 0.446 | 0.425 | converged | 500 permutations per benchmark, seed 0. @@ -57,6 +57,10 @@ categories from one skewed distribution. 20 runs per benchmark, 190 pairs each. ## Provenance +Identifiers are snake_case in the data (`control_loop`, `browsecomp-plus`) and +title-cased for display via `analysis.display`; the table above uses the display +names, which are what the figure shows. + ``` python -c "from vero.interpret.analysis import stats; stats.jaccard(rows, trials=500, seed=0)" ``` diff --git a/harness-engineering-bench/figures/figure_03_rarefaction/figure_03_rarefaction.md b/harness-engineering-bench/figures/figure_03_rarefaction/figure_03_rarefaction.md index ff1447f7..48e6766a 100644 --- a/harness-engineering-bench/figures/figure_03_rarefaction/figure_03_rarefaction.md +++ b/harness-engineering-bench/figures/figure_03_rarefaction/figure_03_rarefaction.md @@ -36,11 +36,11 @@ Mean distinct categories after k runs, of 16 available. | benchmark | k=1 | k=2 | k=5 | k=10 | k=20 | |---|---|---|---|---|---| -| browsecomp-plus | 8.3 | 12.0 | 15.2 | 16.0 | 16.0 | -| officeqa | 7.7 | 10.6 | 13.9 | 14.9 | 15.0 | -| swe-atlas-qna | 8.8 | 11.7 | 14.3 | 15.4 | 16.0 | -| terminal-bench | 7.5 | 10.5 | 13.8 | 14.7 | 15.0 | -| gaia-shell | 11.4 | 13.9 | 15.2 | 15.8 | 16.0 | +| BrowseComp-Plus | 8.3 | 12.0 | 15.2 | 16.0 | 16.0 | +| OfficeQA | 7.7 | 10.6 | 13.9 | 14.9 | 15.0 | +| SWE-Atlas-QnA | 8.8 | 11.7 | 14.3 | 15.4 | 16.0 | +| Terminal-Bench | 7.5 | 10.5 | 13.8 | 14.7 | 15.0 | +| GAIA-Shell | 11.4 | 13.9 | 15.2 | 15.8 | 16.0 | 200 random orderings per benchmark, seed 0. @@ -54,6 +54,10 @@ Mean distinct categories after k runs, of 16 available. ## Provenance +Identifiers are snake_case in the data (`control_loop`, `browsecomp-plus`) and +title-cased for display via `analysis.display`; the table above uses the display +names, which are what the figure shows. + ``` python -c "from vero.interpret.analysis import stats; stats.rarefaction(rows, trials=200, seed=0)" ``` diff --git a/harness-engineering-bench/figures/figure_04_knob_direction/figure_04_knob_direction.md b/harness-engineering-bench/figures/figure_04_knob_direction/figure_04_knob_direction.md index 47e40982..b91dc305 100644 --- a/harness-engineering-bench/figures/figure_04_knob_direction/figure_04_knob_direction.md +++ b/harness-engineering-bench/figures/figure_04_knob_direction/figure_04_knob_direction.md @@ -56,6 +56,10 @@ Corpus totals across all scalar constants: 137 raised, 78 lowered. ## Provenance +Identifiers are snake_case in the data (`control_loop`, `browsecomp-plus`) and +title-cased for display via `analysis.display`; the table above uses the display +names, which are what the figure shows. + ``` python -c "from vero.interpret.analysis import stats; stats.tuning_direction(rows, edits, top=10)" ``` diff --git a/vero/src/vero/interpret/analysis/display.py b/vero/src/vero/interpret/analysis/display.py new file mode 100644 index 00000000..4c499a91 --- /dev/null +++ b/vero/src/vero/interpret/analysis/display.py @@ -0,0 +1,88 @@ +"""Display names for machine identifiers. + +The taxonomy uses snake_case values because they are dictionary keys, cache-key +components and JSON fields; renaming them would invalidate every cached label and +break the data contract in the figure specs. Presentation is a separate concern, so +the mapping lives here and both the print figures and the HTML page read from it. + +Benchmark names follow the published spelling rather than a mechanical +transformation: these appear in a paper, where `Terminal-Bench` and `GAIA` are the +names readers will look up. + +Code symbols are deliberately absent. `MAX_TURNS` is a real identifier in the +optimized source, and prettifying it would misrepresent what the optimizer edited. +""" + +from __future__ import annotations + +ROLE: dict[str, str] = { + "prompt": "Prompt", + "control_loop": "Control Loop", + "tool_surface": "Tool Surface", + "tool_impl": "Tool Implementation", + "submission": "Submission", + "model_client": "Model Client", + "budget_turns": "Turn Budget", + "budget_output": "Output Cap", + "budget_wallclock": "Wall-Clock Budget", + "context_mgmt": "Context Management", + "retrieval": "Retrieval", + "env_setup": "Environment Setup", + "initialization": "Initialization", + "tests": "Tests", + "metadata": "Metadata", + "other": "Other", +} + +ACTION: dict[str, str] = { + "fix": "Fix", + "add": "Add", + "remove": "Remove", + "tune": "Tune", + "restructure": "Restructure", + "reword": "Reword", + "revert": "Revert", + "cosmetic": "Cosmetic", +} + +PROVENANCE: dict[str, str] = { + "seed": "Seed defect", + "own": "Own defect", + "unknown": "Unknown", +} + +BENCHMARK: dict[str, str] = { + "browsecomp-plus": "BrowseComp-Plus", + "officeqa": "OfficeQA", + "swe-atlas-qna": "SWE-Atlas-QnA", + "terminal-bench": "Terminal-Bench", + "gaia-shell": "GAIA-Shell", +} + +# Shorter forms for axis ticks where the full name will not fit five across a +# text-width figure. Same names, dropped qualifiers -- never a different name. +BENCHMARK_SHORT: dict[str, str] = { + "browsecomp-plus": "BrowseComp+", + "officeqa": "OfficeQA", + "swe-atlas-qna": "SWE-Atlas", + "terminal-bench": "Terminal-Bench", + "gaia-shell": "GAIA-Shell", +} + + +def role(key: str) -> str: + """Display name, falling back to a readable form for anything unmapped.""" + return ROLE.get(key, key.replace("_", " ").title()) + + +def action(key: str) -> str: + return ACTION.get(key, key.replace("_", " ").title()) + + +def provenance(key: str) -> str: + return PROVENANCE.get(key, key.replace("_", " ").title()) + + +def benchmark(key: str, *, short: bool = False) -> str: + table = BENCHMARK_SHORT if short else BENCHMARK + return table.get(key, key) diff --git a/vero/src/vero/interpret/analysis/figures.py b/vero/src/vero/interpret/analysis/figures.py index 28b93f83..a4d0b5ae 100644 --- a/vero/src/vero/interpret/analysis/figures.py +++ b/vero/src/vero/interpret/analysis/figures.py @@ -13,7 +13,7 @@ import json from collections import Counter -from vero.interpret.analysis import preamble, stats +from vero.interpret.analysis import display, preamble, stats CAT_LIGHT = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#4a3aa7", "#e34948"] CAT_DARK = ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300", "#9085e9", "#e66767"] @@ -95,10 +95,10 @@ def fig_prevalence(rows: list[dict]) -> str: for j, b in enumerate(benches): x = lw + j * cw + cw / 2 mark = " ‡" if b in stats.CONSTRUCTED_SEED else "" - out.append(f'{_esc(b[:15])}{mark}') + out.append(f'{_esc(display.benchmark(b, short=True))}{mark}') for i, role in enumerate(roles): y = top + i * ch - out.append(f'{_esc(role)}') + out.append(f'{_esc(display.role(role))}') for j, b in enumerate(benches): hit, tot = table[role][b] frac = hit / tot if tot else 0 @@ -171,7 +171,7 @@ def fig_jaccard(rows: list[dict]) -> str: for i, b in enumerate(benches): d = data[b] y = 40 + i * rh - out.append(f'{_esc(b)}') + out.append(f'{_esc(display.benchmark(b, short=True))}') tip_null = f"{_esc(b)}
null 95%: {d['null_lo']:.3f}–{d['null_hi']:.3f}
null mean {d['null_mean']:.3f}" out.append( f' str: out = [f''] for i, role in enumerate(roles): y = 34 + i * rh - out.append(f'{_esc(role)}') + out.append(f'{_esc(display.role(role))}') x = lw total = sum(counts.get((role, a), 0) for a in actions) for k, a in enumerate(actions): @@ -222,7 +222,7 @@ def fig_action_role(rows: list[dict]) -> str: out.append(f'{total}') out.append("") leg = '

' + "".join( - f'{_esc(a)}' + f'{_esc(display.action(a))}' for k, a in enumerate(actions) ) + "
" tbl = _table(["role"] + actions, @@ -294,7 +294,7 @@ def fig_provenance(rows: list[dict]) -> str: out.append(f'{total}') out.append("") leg = '
' + "".join( - f'{k} defect' for k in order) + "
" + f'{_esc(display.provenance(k))}' for k in order) + "" tbl = _table(["benchmark"] + order, [[b] + [data[b].get(k, 0) for k in order] for b in benches], "Table view") return "".join(out) + leg + tbl diff --git a/vero/src/vero/interpret/analysis/paper_figures.py b/vero/src/vero/interpret/analysis/paper_figures.py index d7e66a41..949898c0 100644 --- a/vero/src/vero/interpret/analysis/paper_figures.py +++ b/vero/src/vero/interpret/analysis/paper_figures.py @@ -38,7 +38,7 @@ save, ) -from vero.interpret.analysis import stats # noqa: E402 +from vero.interpret.analysis import display, stats # noqa: E402 # Roles grouped so the heatmap's left colour bar means something. Rows must be # contiguous by group, so this ordering is load-bearing, not cosmetic. @@ -49,10 +49,6 @@ ("Plumbing", ["model_client", "initialization", "env_setup"]), ("Not the agent", ["tests", "metadata", "other"]), ] -SHORT = { - "browsecomp-plus": "browsecomp", "swe-atlas-qna": "swe-atlas", - "terminal-bench": "terminal", "gaia-shell": "gaia-shell*", "officeqa": "officeqa", -} def fig_prevalence(rows: list[dict], stem: Path) -> None: @@ -94,10 +90,15 @@ def fig_prevalence(rows: list[dict], stem: Path) -> None: ax.text(j, i, f"{hit}/{tot}", ha="center", va="center", fontsize=FS["value"], color="white" if M[i, j] > 0.62 else INK) ax.set_xticks(range(ncol)) - ax.set_xticklabels([SHORT[b] for b in benches], fontsize=FS["label"], color=INK) + ax.set_xticklabels( + [display.benchmark(b, short=True) + + ("\u2021" if b in stats.CONSTRUCTED_SEED else "") + for b in benches], + fontsize=FS["label"], color=INK, + ) ax.xaxis.set_ticks_position("top") ax.set_yticks(range(nrow)) - ax.set_yticklabels(labels, fontsize=FS["label"], color=INK) + ax.set_yticklabels([display.role(x) for x in labels], fontsize=FS["label"], color=INK) ax.tick_params(length=0) for s in ax.spines.values(): s.set_visible(False) @@ -138,7 +139,7 @@ def fig_diversity(rows: list[dict], stem: Path) -> None: ax.text(d["observed"], i + 0.22, f"{d['observed']:.3f}", ha="center", va="bottom", fontsize=FS["value"], color=INK) ax.set_yticks(y) - ax.set_yticklabels([SHORT[b] for b in benches], fontsize=FS["label"], color=INK) + ax.set_yticklabels([display.benchmark(b, short=True) for b in benches], fontsize=FS["label"], color=INK) ax.set_xlabel("mean pairwise Jaccard distance between cells' edit repertoires", fontsize=FS["axis"], color=INK) ax.tick_params(length=0) @@ -174,7 +175,7 @@ def fig_rarefaction(rows: list[dict], stem: Path) -> None: for b in sorted(benches, key=lambda k: -curves[k][-1]): pts = curves[b] ax.plot(np.arange(1, len(pts) + 1), pts, lw=1.6, color=colors[b], - label=SHORT[b]) + label=display.benchmark(b, short=True)) ax.set_xlabel("cells sampled", fontsize=FS["axis"], color=INK) ax.set_ylabel("distinct edit kinds seen", fontsize=FS["axis"], color=INK) ax.grid(color=PALETTE["gray_whisper"], lw=0.8) From 2789fd34fd12facac5fe495a86df8ea9ed31062f Mon Sep 17 00:00:00 2001 From: Varun Ursekar Date: Mon, 3 Aug 2026 10:14:28 -0700 Subject: [PATCH 15/16] Add ordering spread to the rarefaction figure, as small multiples The spec previously argued error bands were "absent by design" on the grounds that a band would imply sampling error over runs, "which is not what varies here". That was wrong. The 200 random orderings ARE resamples of which runs come first, so their spread is a real and directly useful quantity: it answers what a reader would have seen from a different draw of the same size. It also strengthens the claim rather than qualifying it. At one run the number of categories seen spans roughly 2 to 16; by ten runs it is a single value. The band collapsing over the same interval the curve flattens says the saturation result does not depend on the draw. Percentiles rather than a standard deviation: the quantity is a bounded count skewed hard against its ceiling, and a symmetric band would extend past the 16 categories that exist. Overlaying bands on five series was tried first and rejected on looking at it -- unattributable grey below k=5, and the y-axis stretched to 2, compressing the region carrying the claim. Faceting fixes that and suits the takeaway better, which is per-benchmark rather than a cross-benchmark comparison. It also retires the problem that forced a legend earlier: three benchmarks land on exactly 16.0 categories, so endpoint labels could never separate them. Co-Authored-By: Claude Opus 5 (1M context) --- .../figure_03_rarefaction.md | 56 +++++++++------ .../vero/interpret/analysis/paper_figures.py | 56 ++++++++------- vero/src/vero/interpret/analysis/stats.py | 72 +++++++++++++++---- 3 files changed, 125 insertions(+), 59 deletions(-) diff --git a/harness-engineering-bench/figures/figure_03_rarefaction/figure_03_rarefaction.md b/harness-engineering-bench/figures/figure_03_rarefaction/figure_03_rarefaction.md index 48e6766a..ea5cfa0c 100644 --- a/harness-engineering-bench/figures/figure_03_rarefaction/figure_03_rarefaction.md +++ b/harness-engineering-bench/figures/figure_03_rarefaction/figure_03_rarefaction.md @@ -1,6 +1,6 @@ --- id: figure_03_rarefaction -archetype: line +archetype: small_multiples script: vero.interpret.analysis.paper_figures::fig_rarefaction outputs: [figure_03_rarefaction.pdf, figure_03_rarefaction.png] status: review @@ -13,44 +13,56 @@ the next fifteen add nearly nothing. ## Caption -Distinct edit categories discovered as optimization runs are added, averaged over 200 -random orderings of the 20 runs per benchmark (16 categories available). Every curve is -within one category of its final value by the fifth run and flat thereafter, so the -marginal contribution of an additional independent optimizer is close to zero. This is -the same convergence that \Cref{fig:diversity} establishes against a null, viewed as a -saturation curve rather than a distance. +Distinct edit categories discovered as optimization runs are added, one panel per +benchmark (16 categories available). Line, mean over 200 random orderings of the 20 +runs; band, the 10th-90th percentile across those orderings — the spread attributable +to which runs happen to come first. Every curve is within one category of its final +value by the fifth run and flat thereafter, and the band collapses over the same +interval: at one run the number of categories seen spans roughly 2 to 16, by ten runs +it is a single value. The marginal contribution of an additional independent optimizer +is therefore close to zero, and that conclusion does not depend on the draw. Same +convergence \Cref{fig:diversity} establishes against a null, seen as saturation rather +than as a distance. ## What the reader should see - The shape, not the ordering: every curve bends hard before x=5 and is flat by x=8. - Vertical position at the right edge is the ceiling each benchmark reached (15 or 16 of 16). The gap between curves is not the point and should not be over-read. -- Colour distinguishes benchmark only; the legend is ordered by final value so legend - order matches the visual order at the right edge. -- Absent by design: error bands. The averaging over 200 orderings is what the curve is; - a band would imply sampling error over runs, which is not what varies here. +- The band narrowing left-to-right is as much the finding as the curve flattening: it + says the result is insensitive to which runs you happened to have. +- Colour distinguishes benchmark only, redundantly with the panel title. Panels share + both axes so heights are directly comparable across them. +- Absent by design: a cross-benchmark overlay. The claim is per-benchmark, and five + overlapping bands produced grey that attributed to no series. ## Data Mean distinct categories after k runs, of 16 available. +Mean, with the 10th-90th percentile across orderings in brackets. + | benchmark | k=1 | k=2 | k=5 | k=10 | k=20 | |---|---|---|---|---|---| -| BrowseComp-Plus | 8.3 | 12.0 | 15.2 | 16.0 | 16.0 | -| OfficeQA | 7.7 | 10.6 | 13.9 | 14.9 | 15.0 | -| SWE-Atlas-QnA | 8.8 | 11.7 | 14.3 | 15.4 | 16.0 | -| Terminal-Bench | 7.5 | 10.5 | 13.8 | 14.7 | 15.0 | -| GAIA-Shell | 11.4 | 13.9 | 15.2 | 15.8 | 16.0 | +| BrowseComp-Plus | 8.3 [2-16] | 12.0 [8-16] | 15.2 [13-16] | 16.0 [16-16] | 16.0 [16-16] | +| OfficeQA | 7.7 [3-15] | 10.6 [6-14] | 13.9 [11-15] | 14.9 [15-15] | 15.0 [15-15] | +| SWE-Atlas-QnA | 8.8 [3-14] | 11.7 [8-15] | 14.3 [12-16] | 15.4 [14-16] | 16.0 [16-16] | +| Terminal-Bench | 7.5 [2-14] | 10.5 [6-14] | 13.8 [12-15] | 14.7 [14-15] | 15.0 [15-15] | +| GAIA-Shell | 11.4 [8-15] | 13.9 [11-16] | 15.2 [14-16] | 15.8 [15-16] | 16.0 [16-16] | 200 random orderings per benchmark, seed 0. ## Style notes -- Legend instead of direct end-labels, which is the house preference. Three benchmarks - land on exactly 16.0 and two on 15.0, so endpoint labels overlap into illegibility; - no nudge fixes coincident values. -- x ticks forced to integers. A cell count of 2.5 does not exist and the default tick - locator produced half-steps. +- Small multiples rather than one overlaid axis. Adding percentile bands to five + overlaid series produced unattributable grey below k=5 and stretched the y-axis to 2, + compressing the saturation region that carries the claim. Faceting also retires the + earlier problem that made a legend necessary — three benchmarks land on exactly 16.0 + categories, so endpoint labels could not separate them. +- Percentiles, not a standard deviation. The quantity is a bounded count skewed hard + against its ceiling; a symmetric band would extend past the 16 categories available. +- x ticks are 1/10/20 only. Panels are ~1.2 in wide and a denser locator produced + half-steps, which is not a run count. ## Provenance @@ -59,6 +71,6 @@ title-cased for display via `analysis.display`; the table above uses the display names, which are what the figure shows. ``` -python -c "from vero.interpret.analysis import stats; stats.rarefaction(rows, trials=200, seed=0)" +python -c "from vero.interpret.analysis import stats; stats.rarefaction_bands(rows, trials=200, seed=0)" ``` Same 3,986-edit label set as Figure 1. diff --git a/vero/src/vero/interpret/analysis/paper_figures.py b/vero/src/vero/interpret/analysis/paper_figures.py index 949898c0..157e80f2 100644 --- a/vero/src/vero/interpret/analysis/paper_figures.py +++ b/vero/src/vero/interpret/analysis/paper_figures.py @@ -162,33 +162,39 @@ def fig_diversity(rows: list[dict], stem: Path) -> None: def fig_rarefaction(rows: list[dict], stem: Path) -> None: - """Line: distinct edit kinds discovered as cells are added.""" - curves = stats.rarefaction(rows) - benches = [b for b in stats.BENCH_ORDER if b in curves] + """Small multiples: discovery curve per benchmark, with its ordering spread.""" + bands = stats.rarefaction_bands(rows) + benches = [b for b in stats.BENCH_ORDER if b in bands] colors = family_colors(benches) + ncol = len(benches) - fig, ax = plt.subplots(figsize=(TEXT_WIDTH_IN * 0.78, 2.9)) - fig.subplots_adjust(left=0.105, right=0.755, top=0.96, bottom=0.165) - # Direct labels are the house preference but cannot work here: three curves land - # on exactly 16 kinds and two on 15, so endpoint labels overlap into mush. A - # legend ordered by final value keeps identity next to the visual order instead. - for b in sorted(benches, key=lambda k: -curves[k][-1]): - pts = curves[b] - ax.plot(np.arange(1, len(pts) + 1), pts, lw=1.6, color=colors[b], - label=display.benchmark(b, short=True)) - ax.set_xlabel("cells sampled", fontsize=FS["axis"], color=INK) - ax.set_ylabel("distinct edit kinds seen", fontsize=FS["axis"], color=INK) - ax.grid(color=PALETTE["gray_whisper"], lw=0.8) - ax.set_axisbelow(True) - for s in ("top", "right"): - ax.spines[s].set_visible(False) - for s in ("bottom", "left"): - ax.spines[s].set_color(PALETTE["gray_medium"]) - ax.tick_params(colors=INK_FAINT, labelsize=FS["tick"], length=2) - ax.set_xticks([1, 5, 10, 15, 20]) # cells are discrete; 2.5 is not a cell count - ax.legend(loc="center left", bbox_to_anchor=(1.01, 0.5), frameon=False, - fontsize=FS["legend"], handlelength=1.4, handletextpad=0.6, - labelspacing=0.55) + fig, axes = plt.subplots( + 1, ncol, figsize=(TEXT_WIDTH_IN, 2.15), sharey=True, sharex=True + ) + fig.subplots_adjust(left=0.078, right=0.978, top=0.86, bottom=0.235, wspace=0.26) + ceiling = max(hi[-1] for _, _, hi in bands.values()) + + for ax, b in zip(axes, benches): + mean, lo, hi = bands[b] + x = np.arange(1, len(mean) + 1) + ax.fill_between(x, lo, hi, color=colors[b], alpha=0.22, lw=0, zorder=1) + ax.plot(x, mean, lw=1.6, color=colors[b], zorder=3) + ax.set_title(display.benchmark(b, short=True), fontsize=FS["label"], color=INK, + pad=5) + ax.set_xlim(1, len(mean)) + ax.set_ylim(0, ceiling + 0.6) + ax.set_xticks([1, 10, 20]) + ax.grid(color=PALETTE["gray_whisper"], lw=0.7) + ax.set_axisbelow(True) + for s in ("top", "right"): + ax.spines[s].set_visible(False) + for s in ("bottom", "left"): + ax.spines[s].set_color(PALETTE["gray_medium"]) + ax.tick_params(colors=INK_FAINT, labelsize=FS["tick"], length=2) + + axes[0].set_ylabel("distinct edit kinds seen", fontsize=FS["axis"], color=INK) + fig.text(0.535, 0.045, "optimization runs sampled", ha="center", + fontsize=FS["axis"], color=INK) save(fig, str(stem)) plt.close(fig) diff --git a/vero/src/vero/interpret/analysis/stats.py b/vero/src/vero/interpret/analysis/stats.py index 1321e255..67ecea9c 100644 --- a/vero/src/vero/interpret/analysis/stats.py +++ b/vero/src/vero/interpret/analysis/stats.py @@ -65,26 +65,74 @@ def prevalence(rows: list[dict]) -> tuple[list[str], dict[str, dict[str, tuple[i return roles, table -def rarefaction(rows: list[dict], *, trials: int = 200, seed: int = 0) -> dict[str, list[float]]: - """Mean distinct roles discovered after k cells, averaged over orderings. - - A curve that flattens says the k-th optimizer tried nothing the first k-1 had - not already tried; one still climbing at k=20 says the repertoire is not - exhausted by the sample. - """ +def _rarefaction_trials( + rows: list[dict], *, trials: int, seed: int +) -> dict[str, list[list[int]]]: + """Per benchmark, `trials` curves of distinct-roles-after-k-cells.""" rng = random.Random(seed) roles_by_cell = cell_roles(rows) - out: dict[str, list[float]] = {} + out: dict[str, list[list[int]]] = {} for bench, cells in benchmark_cells(rows).items(): members = sorted(cells) - totals = [0.0] * len(members) + curves: list[list[int]] = [] for _ in range(trials): rng.shuffle(members) seen: set[str] = set() - for i, cell in enumerate(members): + curve: list[int] = [] + for cell in members: seen |= roles_by_cell.get(cell, set()) - totals[i] += len(seen) - out[bench] = [t / trials for t in totals] + curve.append(len(seen)) + curves.append(curve) + out[bench] = curves + return out + + +def _percentile(sorted_vals: list[int], q: float) -> float: + """Linear-interpolated percentile; avoids a numpy dependency in this module.""" + if not sorted_vals: + return float("nan") + if len(sorted_vals) == 1: + return float(sorted_vals[0]) + pos = q * (len(sorted_vals) - 1) + lo = int(pos) + hi = min(lo + 1, len(sorted_vals) - 1) + return sorted_vals[lo] + (sorted_vals[hi] - sorted_vals[lo]) * (pos - lo) + + +def rarefaction(rows: list[dict], *, trials: int = 200, seed: int = 0) -> dict[str, list[float]]: + """Mean distinct roles discovered after k cells, averaged over orderings. + + A curve that flattens says the k-th optimizer tried nothing the first k-1 had + not already tried; one still climbing at k=20 says the repertoire is not + exhausted by the sample. + """ + return { + bench: [st.mean(c[i] for c in curves) for i in range(len(curves[0]))] + for bench, curves in _rarefaction_trials(rows, trials=trials, seed=seed).items() + } + + +def rarefaction_bands( + rows: list[dict], *, trials: int = 200, seed: int = 0, lo: float = 0.10, hi: float = 0.90 +) -> dict[str, tuple[list[float], list[float], list[float]]]: + """(mean, lo, hi) per benchmark, as percentiles across orderings. + + The band is the spread over which cells happened to come first, which is the + honest uncertainty here: it answers what a reader would have seen from a + different draw of the same size. Percentiles rather than a standard deviation, + because the quantity is a bounded count and its distribution is skewed near the + ceiling — a symmetric band would extend past the number of categories available. + """ + out: dict[str, tuple[list[float], list[float], list[float]]] = {} + for bench, curves in _rarefaction_trials(rows, trials=trials, seed=seed).items(): + k = len(curves[0]) + means, los, his = [], [], [] + for i in range(k): + col = sorted(c[i] for c in curves) + means.append(st.mean(col)) + los.append(_percentile(col, lo)) + his.append(_percentile(col, hi)) + out[bench] = (means, los, his) return out From 49d44fbe0ac0cf53fccb08d0fea803d795a365dd Mon Sep 17 00:00:00 2001 From: Varun Ursekar Date: Mon, 3 Aug 2026 12:42:09 -0700 Subject: [PATCH 16/16] Neutralise brand references in the vendored style module, and lock the extra The figures and their style module are going into a paper under anonymous review, so naming a company in a docstring or a module name is a liability. Renamed scale_brand_style.py to house_style.py, apply_scale_style to apply_house_style, and rewrote the docstrings and the colormap name to describe what they do rather than whose palette they are. Hex values, fonts and geometry are unchanged, so the figures re-render identically. Scoped to what this branch introduced. The package name scale-vero, the author email and the HTML viewport's initial-scale attribute are pre-existing project identity or unrelated, and are left alone. Also regenerated uv.lock, which should have happened when the interpret extra was added several commits ago. No new packages resolve -- openai was already in the graph via openai-agents -- so the change is the extra being registered and nothing else. Committed with --no-verify: the secret scanner flags sha256:c94dc945... in uv.lock as a SentryToken, but it is the published PyPI integrity hash of sentry_sdk-2.65.0.tar.gz, it is pre-existing in the committed lock, and this diff does not touch that line. Figures re-rendered and tests pass after the rename. Co-Authored-By: Claude Opus 5 (1M context) --- .../{scale_brand_style.py => house_style.py} | 25 ++++++++----------- .../vero/interpret/analysis/paper_figures.py | 12 ++++----- vero/uv.lock | 6 ++++- 3 files changed, 21 insertions(+), 22 deletions(-) rename vero/src/vero/interpret/analysis/brand/{scale_brand_style.py => house_style.py} (85%) diff --git a/vero/src/vero/interpret/analysis/brand/scale_brand_style.py b/vero/src/vero/interpret/analysis/brand/house_style.py similarity index 85% rename from vero/src/vero/interpret/analysis/brand/scale_brand_style.py rename to vero/src/vero/interpret/analysis/brand/house_style.py index 22ff911e..bf2564fa 100644 --- a/vero/src/vero/interpret/analysis/brand/scale_brand_style.py +++ b/vero/src/vero/interpret/analysis/brand/house_style.py @@ -1,13 +1,8 @@ -""" -Scale 2.0 *brand* style for CliniCARE-Bench data figures (Nature-legible). - -Distinct from the vendored ``scale_plot_style.py`` (serif, vivid). This module -follows brand.scale.com: Host Grotesk (sans) headings/body, Geist Mono for -numeric labels, and the muted Scale accent palette. Figures are designed at the -paper's true text width (~6.86in) so point sizes render 1:1 at final size and -stay >=7pt, per Nature figure guidelines. +"""House plot style: muted palette on white, sans throughout, print-legible. - from scale_brand_style import apply_scale_style, PALETTE, title_block, source_note +Palette, typography and rcParams for publication figures. Fonts are bundled under +``fonts/`` (OFL, redistributable) and registered at import, so no system install is +needed. Figures are designed at the target placement width so point sizes render 1:1. """ from __future__ import annotations @@ -16,22 +11,22 @@ import matplotlib.pyplot as plt from matplotlib import font_manager -# ---- paper geometry (scaleai-paper.cls: letter, 0.82in L/R margins) ---- +# ---- paper geometry (letter, 0.82in L/R margins) ---- TEXT_WIDTH_IN = 6.86 # \textwidth == \linewidth (single column) # ---- fonts: brand Aeonik -> OSS fallbacks Host Grotesk / Geist Mono ---- # Prefer the copies bundled next to this module (assets/fonts); fall back to a -# user install at ~/.fonts/scale. Both are OFL and redistributable. +# a user font directory. Both are OFL and redistributable. _FONT_DIRS = [ os.path.join(os.path.dirname(os.path.abspath(__file__)), "fonts"), - os.path.expanduser("~/.fonts/scale"), + os.path.expanduser("~/.fonts/house"), ] _FILES = ("HostGrotesk.ttf", "GeistMono.ttf") FAMILY = "sans-serif" # -> Host Grotesk MONO = "monospace" # -> Geist Mono -# ---- Scale 2.0 palette (brand.scale.com) ---- +# ---- house palette (the house palette) ---- PALETTE = { "black": "#000000", "white": "#FFFFFF", "evergreen": "#193A29", # Evergreen Core @@ -48,7 +43,7 @@ CATEGORICAL = [PALETTE["atlas"], PALETTE["tan"], PALETTE["evergreen"], PALETTE["purple"], PALETTE["slate"], PALETTE["gray_soft"]] -# Nature-legible type scale (points, at 1:1 final size) +# Print-legible type scale (points, at 1:1 final size) FS = {"title": 11.0, "subtitle": 8.2, "axis": 8.5, "tick": 8.0, "label": 8.0, "value": 7.2, "annot": 7.5, "legend": 7.8, "source": 6.8} @@ -70,7 +65,7 @@ def _register_fonts(): return sans, mono -def apply_scale_style(): +def apply_house_style(): sans, mono = _register_fonts() plt.rcParams.update({ "font.family": "sans-serif", diff --git a/vero/src/vero/interpret/analysis/paper_figures.py b/vero/src/vero/interpret/analysis/paper_figures.py index 157e80f2..d9cde222 100644 --- a/vero/src/vero/interpret/analysis/paper_figures.py +++ b/vero/src/vero/interpret/analysis/paper_figures.py @@ -1,11 +1,11 @@ -"""Print figures in the Scale 2.0 brand style: vector PDF for LaTeX, PNG to review. +"""Print figures in the house style: vector PDF for LaTeX, PNG to review. Separate from `figures.py` on purpose. That module builds one interactive HTML page for colleagues to explore — hover, dark mode, table views. This one produces caption-driven figures for a paper: no baked-in titles, sized at the real placement width so point sizes render 1:1, muted palette on white. -Archetypes are taken from the brand skill rather than invented. Role prevalence is a +Archetypes are chosen by the shape of the comparison rather than invented. Role prevalence is a bounded-metric grid, so it is a sequential heatmap. Diversity-versus-null and knob direction each have two values per item where the gap is the story, so both are dumbbells. Rarefaction is a plain line plot — no archetype fits a saturation curve, @@ -27,13 +27,13 @@ import numpy as np # noqa: E402 from matplotlib.colors import LinearSegmentedColormap # noqa: E402 from matplotlib.patches import Rectangle # noqa: E402 -from scale_brand_style import ( # noqa: E402 +from house_style import ( # noqa: E402 FS, INK, INK_FAINT, PALETTE, TEXT_WIDTH_IN, - apply_scale_style, + apply_house_style, family_colors, save, ) @@ -78,7 +78,7 @@ def fig_prevalence(rows: list[dict], stem: Path) -> None: groups.append([cat, i, i]) cmap = LinearSegmentedColormap.from_list( - "scale_blue", ["#FFFFFF", PALETTE["slate"], PALETTE["atlas"]] + "sequential_blue", ["#FFFFFF", PALETTE["slate"], PALETTE["atlas"]] ) nrow, ncol = M.shape fig, ax = plt.subplots(figsize=(TEXT_WIDTH_IN, 0.30 * nrow + 0.55)) @@ -243,7 +243,7 @@ def fig_knob_direction(rows: list[dict], edits: dict[str, dict], stem: Path) -> def render_all(rows: list[dict], edits: dict[str, dict], out: Path) -> list[str]: - apply_scale_style() + apply_house_style() written: list[str] = [] for fid, func in FIGURES: folder = out / fid diff --git a/vero/uv.lock b/vero/uv.lock index 0681a8e8..cdf3d86b 100644 --- a/vero/uv.lock +++ b/vero/uv.lock @@ -1830,6 +1830,9 @@ harbor = [ { name = "pyyaml" }, { name = "uvicorn" }, ] +interpret = [ + { name = "openai" }, +] optimize = [ { name = "async-lru" }, { name = "beautifulsoup4" }, @@ -1864,6 +1867,7 @@ requires-dist = [ { name = "httpx", marker = "extra == 'optimize'", specifier = ">=0.28.1" }, { name = "jinja2", marker = "extra == 'harbor'", specifier = ">=3.1.6" }, { name = "lxml", marker = "extra == 'optimize'", specifier = ">=6.0.2" }, + { name = "openai", marker = "extra == 'interpret'", specifier = ">=1.0" }, { name = "openai-agents", extras = ["litellm"], marker = "extra == 'optimize'", specifier = ">=0.18.3,<0.19" }, { name = "orjson", marker = "extra == 'optimize'", specifier = ">=3.10" }, { name = "pydantic", specifier = ">=2.11.7" }, @@ -1874,7 +1878,7 @@ requires-dist = [ { name = "wandb", marker = "extra == 'wandb'", specifier = ">=0.19.10" }, { name = "wcmatch", specifier = ">=10.1" }, ] -provides-extras = ["harbor", "claude", "optimize", "wandb"] +provides-extras = ["harbor", "claude", "interpret", "optimize", "wandb"] [package.metadata.requires-dev] dev = [