marcus-toolkit 0.1.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.
@@ -0,0 +1,251 @@
1
+ """
2
+ Run guards: prove a run executed the code its record names.
3
+
4
+ * :func:`tree_hash` hashes the files that execute, the construction the
5
+ Minecraft image guard uses (sorted path, length, bytes); a missing file is
6
+ a refusal, never a quietly shorter hash.
7
+ * :func:`committed_hash` hashes the same files as committed in a git
8
+ repository, so uncommitted edits cannot satisfy the guard.
9
+ * :func:`git_version` is the ``marcus-version.txt`` record: checkout,
10
+ branch, commit, dirty file count.
11
+
12
+ Lifted from the Minecraft plumbing's ``plumbing_hash.py``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import subprocess # nosec B404 - git on a path we own, fixed argv
19
+ import sys
20
+ from collections.abc import Sequence
21
+ from pathlib import Path
22
+
23
+
24
+ def tree_hash(root: Path, files: Sequence[str]) -> str:
25
+ """
26
+ Hash the named files under ``root``.
27
+
28
+ Parameters
29
+ ----------
30
+ root : Path
31
+ The directory the run executes from.
32
+ files : Sequence[str]
33
+ The files that execute, relative to ``root``.
34
+
35
+ Returns
36
+ -------
37
+ str
38
+ A sha256 hex digest over each file's relative path, byte length and
39
+ bytes, in sorted order.
40
+
41
+ Raises
42
+ ------
43
+ FileNotFoundError
44
+ If ``root`` is not a directory or any named file is missing.
45
+ """
46
+ if not root.is_dir():
47
+ raise FileNotFoundError(f"not a directory: {root}")
48
+ digest = hashlib.sha256()
49
+ for name in sorted(files):
50
+ path = root / name
51
+ if not path.is_file():
52
+ raise FileNotFoundError(f"missing from {root}: {name}")
53
+ content = path.read_bytes()
54
+ digest.update(name.encode())
55
+ digest.update(b"\0")
56
+ digest.update(str(len(content)).encode())
57
+ digest.update(b"\0")
58
+ digest.update(content)
59
+ return digest.hexdigest()
60
+
61
+
62
+ def committed_hash(
63
+ repo: Path, files: Sequence[str], subdir: str = "", ref: str = "HEAD"
64
+ ) -> str:
65
+ """
66
+ Hash the same files as they are committed in ``repo`` at ``ref``.
67
+
68
+ Parameters
69
+ ----------
70
+ repo : Path
71
+ The repository root.
72
+ files : Sequence[str]
73
+ The files, relative to ``subdir``.
74
+ subdir : str
75
+ Where they live inside the repository (``""`` for the root).
76
+ ref : str
77
+ The commit to read.
78
+
79
+ Returns
80
+ -------
81
+ str
82
+ The same construction as :func:`tree_hash`, over the committed bytes.
83
+
84
+ Raises
85
+ ------
86
+ FileNotFoundError
87
+ If git cannot be run, the ref does not exist, or a file is not in that commit.
88
+ """
89
+ digest = hashlib.sha256()
90
+ prefix = f"{subdir.rstrip('/')}/" if subdir else ""
91
+ for name in sorted(files):
92
+ try:
93
+ content = subprocess.run( # nosec B603 B607
94
+ ["git", "-C", str(repo), "show", f"{ref}:{prefix}{name}"],
95
+ capture_output=True,
96
+ check=True,
97
+ ).stdout
98
+ except (OSError, subprocess.CalledProcessError) as error:
99
+ raise FileNotFoundError(
100
+ f"not in {repo} at {ref}: {prefix}{name} ({error})"
101
+ ) from error
102
+ digest.update(name.encode())
103
+ digest.update(b"\0")
104
+ digest.update(str(len(content)).encode())
105
+ digest.update(b"\0")
106
+ digest.update(content)
107
+ return digest.hexdigest()
108
+
109
+
110
+ def uncommitted(repo: Path, files: Sequence[str], subdir: str = "") -> list[str]:
111
+ """
112
+ Which of the files have uncommitted changes in ``repo``.
113
+
114
+ Parameters
115
+ ----------
116
+ repo : Path
117
+ The repository root.
118
+ files : Sequence[str]
119
+ The files, relative to ``subdir``.
120
+ subdir : str
121
+ Where they live inside the repository.
122
+
123
+ Returns
124
+ -------
125
+ List[str]
126
+ Paths git reports as changed; empty when git cannot answer.
127
+ """
128
+ prefix = f"{subdir.rstrip('/')}/" if subdir else ""
129
+ try:
130
+ out = subprocess.run( # nosec B603 B607
131
+ [
132
+ "git",
133
+ "-C",
134
+ str(repo),
135
+ "status",
136
+ "--porcelain",
137
+ "--",
138
+ *(f"{prefix}{n}" for n in files),
139
+ ],
140
+ capture_output=True,
141
+ text=True,
142
+ check=True,
143
+ ).stdout
144
+ except (OSError, subprocess.CalledProcessError):
145
+ return []
146
+ return [line[3:].strip() for line in out.splitlines() if line.strip()]
147
+
148
+
149
+ def git_version(checkout: Path) -> dict[str, str]:
150
+ """
151
+ Read the version record of a checkout: what ``marcus-version.txt`` holds.
152
+
153
+ Parameters
154
+ ----------
155
+ checkout : Path
156
+ A git working directory.
157
+
158
+ Returns
159
+ -------
160
+ Dict[str, str]
161
+ ``checkout``, ``branch``, ``commit`` and ``dirty`` (a count of changed
162
+ files, as text).
163
+ """
164
+
165
+ def git(*args: str) -> str:
166
+ try:
167
+ return subprocess.run(
168
+ ["git", "-C", str(checkout), *args],
169
+ capture_output=True,
170
+ text=True,
171
+ check=True,
172
+ ).stdout.strip() # nosec B603 B607
173
+ except (OSError, subprocess.CalledProcessError):
174
+ return ""
175
+
176
+ dirty = git("status", "--porcelain")
177
+ return {
178
+ "checkout": str(checkout),
179
+ "branch": git("rev-parse", "--abbrev-ref", "HEAD"),
180
+ "commit": git("rev-parse", "HEAD"),
181
+ "dirty": str(len([line for line in dirty.splitlines() if line.strip()])),
182
+ }
183
+
184
+
185
+ def format_version(version: dict[str, str]) -> str:
186
+ """Render :func:`git_version` the way the Minecraft run records print it."""
187
+ return (
188
+ f"checkout: {version['checkout']}\nbranch: {version['branch']}\n"
189
+ f"commit: {version['commit']}\ndirty: {version['dirty']} files\n"
190
+ )
191
+
192
+
193
+ def contains_commit(checkout: Path, sha: str) -> bool:
194
+ """
195
+ Whether ``checkout``'s HEAD descends from ``sha``.
196
+
197
+ Parameters
198
+ ----------
199
+ checkout : Path
200
+ A git working directory.
201
+ sha : str
202
+ A commit, full or abbreviated.
203
+
204
+ Returns
205
+ -------
206
+ bool
207
+ """
208
+ try:
209
+ subprocess.run(
210
+ ["git", "-C", str(checkout), "merge-base", "--is-ancestor", sha, "HEAD"],
211
+ capture_output=True,
212
+ check=True,
213
+ ) # nosec B603 B607
214
+ return True
215
+ except (OSError, subprocess.CalledProcessError):
216
+ return False
217
+
218
+
219
+ def main(argv: list[str] | None = None) -> int:
220
+ """Print the hash of a directory or of a repository's committed tree."""
221
+ args = list(sys.argv[1:] if argv is None else argv)
222
+ if len(args) >= 2 and args[0] != "--committed":
223
+ try:
224
+ print(tree_hash(Path(args[0]), args[1].split(",")))
225
+ except FileNotFoundError as error:
226
+ print(f"integrity: {error}", file=sys.stderr)
227
+ return 2
228
+ return 0
229
+ if len(args) >= 3 and args[0] == "--committed":
230
+ try:
231
+ print(
232
+ committed_hash(
233
+ Path(args[1]),
234
+ args[2].split(","),
235
+ subdir=args[3] if len(args) > 3 else "",
236
+ )
237
+ )
238
+ except FileNotFoundError as error:
239
+ print(f"integrity: {error}", file=sys.stderr)
240
+ return 2
241
+ return 0
242
+ print(
243
+ "usage: marcus-toolkit-hash <dir> <file,file,...>"
244
+ " | --committed <repo> <file,file,...> [subdir]",
245
+ file=sys.stderr,
246
+ )
247
+ return 2
248
+
249
+
250
+ if __name__ == "__main__":
251
+ sys.exit(main())
@@ -0,0 +1,223 @@
1
+ """
2
+ The toolbox's own live check: a real server, a scripted agent, every reply read back.
3
+
4
+ What it proves, the way every extension's free rehearsal proves it: a server
5
+ under test starts with no key and its model off; a project is seeded with
6
+ two tasks, the second depending on the first; one scripted agent registers,
7
+ pulls the first, reports with evidence, pulls the second (now claimable),
8
+ closes it, and hears the drain; the sampler read the board from outside
9
+ before and after; the server log carries no model-provider host.
10
+
11
+ Usage::
12
+
13
+ python -m marcus_toolkit.livecheck --checkout ~/dev/marcus --out livecheck-runs
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from . import integrity, measure, record, sampler, seed, server
25
+ from .agent import BoardAgent, jsonl_logger
26
+ from .client import MarcusMCPClient
27
+
28
+ TASKS: list[dict[str, Any]] = [
29
+ {
30
+ "name": "Say where you are",
31
+ "description": "Report your position on the board; nothing else to do.",
32
+ "labels": ["livecheck", "seeded"],
33
+ "completion_criteria": ["The completion report says where the agent is"],
34
+ "estimated_hours": 0.1,
35
+ "priority": "high",
36
+ },
37
+ {
38
+ "name": "Say it again, now that the first report is in",
39
+ "description": "Only claimable once the first task is done.",
40
+ "labels": ["livecheck", "seeded"],
41
+ "depends_on": ["Say where you are"],
42
+ "completion_criteria": ["The completion report repeats the position"],
43
+ "estimated_hours": 0.1,
44
+ "priority": "medium",
45
+ },
46
+ ]
47
+
48
+
49
+ def run(checkout: Path, out: Path, python: str = sys.executable) -> dict[str, Any]:
50
+ """
51
+ Run the live check and return its verdict.
52
+
53
+ Parameters
54
+ ----------
55
+ checkout : Path
56
+ A Marcus checkout to serve from.
57
+ out : Path
58
+ The ``runs`` root; a ``<stamp>/`` directory is created under it.
59
+ python : str
60
+ The interpreter that runs the server.
61
+
62
+ Returns
63
+ -------
64
+ Dict[str, Any]
65
+ ``{"PASS": bool, "checks": {...}, "record": str}``.
66
+ """
67
+ rec = record.RunRecord(out)
68
+ data_dir = rec.dir / "data"
69
+ port = server.free_port()
70
+ config_path = rec.dir / "config_marcus.json"
71
+ server.write_config(
72
+ config_path,
73
+ port=port,
74
+ project_name="runner-livecheck",
75
+ sqlite_db_path=data_dir / "kanban.db",
76
+ )
77
+ version = integrity.git_version(checkout)
78
+ rec.write("marcus-version.txt", integrity.format_version(version))
79
+ for key, value in [
80
+ ("run_at", rec.stamp),
81
+ ("kind", "marcus-toolkit live check"),
82
+ ("port", port),
83
+ ("checkout", f"{checkout} @ {version['commit']} ({version['dirty']} dirty)"),
84
+ ("ai", "off, keys stripped"),
85
+ ]:
86
+ rec.config_line(key, value)
87
+
88
+ srv = server.ServerUnderTest(
89
+ checkout, data_dir, config_path, port, rec.dir / "server.log", python=python
90
+ )
91
+ checks: dict[str, bool] = {}
92
+ detail: dict[str, Any] = {}
93
+ try:
94
+ srv.start(wait_seconds=120)
95
+ client = MarcusMCPClient(srv.url, timeout=120)
96
+ if not client.connect():
97
+ raise RuntimeError(
98
+ f"could not connect to the server under test: {client.last_error}"
99
+ )
100
+ seeded = seed.seed_project(
101
+ client,
102
+ name="runner-livecheck",
103
+ description="the toolbox's own live check",
104
+ tasks=TASKS,
105
+ project_root=rec.dir / "project-root",
106
+ objective="Say where you are; the board is the only channel.",
107
+ )
108
+ rec.config_line("project_id", seeded.project_id)
109
+ board_db = data_dir / "kanban.db"
110
+ before = sampler.sample(board_db, seeded.project_id)
111
+
112
+ state_dir = rec.dir / "state"
113
+ agent = BoardAgent(
114
+ client,
115
+ "probe-1",
116
+ seeded.project_id,
117
+ role="probe",
118
+ skills=["probe"],
119
+ log=jsonl_logger(state_dir / "probe-1" / "board-calls.jsonl"),
120
+ hold_path=state_dir / "probe-1" / "board-hold.json",
121
+ )
122
+ first = agent.pull()
123
+ detail["first_pull"] = first.get("status")
124
+ if first.get("status") == "assigned":
125
+ agent.report(
126
+ "in_progress",
127
+ "at the start line",
128
+ progress=30,
129
+ subject="position.start",
130
+ )
131
+ agent.decide(
132
+ "stayed at the start line",
133
+ "the task asks only for a report",
134
+ subject="position.start",
135
+ )
136
+ agent.opinion(
137
+ "position.start",
138
+ "the start line is where the board says it is",
139
+ "I looked",
140
+ )
141
+ r1 = agent.report(
142
+ "completed",
143
+ "I am at the start line",
144
+ evidence={"position": "start line", "task": first["task"].get("name")},
145
+ )
146
+ detail["first_close"] = r1.get("success")
147
+ second = agent.pull()
148
+ detail["second_pull"] = second.get("status")
149
+ if second.get("status") == "assigned":
150
+ r2 = agent.report(
151
+ "completed",
152
+ "Still at the start line",
153
+ evidence={"position": "start line", "task": second["task"].get("name")},
154
+ )
155
+ detail["second_close"] = r2.get("success")
156
+ third = agent.pull()
157
+ detail["third_pull"] = third.get("status")
158
+ after = sampler.sample(board_db, seeded.project_id)
159
+
160
+ calls = measure.read_calls(state_dir)
161
+ pulls = measure.pulls(calls)
162
+ board = measure.board_from_db(board_db, seeded.project_id)
163
+ model_calls = srv.model_calls()
164
+ measurements = {
165
+ "project_id": seeded.project_id,
166
+ "board": board,
167
+ "board_calls": measure.board_calls(calls),
168
+ "pulls": pulls,
169
+ "sampler_before": before,
170
+ "sampler_after": after,
171
+ "model_calls_in_server_log": model_calls,
172
+ "detail": detail,
173
+ }
174
+ rec.write("measurements.json", json.dumps(measurements, indent=2) + "\n")
175
+ checks = {
176
+ "board holds 3 cards (2 seeded + About)": board["total_tasks"] == 3,
177
+ "the sampler saw 1 claimable and 1 unclaimable before": before[
178
+ "tasks_claimable"
179
+ ]
180
+ == 1
181
+ and before["tasks_unclaimable"] == 1,
182
+ "first pull assigned the first task": first.get("status") == "assigned"
183
+ and (first.get("task") or {}).get("name") == TASKS[0]["name"],
184
+ "the first close was accepted": detail.get("first_close") is True,
185
+ "second pull assigned the dependent task": second.get("status")
186
+ == "assigned"
187
+ and (second.get("task") or {}).get("name") == TASKS[1]["name"],
188
+ "the second close was accepted": detail.get("second_close") is True,
189
+ "the third pull heard the drain": third.get("status") == "drained",
190
+ "both seeded cards are done on the board": board["completed"] == 3,
191
+ "the sampler saw nothing claimable after": after["tasks_claimable"] == 0,
192
+ "every board call was read back": bool(calls)
193
+ and all("reply" in c for c in calls),
194
+ "the server made zero model calls": model_calls == 0,
195
+ }
196
+ finally:
197
+ srv.stop()
198
+ rec.copy_in(data_dir / "kanban.db", "kanban.db")
199
+ verdict = {
200
+ "PASS": all(checks.values()) and bool(checks),
201
+ "checks": checks,
202
+ "record": str(rec.dir),
203
+ }
204
+ rec.write("VERDICT.json", json.dumps(verdict, indent=2) + "\n")
205
+ return verdict
206
+
207
+
208
+ def main(argv: list[str] | None = None) -> int:
209
+ """CLI entry point; exit 0 on PASS."""
210
+ parser = argparse.ArgumentParser(description=__doc__)
211
+ parser.add_argument(
212
+ "--checkout", required=True, help="a Marcus checkout to serve from"
213
+ )
214
+ parser.add_argument("--out", default="livecheck-runs", help="where the record goes")
215
+ parser.add_argument("--python", default=sys.executable)
216
+ args = parser.parse_args(argv)
217
+ verdict = run(Path(args.checkout).resolve(), Path(args.out), args.python)
218
+ print(json.dumps(verdict, indent=2))
219
+ return 0 if verdict["PASS"] else 1
220
+
221
+
222
+ if __name__ == "__main__":
223
+ sys.exit(main())
@@ -0,0 +1,197 @@
1
+ """
2
+ The measures every board-mediated run reports, read from the record.
3
+
4
+ * pulls: how many ``request_next_task`` calls were assignments, retries or
5
+ the drain verdict, and the idle-poll ratio (test18's 88% idle polls is the
6
+ number this exists to show);
7
+ * the ready frontier as the kernel reported it on non-assignment replies, and
8
+ as the sampler read it from outside;
9
+ * the board's own counts from a copy of ``kanban.db`` when the server is gone
10
+ (a watchdog halt kills the server before measurement runs).
11
+
12
+ Lifted from the Minecraft plumbing's ``measure.py``; the candidate funnel and
13
+ the domain blocks stay with the extension that owns them.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import sqlite3
20
+ from collections import Counter
21
+ from collections.abc import Iterable
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+
26
+ def read_jsonl(path: Path) -> list[dict[str, Any]]:
27
+ """Every parseable row of a JSONL file; a missing file is an empty list."""
28
+ if not path.is_file():
29
+ return []
30
+ rows: list[dict[str, Any]] = []
31
+ for line in path.read_text(errors="replace").splitlines():
32
+ try:
33
+ row = json.loads(line)
34
+ except json.JSONDecodeError:
35
+ continue
36
+ if isinstance(row, dict):
37
+ rows.append(row)
38
+ return rows
39
+
40
+
41
+ def read_calls(state_dir: Path) -> list[dict[str, Any]]:
42
+ """
43
+ Every board call every agent recorded, oldest first.
44
+
45
+ Parameters
46
+ ----------
47
+ state_dir : Path
48
+ The run's state directory; ``board-calls.jsonl`` is read from
49
+ ``<agent>/`` and from the doubled ``<agent>/<agent>/`` layout.
50
+
51
+ Returns
52
+ -------
53
+ List[Dict[str, Any]]
54
+ Rows with ``at``, ``agent``, ``tool``, ``args``, ``outcome``, ``ms``, ``reply``.
55
+ """
56
+ files = sorted(
57
+ set(state_dir.glob("*/board-calls.jsonl"))
58
+ | set(state_dir.glob("*/*/board-calls.jsonl"))
59
+ )
60
+ calls: list[dict[str, Any]] = []
61
+ for file in files:
62
+ calls.extend(read_jsonl(file))
63
+ calls.sort(key=lambda c: str(c.get("at", "")))
64
+ return calls
65
+
66
+
67
+ def pulls(calls: Iterable[dict[str, Any]]) -> dict[str, Any]:
68
+ """
69
+ Count the pulls and compute the idle-poll ratio.
70
+
71
+ Parameters
72
+ ----------
73
+ calls : Iterable[Dict[str, Any]]
74
+ Board calls.
75
+
76
+ Returns
77
+ -------
78
+ Dict[str, Any]
79
+ ``total``, ``assigned``, ``retry``, ``drained`` and ``idle_poll_ratio``
80
+ (retries over total, ``None`` with no pulls).
81
+ """
82
+ outcomes = Counter(
83
+ str(c.get("outcome")) for c in calls if c.get("tool") == "request_next_task"
84
+ )
85
+ total = sum(outcomes.values())
86
+ return {
87
+ "total": total,
88
+ "assigned": outcomes.get("assigned", 0),
89
+ "retry": outcomes.get("retry", 0),
90
+ "drained": outcomes.get("drained", 0),
91
+ "idle_poll_ratio": (
92
+ round(outcomes.get("retry", 0) / total, 3) if total else None
93
+ ),
94
+ }
95
+
96
+
97
+ def frontier_series(calls: Iterable[dict[str, Any]]) -> list[tuple[str, int]]:
98
+ """Extract the frontier width the kernel reported on each non-assignment pull.
99
+
100
+ Returns ``(agent, width)`` pairs.
101
+ """
102
+ series: list[tuple[str, int]] = []
103
+ for call in calls:
104
+ if call.get("tool") != "request_next_task":
105
+ continue
106
+ reply = call.get("reply") or {}
107
+ drain = reply.get("drain") or reply.get("frontier") or {}
108
+ width = drain.get("tasks_claimable") if isinstance(drain, dict) else None
109
+ if isinstance(width, int):
110
+ series.append((str(call.get("agent")), width))
111
+ return series
112
+
113
+
114
+ def summarize(values: list[int]) -> dict[str, Any]:
115
+ """Summarise a series: samples, min, max and mean; ``None`` values when empty."""
116
+ if not values:
117
+ return {"samples": 0, "min": None, "max": None, "mean": None}
118
+ return {
119
+ "samples": len(values),
120
+ "min": min(values),
121
+ "max": max(values),
122
+ "mean": round(sum(values) / len(values), 3),
123
+ }
124
+
125
+
126
+ def board_calls(calls: Iterable[dict[str, Any]]) -> dict[str, int]:
127
+ """How many calls of each tool the agents made."""
128
+ return dict(Counter(str(c.get("tool")) for c in calls))
129
+
130
+
131
+ def board_from_db(kanban_db: Path, project_id: str) -> dict[str, Any]:
132
+ """
133
+ Read the board's counts from a copy of ``kanban.db``.
134
+
135
+ Parameters
136
+ ----------
137
+ kanban_db : Path
138
+ The board file.
139
+ project_id : str
140
+ The project to count.
141
+
142
+ Returns
143
+ -------
144
+ Dict[str, Any]
145
+ ``total_tasks``, ``completed``, ``in_progress``, ``blocked``, ``todo``,
146
+ ``declined``.
147
+ """
148
+ con = sqlite3.connect(f"file:{kanban_db}?mode=ro", uri=True, timeout=5)
149
+ try:
150
+ rows = con.execute(
151
+ "select status, count(*) from tasks where project_id = ? group by status",
152
+ (project_id,),
153
+ ).fetchall()
154
+ finally:
155
+ con.close()
156
+ by_status = {str(status): int(count) for status, count in rows}
157
+ return {
158
+ "total_tasks": sum(by_status.values()),
159
+ "completed": by_status.get("done", 0) + by_status.get("completed", 0),
160
+ "in_progress": by_status.get("in_progress", 0),
161
+ "blocked": by_status.get("blocked", 0),
162
+ "declined": by_status.get("declined", 0),
163
+ "todo": by_status.get("todo", 0),
164
+ }
165
+
166
+
167
+ def sampled_frontier(samples: Iterable[dict[str, Any]]) -> dict[str, Any]:
168
+ """Summarise the sampler's frontier series, keeping the series."""
169
+ widths = [
170
+ int(s["tasks_claimable"])
171
+ for s in samples
172
+ if isinstance(s.get("tasks_claimable"), int)
173
+ ]
174
+ out = summarize(widths)
175
+ out["series"] = widths
176
+ return out
177
+
178
+
179
+ def utilization(
180
+ samples: Iterable[dict[str, Any]], agents: int | None = None
181
+ ) -> dict[str, Any]:
182
+ """How many agents held a task at each sample, and the mean fraction busy."""
183
+ holding = [
184
+ len(s.get("holders") or {})
185
+ for s in samples
186
+ if isinstance(s.get("holders"), dict)
187
+ ]
188
+ out = {
189
+ "samples": len(holding),
190
+ "mean_agents_holding_a_task": (
191
+ round(sum(holding) / len(holding), 3) if holding else None
192
+ ),
193
+ "series": holding,
194
+ }
195
+ if agents and holding:
196
+ out["mean_fraction_busy"] = round(sum(holding) / len(holding) / agents, 3)
197
+ return out