Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions dokimi/changelog.d/20261022_090000_napetrov_repo-arm-uptake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
### Added

- **repo run: uptake of what a `patch:` arm added** — `meta.json` `arm_patch`
lists the files the patch `added` (guidance files and `.mcp.json` left out) and the
`mcp_servers` it added to a `.mcp.json`, and `result.json` gets an `uptake`
object: the agent's commands naming an added file, reads of one, and calls of
those servers' tools, leaving out calls the harness denied.
`repo report` shows the cells that used the arm's
additions per row; `repo compare` lists uptake per treatment and warns when a
treatment's cells used none of it, since its deltas then measure the patch's
text, not the tool (#41 D4).
21 changes: 20 additions & 1 deletion dokimi/docs/repo-evaluation-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,9 @@ grader diffs the agent's changes from `start_sha`. With the arm come
`arm_files`, the files it deleted, wrote or changed; for `guidance:rev:` an
`arm_rev` object (`rev` as given, its `sha`, and `removed`: the base's guidance
files with no counterpart at `<rev>`); for `patch:` an `arm_patch` object (the
file's absolute `path` and full `sha256`).
file's absolute `path` and full `sha256`, `added`: the files the patch added
that neither the guidance globs nor `.mcp.json` match, and `mcp_servers`: the servers
the patch added to the `.mcp.json` at the root, read before the agent runs).

Step 3 removes history only. A fix that landed upstream before a model's
training cutoff may still be known to the model, so reports split results by
Expand Down Expand Up @@ -372,6 +374,23 @@ Code's `modelUsage`), `main` when the harness reported the parent's only, and
`null` when it reported none. `subagent_calls` counts the parent's and
subagents' `Agent`/`Task` calls; `null` when the harness wrote no result record.

For a `patch:` arm with `added` files or `mcp_servers`, `result.json` has an
Comment thread
antonwolfy marked this conversation as resolved.
`uptake` object: whether the agent used what the arm added. `files` and
`mcp_servers` list what it added (sorted); `commands` counts
command events that name an added file as a workspace path (`tools/fmt`,
`./tools/fmt`, `/eval/run/workspace/tools/fmt`, not `cd tools && ./fmt`; a file
with no `/` only as `./name`, under the workspace path, first in a command or after an interpreter, so
`npm run lint` does not name a `lint` file), `reads` the read events of one (a
read of an added script counts as use), `mcp_calls` the calls of the servers' tools
(`mcp__<server>__<tool>`), none of them a call the harness denied; `used`
is whether any is non-zero, and `measured` is
`false` when the agent left no trace to count from. `repo report` shows the
cells that used it per row. `repo compare` puts an `uptake` entry per treatment
in its summary (`{cells, used, unmeasured}`: the measured cells, how many of
them used it, and how many could not be measured; the text lines say so), prints it as an `uptake: ...`
line, and warns about a treatment whose cells used none of it: its deltas then
measure the patch's text, such as a guidance line, not the tool.

### 4.2 `grade.json` (written by `grade` to `{run_dir}/grade.json`)

```json
Expand Down
20 changes: 17 additions & 3 deletions dokimi/dokimi/repos/compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
than it asked for is warned about as in ``repo report``. An image ``repo run``
tagged from the tree's own revision follows the tree, so it is compared only
against an ``--image`` given explicitly.

A treatment arm that added a file or an MCP server (``result.json``
``uptake``) whose agents used it in none of their cells is warned about too: its
deltas measure the patch's text (a guidance line, say), not the tool.
"""

from __future__ import annotations
Expand All @@ -53,7 +57,8 @@

from dokimi.eval.stats import adjust_pvalues, bootstrap_ci, mean_ci

from .report import load_cells, mismatch_warnings, model_mismatch, model_matches, resolved_models
from .report import (load_cells, mismatch_warnings, model_matches, model_mismatch, resolved_models, unmeasured_note,
uptake_counts)

#: Largest number of non-zero deltas whose sign-flip distribution is enumerated (2**16 patterns).
EXACT_MAX = 16
Expand Down Expand Up @@ -150,15 +155,18 @@ def _one_arm_models(treatment: str, base_vals: dict[tuple[str, str], dict[str, A

def _provenance(batch_dirs: list[Path], cells: list[dict[str, Any]], baseline: str,
treatments: list[str], models: list[str] | None = None) -> dict[str, Any]:
"""``sources`` (each selector's batches), ``stamps`` (each batch's pin and harness versions), ``warnings``."""
"""``sources`` (each selector's batches), ``stamps`` (each batch's pin and harness versions), ``uptake`` (each
treatment's cells that used what its arm added), ``warnings``."""
names = [d.resolve().name for d in batch_dirs]
stamps = {name: {**batch_stamp(d), "harness_versions": sorted({str(c.get("harness_version"))
for c in cells if c["batch"] == name}),
"model_resolved": sorted({m for c in cells if c["batch"] == name for m in resolved_models(c)})}
for d, name in zip(batch_dirs, names, strict=True)}
sources = {text: sorted({c["batch"] for c in cells if selector_matches(parse_selector(text), c)})
for text in [baseline, *treatments]}
return {"sources": sources, "stamps": stamps,
uptake = {text: uptake_counts([c for c in cells if selector_matches(parse_selector(text), c)
and (not models or c["model"] in models)]) for text in treatments}
return {"sources": sources, "stamps": stamps, "uptake": {k: v for k, v in uptake.items() if v is not None},
"warnings": _provenance_warnings(cells, baseline, treatments, sources, stamps, models)}


Expand Down Expand Up @@ -215,6 +223,10 @@ def _provenance_warnings(cells: list[dict[str, Any]], baseline: str, treatments:
if base and treat and not _models_equivalent(base, treat):
out.append(f"{text} vs {baseline}: model {model} was answered by {', '.join(sorted(base))} in "
f"baseline, {', '.join(sorted(treat))} in treatment")
uptake = uptake_counts(arms[1])
if uptake and uptake["cells"] and not uptake["used"]:
out.append(f"{text}: the agent used none of what the arm added in {uptake['cells']} cell(s); "
"a delta measures the patch's text, not the tool")
compared = [c for c in cells if (not models or c["model"] in models)
and any(selector_matches(parse_selector(sel), c) for sel in (baseline, *treatments))]
return out + mismatch_warnings(model_mismatch(compared))
Expand Down Expand Up @@ -404,6 +416,8 @@ def format_compare(summary: dict[str, Any]) -> str:
+ (f" and {len(summary['unpaired']) - len(shown)} more" if len(summary["unpaired"]) > 10 else ""))
if summary["missing"]:
lines.append(f"{len(summary['missing'])} cells missing (no result.json): `repo report` lists them")
lines += [f"uptake: {text} used what its arm added in {u['used']}/{u['cells']} cell(s){unmeasured_note(u)}"
for text, u in summary.get("uptake", {}).items()]
lines += [f"warning: {w}" for w in summary["warnings"]]
return "\n".join(lines)

Expand Down
25 changes: 25 additions & 0 deletions dokimi/dokimi/repos/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
``host`` requirements (``unsupported.json``, with the reason). They are
neither failures nor missing.
- cost and turns are means over finished cells, as the harness reported them.
- **uptake**: for an arm that added a file or an MCP server, the cells whose
agent used it (``result.json`` ``uptake``, see :mod:`.uptake`) over the
cells with a trace to count from.

A warning is printed when the trace says a model other than the requested one
answered a cell (``model_resolved``): a harness alias can pin an older model.
Expand Down Expand Up @@ -90,6 +93,9 @@ def summarize(batch_dir: Path) -> dict[str, Any]:
"turns": _mean([r["agent"]["num_turns"] for r in recs]),
"networks": sorted({str(r["agent"].get("network")) for r in recs}),
"model_resolved": sorted({m for r in recs for m in resolved_models(r)})}
uptake = uptake_counts(recs)
if uptake is not None:
row["uptake"] = uptake
if score_only:
scores = [0.0 if r["grade"]["status"] == "grader_failure" else r["grade"]["score"] for r in recs]
row.update(kind="score", score=_mean([0.0 if s is None else s for s in scores]))
Expand Down Expand Up @@ -125,6 +131,9 @@ def format_report(summary: dict[str, Any]) -> str:
f"{result:<22} {r['grader_failures']:>11} {r['missing']:>7} {cost:>8} {turns:>6}")
noted = ("agent_timeouts", "agent_errors", "over_budget", "cost_unknown")
notes = [f"{r[k]} {k.replace('_', ' ')}" for k in noted if r[k]]
if "uptake" in r:
notes.append(f"arm's additions used in {r['uptake']['used']}/{r['uptake']['cells']} cell(s)"
+ unmeasured_note(r["uptake"]))
if notes:
lines.append(" " * (width + 2) + " " + ", ".join(notes))
if summary["missing"]:
Expand All @@ -147,6 +156,22 @@ def _unsupported_lines(cells: list[dict[str, Any]]) -> list[str]:
for (task, reason), n in sorted(by_task.items())]


def unmeasured_note(u: dict[str, int]) -> str:
"""What to append to a ``used/cells`` count when some cells left no trace to measure."""
return f" (warning: {u['unmeasured']} more cell(s) left no trace to measure)" if u.get("unmeasured") else ""


def uptake_counts(recs: list[dict[str, Any]]) -> dict[str, int] | None:
"""``cells`` with a measured ``uptake`` and how many of them ``used`` what the arm added; ``None`` when no
cell's arm added anything to use."""
found = [r["uptake"] for r in recs if isinstance(r.get("uptake"), dict)]
if not found:
return None
measured = [u for u in found if u.get("measured")]
return {"cells": len(measured), "used": sum(bool(u.get("used")) for u in measured),
"unmeasured": len(found) - len(measured)}


def resolved_models(rec: dict[str, Any]) -> list[str]:
"""The models a cell's trace says answered it (``model_resolved``); empty when the trace could not tell."""
found = rec.get("model_resolved")
Expand Down
16 changes: 12 additions & 4 deletions dokimi/dokimi/repos/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
docker_network,
)
from .events import convert
from .uptake import arm_uptake
from .grading import EvalContext, failed_outcome, outcome, run_entry
from .harness_profile import HarnessProfile
from .host import probe_host, unmet, validate_tasks
Expand Down Expand Up @@ -192,6 +193,9 @@ def run_cell(ctx: EvalContext, task: Task, arm: str, rep: int, batch_dir: Path,
"input_tokens": None, "output_tokens": None, "cache_read_tokens": None,
"cache_write_tokens": None, "usage_scope": None, "subagent_calls": None,
"plugins": [], "over_budget": False, "caps": caps, "network": network, "trace_errors": []}
added = meta.get("arm_patch") or {}
uptake_of = (added.get("added") or [], added.get("mcp_servers") or [])
events = None # the trace's events once it parsed; none: the uptake is unmeasured
try:
raw = (rd / "agent.stdout").read_text(encoding="utf-8", errors="replace")
transcript = convert(opts.profile.trace, raw, WORKSPACE)
Expand All @@ -201,6 +205,8 @@ def run_cell(ctx: EvalContext, task: Task, arm: str, rep: int, batch_dir: Path,
meta["harness_version"] = transcript.harness_version or opts.harness_version
meta["model_resolved"] = transcript.model_resolved() # an alias can map `model` to another one
_write_json(rd / "meta.json", meta)
if transcript.events or transcript.num_turns is not None: # else the agent never started
events = transcript.events
cost = transcript.cost_usd
agent.update(
cost_usd=cost, charged_usd=cost if cost is not None else caps["budget_usd"],
Expand All @@ -217,10 +223,12 @@ def run_cell(ctx: EvalContext, task: Task, arm: str, rep: int, batch_dir: Path,
# Excluding the run would favour arms that break workspaces (contract §4.2): it fails the task.
what = "grade run failed" if isinstance(exc, ContainerError) else f"host error after the agent ran ({type(exc).__name__})"
grade = failed_outcome(f"{what}: {str(exc)[:300]}")
record = {"task": task.id, "family": task.family, "arm": arm, "model": opts.model,
"model_resolved": meta.get("model_resolved"), "rep": rep,
"harness": opts.profile.name, "harness_version": meta["harness_version"],
"base_date": meta["base_date"], "score_task": bool(task.score), "agent": agent, "grade": grade}
record: dict[str, Any] = {"task": task.id, "family": task.family, "arm": arm, "model": opts.model,
"model_resolved": meta.get("model_resolved"), "rep": rep,
"harness": opts.profile.name, "harness_version": meta["harness_version"],
"base_date": meta["base_date"], "score_task": bool(task.score), "agent": agent, "grade": grade}
if any(uptake_of): # an arm that added something to use: did the agent use it (#41 D4)
record["uptake"] = arm_uptake(events, *uptake_of, WORKSPACE)
try:
_own_dir(rd) # the grader had the run dir read-write, and may have taken write access away
_write_json(rd / "result.json", record)
Expand Down
116 changes: 116 additions & 0 deletions dokimi/dokimi/repos/uptake.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Copyright (C) 2026 Intel Corporation
# SPDX-License-Identifier: Apache-2.0

"""Uptake: whether the agent of a cell used what its arm added (#41 D4).

An arm that adds a tool (a script, a document, an MCP server) can move a score
through the guidance line that names the tool even if the tool is never
called. ``result.json`` therefore records, for a ``patch:`` arm that adds
something an agent can use, how often the agent used it:

- **files**: the files the patch added that the guidance globs do not match
(a guidance file is loaded, not used; ``.mcp.json`` is a config whose servers are counted by their calls);
- **commands**: command events whose text names one of those files, as a path
relative to the workspace (``tools/fmt``, ``./tools/fmt``, ``bash tools/fmt``)
or under the workspace's absolute path. A file at the workspace root is named only as ``./name``, under the
absolute path, as the first word of a command (or of a ``;``/``&&``/``|`` part) or after a shell or
interpreter (``sh run.sh``): ``npm run lint`` does not name a ``lint`` file. A command run as ``cd tools && ./fmt``
is not counted;
- **reads**: read events of one of those files (reading a script's text counts: it is the agent engaging with
what the arm added, not only running it);
- **mcp_servers** / **mcp_calls**: the servers the patch added to the ``.mcp.json``
(those the file already declared at the base are not the arm's), and the calls of their tools (``mcp__<server>__<tool>``).
Whether the harness starts them is the profile's business: a profile that
starts none gives 0 calls, which is what the warning is for.

A call the harness denied (``denied``) is not counted. ``used`` is whether any
count is non-zero; ``measured`` is false when the agent left no trace to count
from (it never started, or its trace did not parse).
"""

from __future__ import annotations

import json
import re
from pathlib import Path
from typing import Any

MCP_CONFIG = ".mcp.json"


def base_servers(text: str) -> set[str]:
"""The server names a ``.mcp.json`` text declares; none when it does not parse."""
try:
servers = json.loads(text).get("mcpServers")
except (ValueError, AttributeError):
return set()
return {str(k) for k in servers} if isinstance(servers, dict) else set()


def mcp_servers(ws: Path, arm_files: list[str], base: set[str] | frozenset[str] = frozenset()) -> list[str]:
"""The server names of the ``.mcp.json`` at the workspace root that the arm added: those it wrote, less the
``base`` ones the file already declared before the patch; read at prep, before the agent can change it. A file that does not parse names no server, nor does a link out of the workspace
or to anything but a regular file: it is not read."""
if MCP_CONFIG not in arm_files:
return []
try:
path = (ws / MCP_CONFIG).resolve()
if not path.is_relative_to(ws.resolve()) or not path.is_file():
return []
servers = json.loads(path.read_text(encoding="utf-8")).get("mcpServers")
except (OSError, RuntimeError, ValueError, AttributeError): # RuntimeError: a link loop, before Python 3.13
return []
return sorted(str(k) for k in servers if str(k) not in base) if isinstance(servers, dict) else []


def arm_uptake(events: list[dict[str, Any]] | None, files: list[str], servers: list[str],
workdir: str) -> dict[str, Any]:
"""The ``uptake`` of ``result.json`` from the cell's normalized events (``None``: no trace)."""
out: dict[str, Any] = {"files": sorted(files), "mcp_servers": sorted(servers), "measured": events is not None,
"commands": 0, "reads": 0, "mcp_calls": 0, "used": False}
if events is None:
return out
named = _path_pattern(files, workdir)
prefixes = tuple(f"mcp__{_mcp_name(s)}__" for s in servers)
wanted = set(files)
for ev in events:
if ev.get("denied"): # the harness refused the call: the tool never ran
continue
kind = ev.get("kind")
if kind == "command" and named is not None and named.search(str(ev.get("text", ""))):
out["commands"] += 1
elif kind == "read" and ev.get("path") in wanted:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Is a Read of an added script (or cat tools/fmt, git diff tools/fmt) meant to count as uptake? For a tool file that is reading its text, the same signal the warning treats as 'not the tool'.

uptake.py:62-64 counts every read event whose path is an added file, and any command whose text names one as a whole word. The contract (repo-evaluation-contract.md:376-385) lists reads as uptake, so this may be intended for added documents; a split between files meant to be run and files meant to be read, or reporting commands/mcp_calls separately in the warning, would keep the two apart.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intended: a Read of an added file counts as uptake, as the contract lists reads (repo-evaluation-contract.md). The signal is 'the agent engaged with what the arm added', not only 'executed it'. 3117e66 states this in the uptake docstring and the contract; I did not split reads from commands, since the warning is about an arm whose additions the agent ignored entirely.

out["reads"] += 1
elif kind == "tool" and prefixes and str(ev.get("name", "")).startswith(prefixes):
out["mcp_calls"] += 1
out["used"] = bool(out["commands"] or out["reads"] or out["mcp_calls"])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests do not pin that a read alone, or an MCP call alone, makes used true. Both existing positive cases combine reads=1 or commands=1 with mcp_calls=1.

Mutation check: dropping reads from the used expression, dropping mcp_calls from it, matching the server prefix without the trailing __ (mcp__code_graph2__x counting for code.graph), and gating uptake on only the files or only the servers all pass the full suite (61 passed in test_repo_uptake.py + test_repo_arms.py). Each is a regression that hides the "used none" warning or raises a false one.

Tests to add: arm_uptake([{"kind": "read", "path": "tools/fmt"}], ["tools/fmt"], [], WS)["used"] is true; the same with one mcp__code_graph__x tool event and servers ["code.graph"]; mcp__code_graph2__x gives mcp_calls == 0; and a run_cell case for a patch that adds only an MCP server.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 3117e66: test_a_read_alone_or_an_mcp_call_alone_is_a_use (read alone, MCP call alone, and mcp__code_graph2__x gives mcp_calls == 0). I did not add a run_cell case for an MCP-only patch or a files-only/servers-only gating test.

return out


_INTERPRETERS = r"(?:sh|bash|zsh|dash|python3?|node|ruby|perl)"


def _path_pattern(files: list[str], workdir: str) -> re.Pattern[str] | None:
"""One of ``files`` as a whole word of a command: relative (optionally ``./``) or under ``workdir``.
A file with no ``/`` is a common word, so it must be ``./``-prefixed, under ``workdir``, first in a command
or run by an interpreter."""
if not files:
return None
root = re.escape(workdir.rstrip("/") + "/")
end = r"(?=$|[\s'\"`;&|)<>:])"
parts = []
nested = sorted((f for f in files if "/" in f), key=len, reverse=True)
flat = sorted((f for f in files if "/" not in f), key=len, reverse=True)
if nested:
paths = "|".join(re.escape(f) for f in nested)
parts.append(rf"(?:^|(?<=[\s'\"`=;&|(<>])|(?<=\./)|(?<={root}))(?:{paths}){end}")
if flat:
names = "|".join(re.escape(f) for f in flat)
parts.append(rf"(?:^\s*(?:\./)?|[;&|(]\s*(?:\./)?|(?:^|(?<=\s)){_INTERPRETERS}\s+|(?<=[\s'\"`=;&|(<>])\./|(?<={root}))"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Is lint x (and cd d && lint, asserted at test_repo_uptake.py:244) meant to count as running the workspace's lint? A bare command name resolves through PATH, so it does not run ./lint unless the workspace root is on PATH, and after cd d it cannot name the root file at all.

The flat-name branch of _path_pattern (uptake.py:109) accepts ^\s*(?:\./)? and [;&|(]\s*(?:\./)?, so the ./ is optional for a name first in a command. The docstring at uptake.py:17-18 says cd tools && ./fmt is not counted, which treats cd as changing what a path names; cd d && lint is counted. If the profile does not put the workspace on PATH, requiring ./ (or the workspace path, or an interpreter) for the first-word case keeps the count to runs of the arm's file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not meant to: a bare lint runs whatever PATH finds, and the profile does not put the workspace on PATH. Fixed in eeff916: a root-level file counts only as ./lint, under the workspace path, or after an interpreter (sh lint). lint x and cd d && lint moved to the no-match cases in test_repo_uptake.py; the docstring and repo-evaluation-contract.md say why. Not covered: cd d && ./lint still counts, since the matcher does not track cd (same limit as the nested-path rule).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#153 merged before this landed; the fix is in #186.

rf"(?:{names}){end}")
return re.compile("|".join(parts))


def _mcp_name(server: str) -> str:
"""A server name as Claude Code spells it in tool names: characters outside ``[A-Za-z0-9_-]`` become ``_``."""
return re.sub(r"[^A-Za-z0-9_-]", "_", server)
Loading