tare-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tare/probe.py ADDED
@@ -0,0 +1,273 @@
1
+ """tare probe: measure the room before the run (CONCEPT.md, layer 3).
2
+
3
+ Three readings, none of which asks the agent anything:
4
+
5
+ - context: the real CLI runs inside the room against a fake model endpoint, which
6
+ keeps the request, i.e. the context the harness actually assembled;
7
+ - control: the same run in the user's real setup (the dirty twin). The probe must find
8
+ the user's context there, or it is blind for that class;
9
+ - reach: a plain script inside the room looks for the user's files and inherited
10
+ environment variables; the same script outside is its control.
11
+ """
12
+ import os
13
+ import re
14
+ import subprocess
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ from .agents import Capture, Real
20
+ from .fake import Fake
21
+ from .room import ROOM_HOME, TareError, bwrap, room_env, room_home
22
+
23
+ SECRET_PATHS = (".claude", ".claude.json", ".codex", ".agents", ".config/gh", ".ssh", ".aws", ".netrc",
24
+ ".git-credentials", ".docker/config.json")
25
+ REACH_SCRIPT = ('for p in "$@"; do if [ -e "$p" ]; then echo "path $p"; fi; done; '
26
+ 'env | cut -d= -f1 | sed "s/^/env /"')
27
+ SHELL_ENV = {"PWD", "OLDPWD", "SHLVL", "_"}
28
+ LABELS = {"instructions": "global instructions", "memories": "memories"}
29
+ # An offered MCP tool: an entry of the tools array, or a whole line of a deferred-tools
30
+ # listing (tool search). A mention inside prose, e.g. in instructions, is not one.
31
+ MCP_TOOL = re.compile(r"^(mcp__[A-Za-z0-9_.\-]+?__)[A-Za-z0-9_.\-]+$", re.M)
32
+ # Under load a dirty twin can send its request before its MCP servers are connected. A blind
33
+ # twin gets one more try after this pause, and the reading says so.
34
+ TWIN_RETRY_PAUSE = 10
35
+
36
+
37
+ @dataclass
38
+ class Finding:
39
+ kind: str
40
+ what: str
41
+ source: str
42
+
43
+
44
+ @dataclass
45
+ class Reading:
46
+ agent: str = "?"
47
+ version: str = "?"
48
+ leaks: list[Finding] = field(default_factory=list)
49
+ declared: list[Finding] = field(default_factory=list)
50
+ seen: list[str] = field(default_factory=list) # classes the control found in the dirty twin
51
+ blind: list[str] = field(default_factory=list) # classes the user has but the dirty twin did not show
52
+ retried: list[str] = field(default_factory=list) # classes a first dirty twin was blind for
53
+
54
+ @property
55
+ def zero(self) -> bool:
56
+ return not self.leaks and not self.blind
57
+
58
+
59
+ def _capture(agent, argv: list[str], env: dict[str, str], cwd: Path, fake: Fake) -> Capture:
60
+ proc = subprocess.run(argv, env=env, cwd=cwd, capture_output=True, text=True, timeout=300,
61
+ stdin=subprocess.DEVNULL)
62
+ capture = agent.capture(proc.stdout, fake.requests)
63
+ if capture is None:
64
+ detail = proc.stderr.strip()[-300:] or proc.stdout.strip()[-300:] or "no output"
65
+ raise TareError(f"{agent.name} never reached tare's fake model server, so the probe could not run "
66
+ f"(exit {proc.returncode}): {detail}")
67
+ return capture
68
+
69
+
70
+ def instruction_lines(path: Path) -> list[str]:
71
+ """Distinctive lines of a file of the user's, used as markers."""
72
+ try:
73
+ lines = [line.strip() for line in path.read_text().splitlines()]
74
+ except OSError:
75
+ return []
76
+ return [line[:80] for line in lines if len(line) >= 30][:5]
77
+
78
+
79
+ def _mcp_prefixes(capture: Capture) -> set[str]:
80
+ return set(MCP_TOOL.findall(capture.text + "\n" + "\n".join(capture.tools)))
81
+
82
+
83
+ def _without_paths(text: str) -> str:
84
+ """Skill lines with their "(path)" removed: agy names each skill's file, and the room's
85
+ path (/home/tare/...) differs from the user's for the same skill."""
86
+ return re.sub(r" \([^)\n]*\)", "", text)
87
+
88
+
89
+ def _server_prefix(name: str) -> str:
90
+ return "mcp__" + re.sub(r"[^A-Za-z0-9_-]", "_", name) + "__"
91
+
92
+
93
+ def parent_instructions(project: Path) -> list[Path]:
94
+ """Instruction files in the project's parent directories: the user's, not the project's."""
95
+ return [d / name for d in project.parents for name in ("AGENTS.md", "CLAUDE.md") if (d / name).is_file()]
96
+
97
+
98
+ def score(agent, real: Real, twin: Capture, room: Capture, reach_in: str, reach_out: str,
99
+ bare: Capture | None = None, project: Path | None = None) -> Reading:
100
+ r = Reading(agent=agent.name, version=room.init.get("claude_code_version", "?"))
101
+
102
+ # instruction files above the project; an agent that does not read them cannot leak them
103
+ for path in parent_instructions(project) if project else []:
104
+ lines = instruction_lines(path)
105
+ if lines and any(line in twin.text for line in lines):
106
+ if "parent files" not in r.seen:
107
+ r.seen.append("parent files")
108
+ if any(line in room.text for line in lines):
109
+ r.leaks.append(Finding("parent files", "instructions above the project", str(path)))
110
+
111
+ # extensions: tools the dirty twin offers that a run without extensions does not
112
+ if bare is not None:
113
+ extension_tools = twin.tools - bare.tools
114
+ if extension_tools:
115
+ r.seen.append("extensions")
116
+ r.leaks += [Finding("extensions", name, "a tool of your extensions") for name in sorted(extension_tools & room.tools)]
117
+
118
+ # the user's own files: global instructions, and whatever else the agent reads (memories)
119
+ for kind, path in [("instructions", agent.instructions(real)), *agent.extra_markers(real)]:
120
+ lines = instruction_lines(path)
121
+ if lines:
122
+ (r.seen if any(line in twin.text for line in lines) else r.blind).append(kind)
123
+ if any(line in room.text for line in lines):
124
+ r.leaks.append(Finding(kind, LABELS.get(kind, kind), str(path)))
125
+
126
+ # the real home path: memory paths, parent-directory instruction files, hook output
127
+ home = f"{real.home}/"
128
+ if home in twin.text:
129
+ r.seen.append("home path")
130
+ if home in room.text:
131
+ r.leaks.append(Finding("home path", home, "your files"))
132
+
133
+ # MCP servers and connectors
134
+ twin_servers = _mcp_prefixes(twin)
135
+ connected = [s["name"] for s in twin.init.get("mcp_servers", []) if s.get("status") == "connected"]
136
+ if any(_server_prefix(name) not in twin_servers for name in connected):
137
+ r.blind.append("mcp")
138
+ elif twin_servers:
139
+ r.seen.append("mcp")
140
+ for prefix in sorted(_mcp_prefixes(room)):
141
+ source = "connected account (login)" if prefix.startswith("mcp__claude_ai_") else "MCP server"
142
+ r.leaks.append(Finding("mcp", prefix[5:-2], source))
143
+
144
+ # skills from plugins, the user's skill directories and the account
145
+ plugins = twin.init.get("plugins", [])
146
+ builtin = {p["name"] for p in plugins if p.get("path") == "builtin"}
147
+ user_plugins = {p["name"] for p in plugins if p.get("path") != "builtin"}
148
+ own: dict[str, Path] = {}
149
+ for skill_dir in agent.skill_dirs(real):
150
+ if skill_dir.is_dir():
151
+ own |= {d.name: skill_dir for d in skill_dir.iterdir() if d.is_dir() and not d.name.startswith(".")}
152
+ names = {s for s in twin.init.get("skills", []) if ":" in s and s.split(":")[0] not in builtin} | set(own)
153
+ listed = {}
154
+ for name in sorted(names):
155
+ # the skills listing has one "- name: description" line per skill ("- name (path): ..." in agy)
156
+ match = re.search(rf"^- {re.escape(name)}(?:[:( ].*)?$", twin.text, re.M)
157
+ if match:
158
+ namespace = name.split(":")[0] if ":" in name else None
159
+ source = (str(own[name]) if namespace is None
160
+ else f"plugin {namespace}" if namespace in user_plugins else "login (account skills)")
161
+ listed[_without_paths(match.group(0))[:160]] = source
162
+ if names:
163
+ (r.seen if listed else r.blind).append("skills")
164
+ counts: dict[str, int] = {}
165
+ room_lines = _without_paths(room.text)
166
+ for line, source in listed.items():
167
+ if line in room_lines:
168
+ counts[source] = counts.get(source, 0) + 1
169
+ r.leaks += [Finding("skills", f"{n} skill{'s' if n != 1 else ''}", source) for source, n in sorted(counts.items())]
170
+ if user_plugins:
171
+ r.seen.append("plugins")
172
+ r.leaks += [Finding("plugin", p["name"], p.get("path", "?"))
173
+ for p in room.init.get("plugins", []) if p.get("path") != "builtin"]
174
+
175
+ # the account email comes with subscription auth: declared, not a leak
176
+ email = agent.email(real)
177
+ if email and email in twin.text:
178
+ r.seen.append("email")
179
+ if email in room.text:
180
+ r.declared.append(Finding("email", "account email", "subscription login"))
181
+
182
+ # reach: the same script outside (control) and inside the room
183
+ if any(line.startswith("path ") for line in reach_out.splitlines()):
184
+ r.seen.append("reach")
185
+ allowed = set(room_env(agent)) | SHELL_ENV
186
+ for line in reach_in.splitlines():
187
+ kind, _, value = line.partition(" ")
188
+ if kind == "path":
189
+ r.leaks.append(Finding("reach", value, "visible inside the room"))
190
+ elif kind == "env" and value not in allowed:
191
+ r.leaks.append(Finding("env", value, "inherited environment"))
192
+ for name in getattr(agent, "secret_files", [agent.credentials]):
193
+ r.declared.append(Finding("credentials", f"{agent.room_config}/{name}", "copy of the login, needed by the CLI"))
194
+ return r
195
+
196
+
197
+ def settle(run_twin, read) -> Reading:
198
+ """Read with a dirty twin; a twin blind for some class gets one more try."""
199
+ reading = read(run_twin())
200
+ if reading.blind:
201
+ first = list(reading.blind)
202
+ time.sleep(TWIN_RETRY_PAUSE)
203
+ reading = read(run_twin())
204
+ reading.retried = first
205
+ return reading
206
+
207
+
208
+ def probe(agent, real: Real, project: Path) -> Reading:
209
+ project = project.resolve()
210
+ targets = [str(real.home), str(real.config), *(str(real.home / p) for p in SECRET_PATHS)]
211
+ if Path("/mnt/c").is_dir():
212
+ targets.append("/mnt/c") # WSL: the Windows drive and its user profile
213
+
214
+ # the dirty twin: the user's real setup, as a plain terminal would start it
215
+ twin_env = {k: v for k, v in os.environ.items() if not k.startswith("CLAUDE") or k == "CLAUDE_CONFIG_DIR"}
216
+
217
+ def run_twin() -> Capture:
218
+ with Fake() as fake:
219
+ args, env = agent.probe(fake.url)
220
+ with agent.twin(real, fake.url) as extra:
221
+ env = twin_env | env | extra
222
+ argv = [str(real.binary), *args]
223
+ if hasattr(agent, "twin_argv"): # a twin that must not write into the real setup
224
+ argv = agent.twin_argv(argv, env)
225
+ return _capture(agent, argv, env, project, fake)
226
+
227
+ def in_room(home: Path, flags: list[str]) -> Capture:
228
+ with Fake() as fake:
229
+ agent.prepare_probe(home / Path(agent.room_config).relative_to(ROOM_HOME), fake.url)
230
+ args, env = agent.probe(fake.url)
231
+ argv = bwrap(agent, real, home, project, [agent.name, *flags, *args], env)
232
+ return _capture(agent, argv, {**os.environ}, project, fake)
233
+
234
+ with room_home(agent, real) as home:
235
+ room = in_room(home, agent.room_flags)
236
+ # where an agent loads extensions, a run without them shows which tools are its own
237
+ bare = in_room(home, [*agent.room_flags, *agent.bare_flags]) if getattr(agent, "bare_flags", None) else None
238
+ reach = ["/bin/sh", "-c", REACH_SCRIPT, "reach", *targets]
239
+ reach_in = subprocess.run(bwrap(agent, real, home, project, reach), capture_output=True, text=True,
240
+ timeout=60, check=True).stdout
241
+ reach_out = subprocess.run(reach, capture_output=True, text=True, timeout=60, check=True).stdout
242
+ return settle(run_twin, lambda twin: score(agent, real, twin, room, reach_in, reach_out, bare=bare,
243
+ project=project))
244
+
245
+
246
+ def render(reading: Reading, project: Path) -> str:
247
+ width = max((len(f.what) for f in reading.leaks + reading.declared), default=0) + 2
248
+
249
+ def rows(findings):
250
+ return [f" {f.kind:<13}{f.what:<{width}}{f.source}" for f in findings]
251
+
252
+ version = f" {reading.version}" if reading.version != "?" else ""
253
+ out = [f"tare probe · {reading.agent}{version} · {project}",
254
+ f" control dirty twin shows: {', '.join(reading.seen) or 'nothing'}"]
255
+ if reading.retried:
256
+ out.append(f" retried the first dirty twin was blind for {', '.join(reading.retried)}. "
257
+ "This reading comes from a second one.")
258
+ for cls in reading.blind:
259
+ out.append(f" BLIND {cls}: you have it, but the dirty twin did not show it, so the room cannot be "
260
+ "proven clean of it")
261
+ if reading.leaks:
262
+ out += [" leaks", *rows(reading.leaks)]
263
+ else:
264
+ out.append(" leaks none")
265
+ if reading.declared:
266
+ out += [" declared", *rows(reading.declared)]
267
+ if reading.zero:
268
+ out.append("tare: 0.00")
269
+ elif reading.leaks:
270
+ out.append(f"tare: {len(reading.leaks)} leak{'s' if len(reading.leaks) != 1 else ''}")
271
+ else:
272
+ out.append("tare: not proven (blind)")
273
+ return "\n".join(out)
tare/recipe.py ADDED
@@ -0,0 +1,102 @@
1
+ """Recipes: how to repeat a Cliff or Swap run exactly.
2
+
3
+ Every run directory gets a recipe.json: the command, the task and check, a digest of the
4
+ project as it was, the agents with their CLI versions and arguments, and the parameters.
5
+ `tare rerun DIR` repeats the command and says what has changed since.
6
+ """
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import subprocess
11
+ import time
12
+ from importlib import metadata
13
+ from pathlib import Path
14
+
15
+ # directories that hold tooling, not the project's content
16
+ SKIP = {".git", ".venv", "venv", "node_modules", "__pycache__", ".pytest_cache", ".ruff_cache"}
17
+
18
+
19
+ def project_digest(project: Path) -> str:
20
+ digest = hashlib.sha256()
21
+ for root, dirs, files in os.walk(project):
22
+ dirs[:] = sorted(d for d in dirs if d not in SKIP)
23
+ for name in sorted(files):
24
+ path = Path(root) / name
25
+ if path.is_symlink() or not path.is_file():
26
+ continue
27
+ digest.update(str(path.relative_to(project)).encode() + b"\0")
28
+ digest.update(hashlib.sha256(path.read_bytes()).digest())
29
+ return digest.hexdigest()
30
+
31
+
32
+ def cli_version(real) -> str:
33
+ try:
34
+ proc = subprocess.run([str(real.binary), "--version"], capture_output=True, text=True, timeout=30,
35
+ stdin=subprocess.DEVNULL)
36
+ return (proc.stdout or proc.stderr).strip().splitlines()[0]
37
+ except (OSError, subprocess.TimeoutExpired, IndexError):
38
+ return "unknown"
39
+
40
+
41
+ def tare_version() -> str:
42
+ try:
43
+ return metadata.version("tare-cli")
44
+ except metadata.PackageNotFoundError:
45
+ return "unknown"
46
+
47
+
48
+ def without_out(argv: list[str]) -> list[str]:
49
+ """The command without its --out, so a rerun gets a run directory of its own."""
50
+ result, skip = [], False
51
+ for i, arg in enumerate(argv):
52
+ if skip:
53
+ skip = False
54
+ continue
55
+ if arg == "--":
56
+ return result + argv[i:]
57
+ if arg == "--out":
58
+ skip = True
59
+ continue
60
+ if not arg.startswith("--out="):
61
+ result.append(arg)
62
+ return result
63
+
64
+
65
+ def with_out(argv: list[str], out: Path) -> list[str]:
66
+ at = argv.index("--") if "--" in argv else len(argv)
67
+ return argv[:at] + ["--out", str(out)] + argv[at:]
68
+
69
+
70
+ def write(out: Path, argv: list[str], kind: str, project: Path, task: str, check: str,
71
+ agents: dict[str, tuple[object, object, list[str]]], params: dict) -> dict:
72
+ recipe = {
73
+ "kind": kind, "argv": without_out(argv), "out": str(out), "created": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
74
+ "tare": tare_version(), "task": task, "check": check, "project": str(project),
75
+ "project_digest": project_digest(project),
76
+ "agents": {key: {"name": agent.name, "args": args, "version": cli_version(real)}
77
+ for key, (agent, real, args) in agents.items()},
78
+ "params": params,
79
+ }
80
+ (out / "recipe.json").write_text(json.dumps(recipe, indent=2, ensure_ascii=False) + "\n")
81
+ return recipe
82
+
83
+
84
+ def read(out: Path) -> dict:
85
+ return json.loads((out / "recipe.json").read_text())
86
+
87
+
88
+ def drift(recipe: dict, reals: dict[str, object]) -> list[str]:
89
+ """What is no longer as it was when the recipe was written."""
90
+ notes = []
91
+ project = Path(recipe["project"])
92
+ if not project.is_dir():
93
+ notes.append(f"the project {project} is gone")
94
+ elif project_digest(project) != recipe["project_digest"]:
95
+ notes.append(f"the project {project} has changed since the recipe was written")
96
+ for key, agent in recipe["agents"].items():
97
+ real = reals.get(agent["name"])
98
+ if real is not None and (now := cli_version(real)) != agent["version"]:
99
+ notes.append(f"agent {key} ({agent['name']}) was {agent['version']}, is now {now}")
100
+ if (now := tare_version()) != recipe["tare"]:
101
+ notes.append(f"tare was {recipe['tare']}, is now {now}")
102
+ return notes
tare/room.py ADDED
@@ -0,0 +1,67 @@
1
+ """The room: a bubblewrap sandbox where nothing of the user's came along.
2
+
3
+ Inside, /home is an empty tmpfs. Only a fresh per-run home (with a copy of the agent's
4
+ login), the CLI and the project directory are mounted, and the environment is cleared
5
+ down to an allowlist. Linux and WSL only (bubblewrap, user namespaces).
6
+ """
7
+ import os
8
+ import shutil
9
+ import tempfile
10
+ from contextlib import contextmanager
11
+ from pathlib import Path
12
+
13
+ ROOM_HOME = "/home/tare"
14
+ ROOM_PROJECT = "/work"
15
+ # Copied from the caller when set; everything else stays outside.
16
+ PASS_ENV = ("TERM", "COLORTERM", "LANG", "LC_ALL")
17
+ # The room must not refresh the login: a refresh may rotate the token family the real
18
+ # session depends on (CONCEPT.md, layer 2). An hour covers ordinary runs.
19
+ MIN_TOKEN_LIFETIME = 3600
20
+
21
+
22
+ class TareError(Exception):
23
+ pass
24
+
25
+
26
+ @contextmanager
27
+ def room_home(agent, real):
28
+ """A fresh home for one room, holding a copy of the agent's login; removed afterwards."""
29
+ left = agent.token_lifetime(real)
30
+ if left < MIN_TOKEN_LIFETIME:
31
+ raise TareError(f"the {agent.name} login token expires in {max(0, int(left // 60))} min: "
32
+ f"start any {agent.name} session to refresh it, then retry")
33
+ home = Path(tempfile.mkdtemp(prefix="tare-room-"))
34
+ try:
35
+ config = home / Path(agent.room_config).relative_to(ROOM_HOME)
36
+ config.mkdir(parents=True)
37
+ agent.seed(real, config)
38
+ yield home
39
+ finally:
40
+ shutil.rmtree(home, ignore_errors=True)
41
+
42
+
43
+ def room_env(agent, extra: dict[str, str] | None = None) -> dict[str, str]:
44
+ env = {k: os.environ[k] for k in PASS_ENV if k in os.environ}
45
+ env |= {"HOME": ROOM_HOME, "PATH": "/opt/agent:/usr/bin:/bin", **agent.room_env()}
46
+ return env | (extra or {})
47
+
48
+
49
+ def bwrap(agent, real, home: Path, project: Path, command: list[str], extra_env: dict[str, str] | None = None,
50
+ extra_binds: list[str] | None = None) -> list[str]:
51
+ """The bwrap command for one room. `command[0]` may be the agent's own name."""
52
+ binds, executable = agent.binds(real)
53
+ if command and command[0] == agent.name:
54
+ command = [executable, *command[1:]]
55
+ argv = ["bwrap", "--ro-bind", "/usr", "/usr", "--ro-bind", "/etc", "/etc",
56
+ "--symlink", "usr/bin", "/bin", "--symlink", "usr/lib", "/lib", "--symlink", "usr/lib64", "/lib64"]
57
+ if Path("/mnt/wsl").is_dir():
58
+ # WSL keeps the resolver behind a symlink into /mnt/wsl; without it DNS fails.
59
+ argv += ["--ro-bind", "/mnt/wsl", "/mnt/wsl"]
60
+ argv += ["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--tmpfs", "/home",
61
+ "--bind", str(home), ROOM_HOME, *binds, *(extra_binds or []),
62
+ "--bind", str(project), ROOM_PROJECT, "--chdir", ROOM_PROJECT,
63
+ "--unshare-user", "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--die-with-parent",
64
+ "--clearenv"]
65
+ for key, value in room_env(agent, extra_env).items():
66
+ argv += ["--setenv", key, value]
67
+ return argv + ["--", *command]
tare/swap.py ADDED
@@ -0,0 +1,219 @@
1
+ """Swap: the room or the model (epic Swap).
2
+
3
+ Two agents, a and b, each run the task once in a room, with a capsule per step. At each cut,
4
+ a fraction of each run, both rooms are crossed with both agents: every agent continues
5
+ every room, by the same rendered handoff, so diagonal and off-diagonal cells read the
6
+ same kind of input. The state effect is how much better the tails do in room a than in
7
+ room b, averaged over the agents; the model effect is how much better agent a does than
8
+ agent b, averaged over the rooms. Where an agent can resume its own session natively, a
9
+ native cell on its own room prices the handoff itself (foreignness). At cut 0 both rooms
10
+ are the untouched project, so the state effect there must be zero: the null check.
11
+ """
12
+ import math
13
+ from concurrent.futures import ThreadPoolExecutor
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ from . import capsule as caps
18
+ from . import usage as usages
19
+ from .cliff import wilson
20
+ from .journal import Journal
21
+
22
+
23
+ @dataclass
24
+ class Side:
25
+ key: str # "a" or "b"
26
+ agent: object
27
+ real: object
28
+ args: list[str]
29
+ out: Path
30
+ steps: list = field(default_factory=list)
31
+ events: list = field(default_factory=list)
32
+ original: str = ""
33
+
34
+
35
+ def newcombe(x1: int, n1: int, x2: int, n2: int) -> tuple[float, float, float]:
36
+ """Difference of two pass rates with a 95 % interval (Newcombe's hybrid score method)."""
37
+ p1, p2 = x1 / n1, x2 / n2
38
+ l1, u1 = wilson(x1, n1)
39
+ l2, u2 = wilson(x2, n2)
40
+ d = p1 - p2
41
+ return d, d - math.sqrt((p1 - l1) ** 2 + (u2 - p2) ** 2), d + math.sqrt((u1 - p1) ** 2 + (p2 - l2) ** 2)
42
+
43
+
44
+ def mean_effect(differences: list[tuple[float, float, float]]) -> tuple[float, float, float]:
45
+ """Mean of independent differences; interval half-widths combine in quadrature."""
46
+ k = len(differences)
47
+ e = sum(d for d, _, _ in differences) / k
48
+ down = math.sqrt(sum((d - lo) ** 2 for d, lo, _ in differences)) / k
49
+ up = math.sqrt(sum((hi - d) ** 2 for d, _, hi in differences)) / k
50
+ return e, e - down, e + up
51
+
52
+
53
+ @dataclass
54
+ class Cut:
55
+ fraction: float
56
+ steps: dict[str, int] # side -> step of its run
57
+ cells: dict[tuple[str, str], list[bool]] = field(default_factory=dict) # (room, agent) -> passes
58
+ native: dict[str, list[bool]] = field(default_factory=dict) # side -> passes of its own native resume
59
+
60
+ def count(self, room: str, agent: str) -> tuple[int, int]:
61
+ r = self.cells[(room, agent)]
62
+ return sum(r), len(r)
63
+
64
+ def state(self) -> tuple[float, float, float]:
65
+ return mean_effect([newcombe(*self.count("a", m), *self.count("b", m)) for m in "ab"])
66
+
67
+ def model(self) -> tuple[float, float, float]:
68
+ return mean_effect([newcombe(*self.count(r, "a"), *self.count(r, "b")) for r in "ab"])
69
+
70
+ def foreignness(self, side: str) -> tuple[float, float, float] | None:
71
+ if side not in self.native:
72
+ return None
73
+ native = self.native[side]
74
+ return newcombe(sum(native), len(native), *self.count(side, side))
75
+
76
+
77
+ def swap(a: Side, b: Side, project: Path, prompt: str, check: str, out: Path, *, cuts: list[float],
78
+ tails: int = 3, jobs: int = 3, workspace_only: bool = False, env: dict[str, str] | None = None,
79
+ journal: Journal | None = None) -> str:
80
+ sides = {"a": a, "b": b}
81
+ journal = journal or Journal(None)
82
+ journal("start", kind="swap", task=prompt, check=check, project=str(project), tails=tails, cuts=cuts,
83
+ sides={s.key: {"agent": s.agent.name, "args": s.args, "dir": s.out.name,
84
+ "billing": usages.billing(s.agent, s.real)} for s in (a, b)})
85
+ journal("phase", phase="original runs")
86
+ with ThreadPoolExecutor(max_workers=2) as pool:
87
+ list(pool.map(lambda s: caps.record(s.agent, s.real, project, s.out, prompt, s.args, env), (a, b)))
88
+ for side in (a, b):
89
+ passed, detail, spent = caps.check_original(side.agent, side.out, check)
90
+ side.steps = caps.capsules(side.out, side.agent)
91
+ side.events = side.agent.trail(caps.session_lines(side.out)).events
92
+ side.original = f"{'passed' if passed else 'failed'} ({detail}) after {len(side.steps) - 1} steps"
93
+ journal("original", side=side.key, passed=passed, detail=detail, steps=[s.tool for s in side.steps], **spent)
94
+
95
+ plan = [Cut(f, {k: round(f * (len(s.steps) - 1)) for k, s in sides.items()}) for f in cuts]
96
+ journal("plan", cuts=[{"fraction": c.fraction, "steps": c.steps} for c in plan])
97
+ journal("phase", phase="tails")
98
+ jobs_list = []
99
+ for i, cut in enumerate(plan):
100
+ for room in "ab":
101
+ capsule = sides[room].steps[cut.steps[room]]
102
+ for agent in "ab":
103
+ cut.cells[(room, agent)] = []
104
+ for n in range(tails):
105
+ jobs_list.append(("cell", i, room, agent, n, capsule))
106
+ if sides[room].agent.native_resume:
107
+ cut.native[room] = []
108
+ for n in range(tails):
109
+ jobs_list.append(("native", i, room, room, n, capsule))
110
+
111
+ def run(job):
112
+ kind, i, room, agent, n, capsule = job
113
+ name = f"cut{i}/{kind}-room-{room}-agent-{agent}-{n}"
114
+ tail_dir = out / "swap" / name
115
+ journal("tail", id=name, status="running", cut=i, room=room, agent=agent, kind=kind,
116
+ agent_name=sides[agent].agent.name, dir=f"swap/{name}")
117
+ if kind == "native":
118
+ s = sides[room]
119
+ tail = caps.run_tail(s.agent, s.real, s.out, capsule, prompt, s.args, check, n, env, tail_dir=tail_dir)
120
+ else:
121
+ s = sides[agent]
122
+ tail = caps.run_handoff(s.agent, s.real, tail_dir, capsule, sides[room].events, prompt, s.args, check,
123
+ env, workspace_only)
124
+ journal("tail", id=name, status="passed" if tail.passed else "failed", detail=tail.detail,
125
+ usage=tail.usage, check_usage=tail.check_usage)
126
+ return job, tail.passed
127
+
128
+ with ThreadPoolExecutor(max_workers=jobs) as pool:
129
+ for (kind, i, room, agent, _, _), passed in pool.map(run, jobs_list):
130
+ (plan[i].native[room] if kind == "native" else plan[i].cells[(room, agent)]).append(passed)
131
+
132
+ text = "\n".join(report(sides, plan, prompt, check, project, len(jobs_list), workspace_only)
133
+ + usages.report(out)) + "\n"
134
+ (out / "report.txt").write_text(text)
135
+ journal("report", text=text)
136
+ journal("phase", phase="done")
137
+ return text
138
+
139
+
140
+ def _leader(state: tuple[float, float, float], model: tuple[float, float, float]) -> str:
141
+ """Which explains more of the gap at a cut: the room, the model, both alike, or neither (both near zero)."""
142
+ if abs(state[0]) < 0.1 and abs(model[0]) < 0.1:
143
+ return "neither"
144
+ if abs(abs(state[0]) - abs(model[0])) < 0.1:
145
+ return "tie"
146
+ return "room" if abs(state[0]) > abs(model[0]) else "model"
147
+
148
+
149
+ def _sign(effect: tuple[float, float, float]) -> str:
150
+ e, lo, hi = effect
151
+ return f"{e:+.2f} [{lo:+.2f}, {hi:+.2f}]"
152
+
153
+
154
+ def report(sides: dict[str, Side], plan: list[Cut], prompt: str, check: str, project: Path, spent: int,
155
+ workspace_only: bool) -> list[str]:
156
+ def who(s: Side) -> str:
157
+ return f"{s.agent.name}{' ' + ' '.join(s.args) if s.args else ''}"
158
+
159
+ lines = [f"tare swap · a: {who(sides['a'])} · b: {who(sides['b'])} · {project}",
160
+ f" task {prompt}", f" check {check}",
161
+ f" run a {sides['a'].original}", f" run b {sides['b'].original}",
162
+ f" handoff {'workspace only' if workspace_only else 'workspace and a written summary of the steps so far'}"]
163
+ dominant, deciding = [], []
164
+ for cut in plan:
165
+ lines += ["", f" cut {cut.fraction:.2f}: workspace a at step {cut.steps['a']}, workspace b at step {cut.steps['b']}",
166
+ " agent a agent b"]
167
+ for room in "ab":
168
+ row = []
169
+ for agent in "ab":
170
+ x, n = cut.count(room, agent)
171
+ lo, hi = wilson(x, n)
172
+ row.append(f"{x}/{n} {lo:.2f}-{hi:.2f}")
173
+ lines.append(f" workspace {room} {row[0]:<17}{row[1]}")
174
+ state, model = cut.state(), cut.model()
175
+ lines.append(f" state effect {_sign(state)} (positive: workspace a better)")
176
+ lines.append(f" model effect {_sign(model)} (positive: agent a better)")
177
+ for side in "ab":
178
+ cost = cut.foreignness(side)
179
+ if cost:
180
+ native, (passed, n) = cut.native[side], cut.count(side, side)
181
+ lines.append(f" handoff cost {side} {_sign(cost)} (own session {sum(native)}/{len(native)} against "
182
+ f"handoff {passed}/{n}, both in its own workspace)")
183
+ dominant.append(_leader(state, model))
184
+ deciding.append(state if dominant[-1] in ("room", "tie") else model)
185
+ lines.append("")
186
+ first = plan[0]
187
+ if first.fraction == 0:
188
+ _, lo, hi = first.state()
189
+ lines.append(" null check " + ("passed: no state effect at cut 0, where both workspaces are the untouched "
190
+ "project" if lo <= 0 <= hi else
191
+ f"FAILED: a state effect at cut 0 ({_sign(first.state())}), where both "
192
+ "workspaces are the same. Do not trust the effects above."))
193
+ shown = [i for i, d in enumerate(dominant) if d != "neither"]
194
+ late = next((i for i, d in enumerate(dominant) if d in ("room", "tie") and "model" in dominant[:i]), None)
195
+ if late is not None and dominant[late] == "tie" and "room" not in dominant:
196
+ verdict = (f"The model explains more than the workspace until cut {plan[late - 1].fraction:.2f}. From cut "
197
+ f"{plan[late].fraction:.2f} on, workspace and model explain about the same.")
198
+ elif "room" in dominant and "model" in dominant[:dominant.index("room")]:
199
+ at = dominant.index("room")
200
+ before = max(i for i in range(at) if dominant[i] == "model")
201
+ verdict = (f"Blame passes from the model to the workspace between cut {plan[before].fraction:.2f} "
202
+ f"and cut {plan[at].fraction:.2f}.")
203
+ elif not shown:
204
+ verdict = "Neither the workspace nor the model makes a difference at any cut."
205
+ elif {dominant[i] for i in shown} == {"model"}:
206
+ verdict = "The model explains more than the workspace " + ("at every cut." if len(shown) == len(plan)
207
+ else "wherever there is an effect.")
208
+ elif {dominant[i] for i in shown} == {"room"}:
209
+ verdict = "The workspace explains more than the model " + ("at every cut." if len(shown) == len(plan)
210
+ else "wherever there is an effect.")
211
+ else:
212
+ names = {"room": "workspace", "model": "model", "tie": "both alike", "neither": "neither"}
213
+ verdict = "No single pattern. What explains more, per cut: " + ", ".join(
214
+ f"{c.fraction:.2f} {names[d]}" for c, d in zip(plan, dominant)) + "."
215
+ if any(deciding[i][1] <= 0 <= deciding[i][2] for i in shown):
216
+ verdict += " The intervals behind this verdict include zero. More tails would settle it."
217
+ lines.append(f" verdict {verdict}")
218
+ lines.append(f" tails {spent}")
219
+ return lines