agentpace 0.9.2__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.
agentpace/share.py ADDED
@@ -0,0 +1,130 @@
1
+ """Prepare rows for contribution. Local only: this module writes a file and nothing else.
2
+
3
+ There is deliberately no network code here or anywhere else in the package. Sending the file is a
4
+ git push the contributor runs themselves, after reading exactly what it holds.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from collections.abc import Iterable, Sequence
11
+ from pathlib import Path
12
+
13
+ from agentpace import schema
14
+ from agentpace.adapters import EXPERIMENTAL
15
+ from agentpace.schema import Row
16
+
17
+ NOTICE = """\
18
+ What these rows reveal, even without any content:
19
+ - Rows from you link to each other through `contributor`.
20
+ - Rows from one session link through `batch`, and `project` groups a local project.
21
+ Use --drop-project to omit it.
22
+ - `week` and the mix of models show roughly when you worked and on what plan tier.
23
+ - A pull request ties this pseudonym to your GitHub identity in public history.
24
+ - Submitting a data pull request dedicates these rows to the public domain under CC0-1.0.
25
+ """
26
+
27
+
28
+ def select(
29
+ rows: Iterable[Row],
30
+ since: str | None = None,
31
+ drop_project: bool = False,
32
+ already_published: Iterable[Row] = (),
33
+ drop_sessions: Iterable[str] = (),
34
+ ) -> tuple[list[Row], int, dict[str, int]]:
35
+ """Choose what to share. Returns the rows, how many were already published, and the rows
36
+ left out per dropped session.
37
+
38
+ A dropped session is withheld whole, before anything else is decided, so the file name and
39
+ the published count see only the rows actually shared. Every dropped session must match a
40
+ local row: a mistyped one would otherwise share the session it meant to withhold.
41
+ """
42
+ known = {schema.dedupe_key(row) for row in already_published}
43
+ dropped = dict.fromkeys(drop_sessions, 0)
44
+ chosen: list[Row] = []
45
+ skipped = 0
46
+ for row in rows:
47
+ if row["batch"] in dropped:
48
+ dropped[row["batch"]] += 1
49
+ continue
50
+ if since and row["week"] < since:
51
+ continue
52
+ if schema.dedupe_key(row) in known:
53
+ skipped += 1
54
+ continue
55
+ kept = {key: value for key, value in row.items() if key != "project" or not drop_project}
56
+ problems = schema.check_row(kept)
57
+ if problems:
58
+ raise ValueError(f"refusing to share an invalid row: {problems}")
59
+ chosen.append(kept)
60
+ unmatched = sorted(batch for batch, count in dropped.items() if not count)
61
+ if unmatched:
62
+ raise ValueError(
63
+ f"--drop-session names {len(unmatched)} sessions with no local run "
64
+ f"({', '.join(unmatched)}); nothing was written. See `agentpace runs`"
65
+ )
66
+ return sorted(chosen, key=schema.dedupe_key), skipped, dropped
67
+
68
+
69
+ def file_name(rows: Sequence[Row]) -> str:
70
+ weeks = sorted(row["week"] for row in rows)
71
+ return f"{rows[0]['contributor']}-{weeks[0]}-{weeks[-1]}.jsonl"
72
+
73
+
74
+ def held_rows(path: Path, rows: Sequence[Row]) -> int | None:
75
+ """How many rows `path` already holds, or None when it does not exist yet.
76
+
77
+ Refuses a file that holds anyone else's rows: a contribution file belongs to one contributor.
78
+ """
79
+ if not path.exists():
80
+ return None
81
+ contributor = rows[0]["contributor"]
82
+ old = schema.read_rows(path)
83
+ if any(row.get("contributor") != contributor for row in old):
84
+ raise ValueError(
85
+ f"refusing to add rows to {path.name}: it holds another contributor's rows"
86
+ )
87
+ return len(old)
88
+
89
+
90
+ def write_contribution(path: Path, rows: Sequence[Row]) -> tuple[int, int]:
91
+ """Create `path`, or extend it. Returns (rows added, rows it already held).
92
+
93
+ An existing file keeps its bytes exactly; the new rows go after them. `select` has already
94
+ left out every published run, so an appended row is never a duplicate.
95
+ """
96
+ if not rows:
97
+ raise ValueError("nothing to write")
98
+ existing = held_rows(path, rows) or 0
99
+ old = path.read_bytes() if path.exists() else b""
100
+ if old and not old.endswith(b"\n"):
101
+ old += b"\n"
102
+ new = "".join(schema.canonical(row) + "\n" for row in rows).encode("utf-8")
103
+ path.parent.mkdir(parents=True, exist_ok=True)
104
+ temporary = path.with_name(path.name + ".tmp")
105
+ temporary.write_bytes(old + new)
106
+ os.replace(temporary, path)
107
+ return len(rows), existing
108
+
109
+
110
+ def experimental_note(rows: Iterable[Row]) -> str | None:
111
+ """A warning when rows from unverified adapters are in the share. They stay in it."""
112
+ harnesses = [row["harness"] for row in rows if row["harness"] in EXPERIMENTAL]
113
+ if not harnesses:
114
+ return None
115
+ names = ", ".join(sorted(set(harnesses)))
116
+ return (
117
+ f"{len(harnesses)} rows come from experimental adapters ({names}). validate rejects them"
118
+ " until the adapter is verified, so a pull request with them stays red until a maintainer"
119
+ " reconciles them by hand; see docs/verify-adapter.md."
120
+ )
121
+
122
+
123
+ def git_commands(path: Path) -> list[str]:
124
+ return [
125
+ f'agentpace validate "{path.parent}"',
126
+ f'git add "{path}"',
127
+ 'git commit -m "data: add runs"',
128
+ "git push origin HEAD",
129
+ "Then open a pull request.",
130
+ ]
agentpace/site.py ADDED
@@ -0,0 +1,133 @@
1
+ """Build the data block inside `docs/index.html`.
2
+
3
+ The page is one static file with no build step. Its data sits inline, so it works from `file://`
4
+ and makes no requests of any kind. Fetching a JSON file would add a network call.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import math
11
+ import re
12
+ from collections import defaultdict
13
+ from collections.abc import Sequence
14
+ from html import escape
15
+ from typing import Any
16
+
17
+ from agentpace import estimate as est
18
+ from agentpace import render
19
+ from agentpace.schema import Row
20
+
21
+ START = '<script id="leaderboard-data" type="application/json">'
22
+ END = "</script>"
23
+ BINS_PER_DECADE = 10
24
+
25
+
26
+ def _summary(values: Sequence[int]) -> dict[str, int]:
27
+ ordered = sorted(values)
28
+ return {
29
+ "n": len(ordered),
30
+ "p50": round(est.quantile(ordered, 0.5)),
31
+ "p75": round(est.quantile(ordered, 0.75)),
32
+ "p90": round(est.quantile(ordered, 0.9)),
33
+ }
34
+
35
+
36
+ def _histogram(values: Sequence[int]) -> list[dict[str, int]]:
37
+ """Counts on a log axis. Aggregates only: individual runs are not embedded in the page."""
38
+ counts: dict[int, int] = defaultdict(int)
39
+ for value in values:
40
+ counts[math.floor(math.log10(value) * BINS_PER_DECADE)] += 1
41
+ return [
42
+ {
43
+ "low": round(10 ** (index / BINS_PER_DECADE)),
44
+ "high": round(10 ** ((index + 1) / BINS_PER_DECADE)),
45
+ "n": counts[index],
46
+ }
47
+ for index in range(max(counts) + 1)
48
+ ]
49
+
50
+
51
+ def payload(rows: Sequence[Row], backtest_report: dict[str, Any]) -> dict[str, Any]:
52
+ completed = [row for row in rows if row["outcome"] == "completed"]
53
+ cells: dict[tuple[str, str, str], list[int]] = defaultdict(list)
54
+ for row in completed:
55
+ cells[(row["model"], row["task_kind"], row["risk"])].append(row["duration_s"])
56
+ durations = [row["duration_s"] for row in completed]
57
+ weeks = sorted({row["week"] for row in rows})
58
+ time_split = backtest_report["splits"]["time"]["tracks"]["pre-run"]
59
+ winner = backtest_report["selection"]["winner"]
60
+ plans = backtest_report["plans"]
61
+ return {
62
+ "runs": len(rows),
63
+ "completed": len(completed),
64
+ "sessions": len({row["batch"] for row in rows}),
65
+ "contributors": len({row["contributor"] for row in rows}),
66
+ "first_week": weeks[0],
67
+ "last_week": weeks[-1],
68
+ "overall": _summary(durations),
69
+ "histogram": _histogram(durations),
70
+ "cells": [
71
+ {"model": key[0], "kind": key[1], "risk": key[2], **_summary(values)}
72
+ for key, values in sorted(cells.items())
73
+ ],
74
+ "backtest": {
75
+ "sessions": plans["sessions"],
76
+ "plan_p80_coverage": round(plans["results"]["agentpace"]["coverage80"]),
77
+ "plan_p90_coverage": round(plans["results"]["agentpace"]["coverage90"]),
78
+ "task_p80_coverage": round(time_split[winner]["session"]["coverage80"]),
79
+ "task_mdape": round(time_split[winner]["session"]["mdape"]),
80
+ },
81
+ }
82
+
83
+
84
+ def texts(data: dict[str, Any]) -> dict[str, str]:
85
+ """Sentences the page shows before any script runs, so link previews, readers without
86
+ JavaScript and crawlers all see the same numbers as everyone else."""
87
+ overall, scores = data["overall"], data["backtest"]
88
+ people = "contributor" if data["contributors"] == 1 else "contributors"
89
+ return {
90
+ "lede": (
91
+ f"Measured from {data['completed']:,} completed agent runs, not from anyone's "
92
+ f"opinion. Half finished within {render.duration(overall['p50'])}. One in ten took "
93
+ f"more than {render.duration(overall['p90'])}."
94
+ ),
95
+ "trace-note": (
96
+ f"Every completed run from {data['first_week']} to {data['last_week']}. "
97
+ "Time runs left to right on a log scale."
98
+ ),
99
+ "reading-scope": (
100
+ f"{data['runs']:,} runs in {data['sessions']} sessions from {data['contributors']} "
101
+ f"{people}, with heavy automated fan-out. It will not match your setup until more "
102
+ "people contribute."
103
+ ),
104
+ "reading-backtest": (
105
+ f"On {scores['sessions']} held-out sessions, the P80 for the whole session covered "
106
+ f"{scores['plan_p80_coverage']}% and the P90 covered {scores['plan_p90_coverage']}%. "
107
+ f"For a single task the median miss is {scores['task_mdape']}%, so read a range, "
108
+ "never one number."
109
+ ),
110
+ }
111
+
112
+
113
+ def _fill(html: str, element_id: str, text: str) -> str:
114
+ pattern = re.compile(rf'(id="{element_id}"[^>]*>)(.*?)(</(?:p|figcaption)>)', re.DOTALL)
115
+ filled, count = pattern.subn(lambda m: m[1] + escape(text, quote=False) + m[3], html, count=1)
116
+ if count != 1:
117
+ raise ValueError(f"the page has no element with id {element_id}")
118
+ return filled
119
+
120
+
121
+ def embed(html: str, data: dict[str, Any]) -> str:
122
+ start = html.index(START) + len(START)
123
+ end = html.index(END, start)
124
+ block = json.dumps(data, separators=(",", ":")).replace("</", "<\\/")
125
+ return f"{html[:start]}{block}{html[end:]}"
126
+
127
+
128
+ def build(html: str, data: dict[str, Any]) -> str:
129
+ """The data block plus the sentences drawn from it."""
130
+ page = embed(html, data)
131
+ for element_id, text in texts(data).items():
132
+ page = _fill(page, element_id, text)
133
+ return page
agentpace/turns.py ADDED
@@ -0,0 +1,282 @@
1
+ """Waiting on the human, measured from main-session timestamps. Local only.
2
+
3
+ A main Claude Code session alternates between agent turns and human prompts. The time from the
4
+ agent's last record to the next prompt a person typed is a wait on the human: review, testing,
5
+ decisions and everything else between turns, breaks included. It is elapsed time, not effort.
6
+
7
+ Main sessions are never a source of run rows. This module reads them for this one quantity, and
8
+ copies four things out of each parsed line: the record type, its timestamp, its `turnOrigin`
9
+ (only whether it says a human typed the prompt) and whether it carries a tool result. Nothing it
10
+ writes reaches `share`, the dataset or a run row.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import statistics
18
+ from collections.abc import Iterator, Sequence
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from agentpace import estimate as est
24
+ from agentpace import ingest, paths, pseudonym, schema
25
+ from agentpace.adapters.claude_code import _extended, _parse_time
26
+
27
+ Turn = dict[str, Any]
28
+
29
+ HOUR_S = 3600
30
+ _ASSISTANT, _HUMAN, _OTHER = "assistant", "human", "other"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class _Mark:
35
+ time: float
36
+ kind: str
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Gap:
41
+ time: float
42
+ gap_s: int
43
+ agent_turn_s: int
44
+
45
+
46
+ @dataclass
47
+ class TurnsReport:
48
+ files: int = 0
49
+ sessions: int = 0
50
+ gaps: int = 0
51
+ no_origin: int = 0
52
+ too_short: int = 0
53
+ unreadable: int = 0
54
+ kept_from_earlier: int = 0
55
+
56
+ def lines(self) -> list[str]:
57
+ lines = [
58
+ f"read {self.files} main-session transcripts: {self.gaps} gaps in "
59
+ f"{self.sessions} sessions"
60
+ ]
61
+ if self.no_origin:
62
+ lines.append(
63
+ f"skipped {self.no_origin} sessions with no turnOrigin, so human prompts cannot "
64
+ "be told apart from other records"
65
+ )
66
+ if self.too_short:
67
+ lines.append(f"skipped {self.too_short} sessions with fewer than 2 human prompts")
68
+ if self.unreadable:
69
+ lines.append(f"could not read {self.unreadable} files")
70
+ if self.kept_from_earlier:
71
+ lines.append(
72
+ f"kept {self.kept_from_earlier} gaps from sessions whose transcripts are gone"
73
+ )
74
+ return lines
75
+
76
+
77
+ def _schema() -> dict[str, Any]:
78
+ return schema.load_schema_file(paths.turn_schema_file())
79
+
80
+
81
+ def check_turn(turn: Any) -> list[str]:
82
+ return schema.check_against(_schema(), turn)
83
+
84
+
85
+ def _project(record: Any) -> tuple[str, float, bool | None] | None:
86
+ """Copy the allowed values out of a parsed line: the only code that touches one.
87
+
88
+ Returns the mark kind, its time, and whether the line carried a `turnOrigin` key at all
89
+ (None for assistant records, where it does not apply)."""
90
+ if not isinstance(record, dict):
91
+ return None
92
+ role = record.get("type")
93
+ time = _parse_time(record.get("timestamp"))
94
+ if time is None:
95
+ return None
96
+ if role == "assistant":
97
+ return (_ASSISTANT, time, None)
98
+ if role != "user":
99
+ return None
100
+ message = record.get("message")
101
+ content = message.get("content") if isinstance(message, dict) else None
102
+ if isinstance(content, list) and any(
103
+ isinstance(block, dict) and block.get("type") == "tool_result" for block in content
104
+ ):
105
+ return (_OTHER, time, None)
106
+ origin = record.get("turnOrigin")
107
+ return (_HUMAN if origin == "human" else _OTHER, time, "turnOrigin" in record)
108
+
109
+
110
+ def gaps(marks: Sequence[_Mark]) -> list[Gap]:
111
+ """Each human prompt after an agent record opens a gap, paired with the agent turn before
112
+ it."""
113
+ found: list[Gap] = []
114
+ last_agent: float | None = None
115
+ previous_prompt: float | None = None
116
+ for mark in sorted(marks, key=lambda m: m.time):
117
+ if mark.kind == _ASSISTANT:
118
+ last_agent = mark.time
119
+ elif mark.kind == _HUMAN:
120
+ if last_agent is not None:
121
+ turn = 0.0
122
+ if previous_prompt is not None and last_agent >= previous_prompt:
123
+ turn = last_agent - previous_prompt
124
+ found.append(Gap(mark.time, round(mark.time - last_agent), round(turn)))
125
+ previous_prompt = mark.time
126
+ return found
127
+
128
+
129
+ @dataclass
130
+ class _Session:
131
+ marks: list[_Mark] = field(default_factory=list)
132
+ has_origin: bool = False
133
+
134
+
135
+ def _read(path: str) -> _Session:
136
+ session = _Session()
137
+ with open(path, encoding="utf-8") as handle:
138
+ for line in handle:
139
+ try:
140
+ record = json.loads(line)
141
+ except ValueError:
142
+ continue
143
+ projected = _project(record)
144
+ if projected is None:
145
+ continue
146
+ kind, time, carries_origin = projected
147
+ session.marks.append(_Mark(time, kind))
148
+ session.has_origin = session.has_origin or bool(carries_origin)
149
+ return session
150
+
151
+
152
+ def discover(root: Path) -> Iterator[tuple[str, str]]:
153
+ """Main-session transcripts sit directly in each project folder as `<session>.jsonl`."""
154
+ base = _extended(root)
155
+ try:
156
+ projects = sorted(os.scandir(base), key=lambda entry: entry.name)
157
+ except OSError:
158
+ return
159
+ for project in projects:
160
+ if not project.is_dir():
161
+ continue
162
+ try:
163
+ files = sorted(os.scandir(project.path), key=lambda entry: entry.name)
164
+ except OSError:
165
+ continue
166
+ for entry in files:
167
+ if entry.is_file() and entry.name.endswith(".jsonl"):
168
+ yield entry.path, entry.name[: -len(".jsonl")]
169
+
170
+
171
+ def collect(root: Path, secret: bytes) -> tuple[list[Turn], set[str], TurnsReport]:
172
+ report = TurnsReport()
173
+ turns: list[Turn] = []
174
+ seen: set[str] = set()
175
+ for path, session_id in discover(root):
176
+ report.files += 1
177
+ try:
178
+ session = _read(path)
179
+ except (OSError, UnicodeDecodeError):
180
+ report.unreadable += 1
181
+ continue
182
+ if not session.has_origin:
183
+ report.no_origin += 1
184
+ continue
185
+ if sum(mark.kind == _HUMAN for mark in session.marks) < 2:
186
+ report.too_short += 1
187
+ continue
188
+ pseudonym_id = pseudonym.turn_session(secret, session_id)
189
+ seen.add(pseudonym_id)
190
+ found = gaps(session.marks)
191
+ if found:
192
+ report.sessions += 1
193
+ for gap in found:
194
+ turns.append(
195
+ {
196
+ "session": pseudonym_id,
197
+ "week": ingest.iso_week(gap.time),
198
+ "gap_s": gap.gap_s,
199
+ "agent_turn_s": gap.agent_turn_s,
200
+ }
201
+ )
202
+ report.gaps = len(turns)
203
+ return turns, seen, report
204
+
205
+
206
+ def read_turns(path: Path) -> list[Turn]:
207
+ if not path.exists():
208
+ return []
209
+ turns = []
210
+ with path.open(encoding="utf-8") as handle:
211
+ for number, line in enumerate(handle, 1):
212
+ if not line.strip():
213
+ continue
214
+ try:
215
+ turn = json.loads(line)
216
+ except ValueError:
217
+ turn = None
218
+ if turn is None or check_turn(turn):
219
+ raise ValueError(
220
+ f"line {number} of {path.name} is not a valid turn; run agentpace turns"
221
+ )
222
+ turns.append(turn)
223
+ return turns
224
+
225
+
226
+ def write_turns(path: Path, turns: Sequence[Turn]) -> None:
227
+ """Atomic, like the run store, so an interrupted write cannot leave half a file."""
228
+ order = ("session", "week", "gap_s", "agent_turn_s")
229
+ path.parent.mkdir(parents=True, exist_ok=True)
230
+ temporary = path.with_name(path.name + ".tmp")
231
+ with temporary.open("w", encoding="utf-8", newline="\n") as handle:
232
+ for turn in turns:
233
+ handle.write(json.dumps({key: turn[key] for key in order}, separators=(",", ":")))
234
+ handle.write("\n")
235
+ os.replace(temporary, path)
236
+
237
+
238
+ def update(root: Path, store: Path, secret: bytes) -> TurnsReport:
239
+ """Replace the gaps of every session read in this pass and keep the rest, because Claude
240
+ Code deletes old transcripts."""
241
+ fresh, seen, report = collect(root, secret)
242
+ kept = [turn for turn in read_turns(store) if turn["session"] not in seen]
243
+ report.kept_from_earlier = len(kept)
244
+ for turn in fresh:
245
+ problems = check_turn(turn)
246
+ if problems:
247
+ raise ValueError(f"a turn failed its schema: {problems}")
248
+ write_turns(store, kept + fresh)
249
+ return report
250
+
251
+
252
+ @dataclass(frozen=True)
253
+ class Summary:
254
+ gaps: int
255
+ sessions: int
256
+ p50: float
257
+ p80: float
258
+ p90: float
259
+ over_hour: float
260
+ waiting_share: float
261
+
262
+
263
+ def summarize(turns: Sequence[Turn]) -> Summary | None:
264
+ """Uncapped quantiles of the wait per human turn, with no constant applied."""
265
+ if not turns:
266
+ return None
267
+ values = sorted(float(turn["gap_s"]) for turn in turns)
268
+ totals: dict[str, list[float]] = {}
269
+ for turn in turns:
270
+ waiting, working = totals.setdefault(turn["session"], [0.0, 0.0])
271
+ totals[turn["session"]] = [waiting + turn["gap_s"], working + turn["agent_turn_s"]]
272
+ shares = [w / (w + a) for w, a in totals.values() if w + a > 0]
273
+ p50, p80, p90 = (est.quantile(values, q) for q in est.QUANTILES)
274
+ return Summary(
275
+ gaps=len(values),
276
+ sessions=len(totals),
277
+ p50=p50,
278
+ p80=p80,
279
+ p90=p90,
280
+ over_hour=sum(value > HOUR_S for value in values) / len(values),
281
+ waiting_share=statistics.median(shares) if shares else 0.0,
282
+ )
agentpace/validate.py ADDED
@@ -0,0 +1,115 @@
1
+ """Checks every data file must pass, locally and in CI.
2
+
3
+ Contributed files are parsed, never executed. Findings name a file, a line and a rule, and never
4
+ quote the offending value, because a CI log is public and the value may be the very thing that
5
+ should not be published.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from collections.abc import Sequence
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from agentpace import ingest, schema
17
+ from agentpace.adapters import EXPERIMENTAL
18
+
19
+ MAX_DURATION_S = 86_400
20
+ MAX_CALLS_PER_SECOND = 5
21
+ MAX_FILE_BYTES = 20_000_000
22
+
23
+ _PII = {
24
+ "email address": re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"),
25
+ "url": re.compile(r"[a-z][a-z0-9+.-]*://", re.IGNORECASE),
26
+ "windows path": re.compile(r"[A-Za-z]:[\\/]"),
27
+ "unix path": re.compile(r"/(?:home|Users|root|var|tmp|etc|opt|mnt|workspace)/"),
28
+ "ip address": re.compile(r"(?<![\d.])\d{1,3}(?:\.\d{1,3}){3}(?![\d.])"),
29
+ "api key": re.compile(r"\b(?:sk-[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9]{8,}|AKIA[0-9A-Z]{8,})"),
30
+ "jwt": re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"),
31
+ }
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Finding:
36
+ file: str
37
+ line: int
38
+ rule: str
39
+
40
+ def __str__(self) -> str:
41
+ return f"{self.file}:{self.line}: {self.rule}"
42
+
43
+
44
+ def scan_for_pii(text: str) -> list[str]:
45
+ """Names of the PII shapes present. Runs over raw text, before any parsing."""
46
+ return [name for name, pattern in _PII.items() if pattern.search(text)]
47
+
48
+
49
+ def check_values(row: schema.Row) -> list[str]:
50
+ """Rules a schema cannot express."""
51
+ problems: list[str] = []
52
+ if row["harness"] in EXPERIMENTAL:
53
+ problems.append(
54
+ "harness adapter is experimental: rows are not published until it is verified"
55
+ )
56
+ if ingest.normalize_model(row["model"]) != row["model"]:
57
+ problems.append("model is not a known public family or 'other'")
58
+ if row["duration_s"] > MAX_DURATION_S:
59
+ problems.append("outlier: duration over 24 hours")
60
+ if row["tool_calls"] > row["duration_s"] * MAX_CALLS_PER_SECOND:
61
+ problems.append("outlier: implausible tool calls per second")
62
+ if row["outcome"] == "completed" and row["tool_calls"] == 0 and not row.get("tokens"):
63
+ problems.append("outlier: completed with no activity")
64
+ return problems
65
+
66
+
67
+ def _check_file(path: Path, seen: dict[tuple[str, str, int, int], str]) -> list[Finding]:
68
+ name = path.name
69
+ if path.suffix != ".jsonl":
70
+ return [Finding(name, 0, "data files must end in .jsonl")]
71
+ if path.stat().st_size > MAX_FILE_BYTES:
72
+ return [Finding(name, 0, "file is too large")]
73
+ try:
74
+ lines = path.read_text(encoding="utf-8").splitlines()
75
+ except UnicodeDecodeError:
76
+ return [Finding(name, 0, "file is not UTF-8")]
77
+ findings: list[Finding] = []
78
+ contributors: set[str] = set()
79
+ for number, line in enumerate(lines, start=1):
80
+ if not line.strip():
81
+ continue
82
+ for shape in scan_for_pii(line):
83
+ findings.append(Finding(name, number, f"looks like PII: {shape}"))
84
+ try:
85
+ row = json.loads(line)
86
+ except ValueError:
87
+ findings.append(Finding(name, number, "not valid JSON"))
88
+ continue
89
+ problems = schema.check_row(row)
90
+ if problems:
91
+ findings += [Finding(name, number, problem) for problem in problems]
92
+ continue
93
+ findings += [Finding(name, number, problem) for problem in check_values(row)]
94
+ key = schema.dedupe_key(row)
95
+ if key in seen:
96
+ findings.append(Finding(name, number, f"duplicate of a run in {seen[key]}"))
97
+ seen[key] = name
98
+ contributors.add(row["contributor"])
99
+ if len(contributors) > 1:
100
+ findings.append(Finding(name, 0, "a file must hold one contributor"))
101
+ elif contributors and not name.startswith(next(iter(contributors))):
102
+ findings.append(Finding(name, 0, "file name must start with its contributor"))
103
+ return findings
104
+
105
+
106
+ def validate(sources: Sequence[Path]) -> list[Finding]:
107
+ files: list[Path] = []
108
+ for source in sources:
109
+ files += sorted(p for p in source.iterdir() if p.is_file()) if source.is_dir() else [source]
110
+ seen: dict[tuple[str, str, int, int], str] = {}
111
+ findings: list[Finding] = []
112
+ for path in files:
113
+ if path.name != ".gitkeep":
114
+ findings += _check_file(path, seen)
115
+ return findings