sourcebound 1.2.1__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.
- clean_docs/__init__.py +26 -0
- clean_docs/__main__.py +3 -0
- clean_docs/accessibility.py +182 -0
- clean_docs/adapters/__init__.py +1 -0
- clean_docs/adapters/event_capture.py +56 -0
- clean_docs/adapters/mdx_dependencies.json +714 -0
- clean_docs/adapters/mdx_parser.mjs +29992 -0
- clean_docs/applicability.py +330 -0
- clean_docs/audit.py +1120 -0
- clean_docs/bootstrap.py +507 -0
- clean_docs/capabilities.py +200 -0
- clean_docs/changed.py +452 -0
- clean_docs/claims.py +840 -0
- clean_docs/cli.py +1612 -0
- clean_docs/context.py +307 -0
- clean_docs/corpus.py +377 -0
- clean_docs/demo.py +369 -0
- clean_docs/doctor.py +184 -0
- clean_docs/emit/__init__.py +4 -0
- clean_docs/emit/llms_txt.py +102 -0
- clean_docs/emit/stepwise.py +168 -0
- clean_docs/engine.py +324 -0
- clean_docs/errors.py +20 -0
- clean_docs/evaluation.py +867 -0
- clean_docs/execution.py +138 -0
- clean_docs/explain.py +123 -0
- clean_docs/extractors/__init__.py +19 -0
- clean_docs/extractors/command.py +51 -0
- clean_docs/extractors/inventory.py +176 -0
- clean_docs/extractors/json_pointer.py +84 -0
- clean_docs/extractors/python_literal.py +104 -0
- clean_docs/extractors/static.py +111 -0
- clean_docs/feedback.py +1390 -0
- clean_docs/impact.py +1624 -0
- clean_docs/improvements.py +1178 -0
- clean_docs/inventory.py +474 -0
- clean_docs/isolation.py +157 -0
- clean_docs/manifest.py +898 -0
- clean_docs/mdx.py +272 -0
- clean_docs/migration.py +121 -0
- clean_docs/models.py +194 -0
- clean_docs/outcomes.py +296 -0
- clean_docs/performance.py +123 -0
- clean_docs/phrasing.py +448 -0
- clean_docs/plugins.py +249 -0
- clean_docs/policy.py +536 -0
- clean_docs/projections.py +255 -0
- clean_docs/regions.py +75 -0
- clean_docs/release.py +232 -0
- clean_docs/renderers.py +57 -0
- clean_docs/residue.py +311 -0
- clean_docs/review_contracts.py +862 -0
- clean_docs/review_ledger.py +297 -0
- clean_docs/review_limits.py +9 -0
- clean_docs/sensitivity.py +602 -0
- clean_docs/snapshot.py +212 -0
- clean_docs/standard.py +281 -0
- clean_docs/standards/default.json +309 -0
- clean_docs/standards/exemplars.md +87 -0
- clean_docs/standards/v0-migrations.json +26 -0
- clean_docs/symbols.py +47 -0
- clean_docs/templates.py +96 -0
- clean_docs/verdict.py +1397 -0
- clean_docs/visuals.py +346 -0
- clean_docs/write_gate.py +170 -0
- sourcebound-1.2.1.dist-info/LICENSE +21 -0
- sourcebound-1.2.1.dist-info/METADATA +109 -0
- sourcebound-1.2.1.dist-info/RECORD +71 -0
- sourcebound-1.2.1.dist-info/WHEEL +5 -0
- sourcebound-1.2.1.dist-info/entry_points.txt +2 -0
- sourcebound-1.2.1.dist-info/top_level.txt +1 -0
clean_docs/evaluation.py
ADDED
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
"""Run observable human tasks and replayable agent round trips."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from dataclasses import asdict, dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Protocol
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
from clean_docs.audit import audit
|
|
21
|
+
from clean_docs.engine import evaluate
|
|
22
|
+
from clean_docs.errors import CleanDocsError, ConfigurationError
|
|
23
|
+
from clean_docs.execution import resolve_argv
|
|
24
|
+
from clean_docs.manifest import load_manifest
|
|
25
|
+
from clean_docs.models import CommandSpec, Manifest
|
|
26
|
+
from clean_docs.regions import atomic_write
|
|
27
|
+
from clean_docs.sensitivity import evaluate_binding_sensitivity, load_json_object
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
TASK_KEYS = {"id", "audience", "prompt", "context", "model", "scorer"}
|
|
31
|
+
MODEL_KEYS = {"adapter", "name", "response", "argv", "timeout_seconds"}
|
|
32
|
+
DEFAULT_PROVIDER_TIMEOUT_SECONDS = 120
|
|
33
|
+
MAX_PROVIDER_TIMEOUT_SECONDS = 3600
|
|
34
|
+
SCORER_KEYS = {
|
|
35
|
+
"command": {"type", "commands"},
|
|
36
|
+
"structured-output": {"type", "expected"},
|
|
37
|
+
"configuration": {"type", "repository"},
|
|
38
|
+
"cited-limit": {"type", "answer", "citation", "forbidden"},
|
|
39
|
+
"mutation-red": {
|
|
40
|
+
"type",
|
|
41
|
+
"repository",
|
|
42
|
+
"fact",
|
|
43
|
+
"fact_sha256",
|
|
44
|
+
"expected_state",
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
COMMAND_EXPECTATION_KEYS = {
|
|
48
|
+
"ref", "documented_as", "exit_code", "stdout_contains", "stderr_contains"
|
|
49
|
+
}
|
|
50
|
+
ID = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ResponseProvider(Protocol):
|
|
54
|
+
@property
|
|
55
|
+
def adapter(self) -> str: ...
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def name(self) -> str: ...
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def configuration_sha256(self) -> str: ...
|
|
62
|
+
|
|
63
|
+
def complete(self, prompt: str) -> str: ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class ModelSpec:
|
|
68
|
+
adapter: str
|
|
69
|
+
name: str
|
|
70
|
+
response: Path | None = None
|
|
71
|
+
argv: tuple[str, ...] = ()
|
|
72
|
+
timeout_seconds: int = DEFAULT_PROVIDER_TIMEOUT_SECONDS
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class EvaluationTask:
|
|
77
|
+
id: str
|
|
78
|
+
audience: str
|
|
79
|
+
prompt: str
|
|
80
|
+
context: tuple[Path, ...]
|
|
81
|
+
model: ModelSpec | None
|
|
82
|
+
scorer: dict[str, Any]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class TaskResult:
|
|
87
|
+
id: str
|
|
88
|
+
audience: str
|
|
89
|
+
ok: bool
|
|
90
|
+
scorer: str
|
|
91
|
+
scorer_sha256: str
|
|
92
|
+
claim: str
|
|
93
|
+
corpus_sha256: str
|
|
94
|
+
prompt_sha256: str
|
|
95
|
+
response_sha256: str | None
|
|
96
|
+
model: dict[str, str] | None
|
|
97
|
+
detail: str
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class EvaluationReport:
|
|
102
|
+
mode: str
|
|
103
|
+
human_tasks: tuple[TaskResult, ...]
|
|
104
|
+
agent_tasks: tuple[TaskResult, ...]
|
|
105
|
+
hygiene_findings: tuple[dict[str, object], ...]
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def ok(self) -> bool:
|
|
109
|
+
return all(result.ok for result in self.human_tasks + self.agent_tasks)
|
|
110
|
+
|
|
111
|
+
def as_dict(self) -> dict[str, object]:
|
|
112
|
+
return {
|
|
113
|
+
"schema": "sourcebound.evaluation.v1",
|
|
114
|
+
"mode": self.mode,
|
|
115
|
+
"ok": self.ok,
|
|
116
|
+
"scores": {
|
|
117
|
+
"human": _score(self.human_tasks),
|
|
118
|
+
"agent": _score(self.agent_tasks),
|
|
119
|
+
},
|
|
120
|
+
"human_tasks": [asdict(result) for result in self.human_tasks],
|
|
121
|
+
"agent_tasks": [asdict(result) for result in self.agent_tasks],
|
|
122
|
+
"hygiene_findings": list(self.hygiene_findings),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class RecordedResponseProvider:
|
|
128
|
+
path: Path
|
|
129
|
+
name: str
|
|
130
|
+
adapter: str = "recorded"
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def configuration_sha256(self) -> str:
|
|
134
|
+
return hashlib.sha256(str(self.path).encode()).hexdigest()
|
|
135
|
+
|
|
136
|
+
def complete(self, prompt: str) -> str:
|
|
137
|
+
del prompt
|
|
138
|
+
try:
|
|
139
|
+
return self.path.read_text(encoding="utf-8")
|
|
140
|
+
except OSError as exc:
|
|
141
|
+
raise ConfigurationError(f"cannot read recorded response {self.path}: {exc}") from exc
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class CommandResponseProvider:
|
|
146
|
+
argv: tuple[str, ...]
|
|
147
|
+
name: str
|
|
148
|
+
root: Path
|
|
149
|
+
timeout_seconds: int = DEFAULT_PROVIDER_TIMEOUT_SECONDS
|
|
150
|
+
adapter: str = "command"
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def configuration_sha256(self) -> str:
|
|
154
|
+
return hashlib.sha256(
|
|
155
|
+
json.dumps(
|
|
156
|
+
{
|
|
157
|
+
"argv": self.argv,
|
|
158
|
+
"timeout_seconds": self.timeout_seconds,
|
|
159
|
+
},
|
|
160
|
+
sort_keys=True,
|
|
161
|
+
separators=(",", ":"),
|
|
162
|
+
).encode()
|
|
163
|
+
).hexdigest()
|
|
164
|
+
|
|
165
|
+
def complete(self, prompt: str) -> str:
|
|
166
|
+
try:
|
|
167
|
+
proc = subprocess.run(
|
|
168
|
+
resolve_argv(self.argv),
|
|
169
|
+
cwd=self.root,
|
|
170
|
+
input=prompt,
|
|
171
|
+
text=True,
|
|
172
|
+
capture_output=True,
|
|
173
|
+
timeout=self.timeout_seconds,
|
|
174
|
+
check=False,
|
|
175
|
+
env=_command_environment(),
|
|
176
|
+
)
|
|
177
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
178
|
+
raise ConfigurationError(f"provider command failed: {exc}") from exc
|
|
179
|
+
if proc.returncode != 0:
|
|
180
|
+
detail = proc.stderr.strip() or proc.stdout.strip() or "no output"
|
|
181
|
+
raise ConfigurationError(
|
|
182
|
+
f"provider command exited {proc.returncode}: {detail}"
|
|
183
|
+
)
|
|
184
|
+
return proc.stdout
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _score(results: tuple[TaskResult, ...]) -> dict[str, int]:
|
|
188
|
+
return {
|
|
189
|
+
"passed": sum(result.ok for result in results),
|
|
190
|
+
"attempted": len(results),
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _command_environment() -> dict[str, str]:
|
|
195
|
+
"""Resolve sibling console scripts from the environment running Sourcebound."""
|
|
196
|
+
executable_dir = str(Path(sys.executable).parent)
|
|
197
|
+
inherited_path = os.environ.get("PATH", "")
|
|
198
|
+
path = executable_dir if not inherited_path else executable_dir + os.pathsep + inherited_path
|
|
199
|
+
return {**os.environ, "NO_COLOR": "1", "PATH": path}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _utc_now() -> str:
|
|
203
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _repository_state(root: Path, excluded: Path) -> tuple[str, str, int]:
|
|
207
|
+
commit_process = subprocess.run(
|
|
208
|
+
["git", "-C", str(root), "rev-parse", "HEAD"],
|
|
209
|
+
text=True,
|
|
210
|
+
capture_output=True,
|
|
211
|
+
timeout=30,
|
|
212
|
+
check=False,
|
|
213
|
+
)
|
|
214
|
+
commit = (
|
|
215
|
+
commit_process.stdout.strip()
|
|
216
|
+
if commit_process.returncode == 0
|
|
217
|
+
else "unversioned"
|
|
218
|
+
)
|
|
219
|
+
try:
|
|
220
|
+
excluded_relative = excluded.resolve().relative_to(root)
|
|
221
|
+
except ValueError:
|
|
222
|
+
excluded_relative = None
|
|
223
|
+
digest = hashlib.sha256()
|
|
224
|
+
count = 0
|
|
225
|
+
candidates = sorted(
|
|
226
|
+
path
|
|
227
|
+
for path in root.rglob("*")
|
|
228
|
+
if ".git" not in path.relative_to(root).parts
|
|
229
|
+
)
|
|
230
|
+
for candidate in candidates:
|
|
231
|
+
relative_path = candidate.relative_to(root)
|
|
232
|
+
relative = relative_path.as_posix()
|
|
233
|
+
if (
|
|
234
|
+
excluded_relative is not None
|
|
235
|
+
and relative_path.is_relative_to(excluded_relative)
|
|
236
|
+
):
|
|
237
|
+
continue
|
|
238
|
+
if candidate.is_symlink():
|
|
239
|
+
content = os.readlink(candidate).encode()
|
|
240
|
+
elif candidate.is_file():
|
|
241
|
+
content = candidate.read_bytes()
|
|
242
|
+
else:
|
|
243
|
+
continue
|
|
244
|
+
digest.update(relative.encode())
|
|
245
|
+
digest.update(b"\0")
|
|
246
|
+
digest.update(content)
|
|
247
|
+
digest.update(b"\0")
|
|
248
|
+
count += 1
|
|
249
|
+
return commit, digest.hexdigest(), count
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _provider_run_record(
|
|
253
|
+
*,
|
|
254
|
+
task: EvaluationTask,
|
|
255
|
+
provider: ResponseProvider,
|
|
256
|
+
commit: str,
|
|
257
|
+
worktree_digest: str,
|
|
258
|
+
worktree_files: int,
|
|
259
|
+
corpus_digest: str,
|
|
260
|
+
prompt_digest: str,
|
|
261
|
+
prompt_bytes: int,
|
|
262
|
+
scorer_digest: str,
|
|
263
|
+
) -> dict[str, object]:
|
|
264
|
+
identity = {
|
|
265
|
+
"task": task.id,
|
|
266
|
+
"commit": commit,
|
|
267
|
+
"corpus_sha256": corpus_digest,
|
|
268
|
+
"prompt_sha256": prompt_digest,
|
|
269
|
+
"scorer_sha256": scorer_digest,
|
|
270
|
+
"provider": {
|
|
271
|
+
"adapter": provider.adapter,
|
|
272
|
+
"name": provider.name,
|
|
273
|
+
"configuration_sha256": provider.configuration_sha256,
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
run_id = hashlib.sha256(
|
|
277
|
+
json.dumps(identity, sort_keys=True, separators=(",", ":")).encode()
|
|
278
|
+
).hexdigest()
|
|
279
|
+
return {
|
|
280
|
+
"schema": "sourcebound.provider-run.v1",
|
|
281
|
+
"run_id": run_id,
|
|
282
|
+
"idempotency_id": run_id,
|
|
283
|
+
"task": task.id,
|
|
284
|
+
"state": "invoking",
|
|
285
|
+
"started_at": _utc_now(),
|
|
286
|
+
"completed_at": None,
|
|
287
|
+
"repository": {
|
|
288
|
+
"commit": commit,
|
|
289
|
+
"worktree_before_sha256": worktree_digest,
|
|
290
|
+
"worktree_after_sha256": None,
|
|
291
|
+
"files": worktree_files,
|
|
292
|
+
},
|
|
293
|
+
"inputs": {
|
|
294
|
+
"corpus_sha256": corpus_digest,
|
|
295
|
+
"prompt_sha256": prompt_digest,
|
|
296
|
+
"prompt_bytes": prompt_bytes,
|
|
297
|
+
"scorer_sha256": scorer_digest,
|
|
298
|
+
},
|
|
299
|
+
"provider": {
|
|
300
|
+
"adapter": provider.adapter,
|
|
301
|
+
"name": provider.name,
|
|
302
|
+
"configuration_sha256": provider.configuration_sha256,
|
|
303
|
+
"timeout_seconds": getattr(provider, "timeout_seconds", None),
|
|
304
|
+
},
|
|
305
|
+
"response_sha256": None,
|
|
306
|
+
"error": None,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _complete_provider_run(
|
|
311
|
+
record: dict[str, object],
|
|
312
|
+
*,
|
|
313
|
+
state: str,
|
|
314
|
+
after_digest: str,
|
|
315
|
+
response: str | None = None,
|
|
316
|
+
error: CleanDocsError | None = None,
|
|
317
|
+
) -> dict[str, object]:
|
|
318
|
+
updated = json.loads(json.dumps(record))
|
|
319
|
+
updated["state"] = state
|
|
320
|
+
updated["completed_at"] = _utc_now()
|
|
321
|
+
repository = updated["repository"]
|
|
322
|
+
assert isinstance(repository, dict)
|
|
323
|
+
repository["worktree_after_sha256"] = after_digest
|
|
324
|
+
if response is not None:
|
|
325
|
+
updated["response_sha256"] = hashlib.sha256(response.encode()).hexdigest()
|
|
326
|
+
if error is not None:
|
|
327
|
+
updated["error"] = {
|
|
328
|
+
"type": type(error).__name__,
|
|
329
|
+
"detail_sha256": hashlib.sha256(str(error).encode()).hexdigest(),
|
|
330
|
+
}
|
|
331
|
+
return updated
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _mapping(raw: Any, where: str) -> dict[str, Any]:
|
|
335
|
+
if not isinstance(raw, dict):
|
|
336
|
+
raise ConfigurationError(f"{where} must be a mapping")
|
|
337
|
+
return raw
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _relative(raw: Any, where: str) -> Path:
|
|
341
|
+
if not isinstance(raw, str) or not raw:
|
|
342
|
+
raise ConfigurationError(f"{where} must be a non-empty path")
|
|
343
|
+
path = Path(raw)
|
|
344
|
+
if path.is_absolute() or ".." in path.parts:
|
|
345
|
+
raise ConfigurationError(f"{where} must stay inside the repository")
|
|
346
|
+
return path
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _strings(raw: Any, where: str, *, nonempty: bool = False) -> tuple[str, ...]:
|
|
350
|
+
if not isinstance(raw, list) or (nonempty and not raw) or not all(
|
|
351
|
+
isinstance(value, str) and value for value in raw
|
|
352
|
+
):
|
|
353
|
+
qualifier = "non-empty " if nonempty else ""
|
|
354
|
+
raise ConfigurationError(f"{where} must be a {qualifier}string list")
|
|
355
|
+
return tuple(raw)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _model(raw: Any, where: str) -> ModelSpec:
|
|
359
|
+
data = _mapping(raw, where)
|
|
360
|
+
unknown = sorted(set(data) - MODEL_KEYS)
|
|
361
|
+
if unknown:
|
|
362
|
+
raise ConfigurationError(f"{where} has unknown key(s): {', '.join(unknown)}")
|
|
363
|
+
adapter = data.get("adapter")
|
|
364
|
+
name = data.get("name")
|
|
365
|
+
if adapter not in {"recorded", "command"}:
|
|
366
|
+
raise ConfigurationError(f"{where}.adapter must be recorded or command")
|
|
367
|
+
if not isinstance(name, str) or not name:
|
|
368
|
+
raise ConfigurationError(f"{where}.name must be non-empty")
|
|
369
|
+
if adapter == "recorded":
|
|
370
|
+
command_only = sorted({"argv", "timeout_seconds"} & set(data))
|
|
371
|
+
if command_only:
|
|
372
|
+
raise ConfigurationError(
|
|
373
|
+
f"{where}.{command_only[0]} is only valid for command adapters"
|
|
374
|
+
)
|
|
375
|
+
return ModelSpec(adapter, name, response=_relative(data.get("response"), f"{where}.response"))
|
|
376
|
+
if "response" in data:
|
|
377
|
+
raise ConfigurationError(f"{where}.response is only valid for recorded adapters")
|
|
378
|
+
timeout_seconds = data.get(
|
|
379
|
+
"timeout_seconds", DEFAULT_PROVIDER_TIMEOUT_SECONDS
|
|
380
|
+
)
|
|
381
|
+
if (
|
|
382
|
+
not isinstance(timeout_seconds, int)
|
|
383
|
+
or isinstance(timeout_seconds, bool)
|
|
384
|
+
or not 1 <= timeout_seconds <= MAX_PROVIDER_TIMEOUT_SECONDS
|
|
385
|
+
):
|
|
386
|
+
raise ConfigurationError(
|
|
387
|
+
f"{where}.timeout_seconds must be an integer from 1 to "
|
|
388
|
+
f"{MAX_PROVIDER_TIMEOUT_SECONDS}"
|
|
389
|
+
)
|
|
390
|
+
return ModelSpec(
|
|
391
|
+
adapter,
|
|
392
|
+
name,
|
|
393
|
+
argv=_strings(data.get("argv"), f"{where}.argv", nonempty=True),
|
|
394
|
+
timeout_seconds=timeout_seconds,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _scorer(raw: Any, where: str) -> dict[str, Any]:
|
|
399
|
+
data = _mapping(raw, where)
|
|
400
|
+
kind = data.get("type")
|
|
401
|
+
if kind not in SCORER_KEYS:
|
|
402
|
+
raise ConfigurationError(
|
|
403
|
+
f"{where}.type must be command, configuration, structured-output, "
|
|
404
|
+
"cited-limit, or mutation-red"
|
|
405
|
+
)
|
|
406
|
+
unknown = sorted(set(data) - SCORER_KEYS[kind])
|
|
407
|
+
if unknown:
|
|
408
|
+
raise ConfigurationError(f"{where} has unknown key(s): {', '.join(unknown)}")
|
|
409
|
+
if kind == "command":
|
|
410
|
+
commands = data.get("commands")
|
|
411
|
+
if not isinstance(commands, list) or not commands:
|
|
412
|
+
raise ConfigurationError(f"{where}.commands must be a non-empty list")
|
|
413
|
+
for index, candidate in enumerate(commands):
|
|
414
|
+
command = _mapping(candidate, f"{where}.commands[{index}]")
|
|
415
|
+
if set(command) != COMMAND_EXPECTATION_KEYS:
|
|
416
|
+
raise ConfigurationError(
|
|
417
|
+
f"{where}.commands[{index}] must contain exactly: "
|
|
418
|
+
+ ", ".join(sorted(COMMAND_EXPECTATION_KEYS))
|
|
419
|
+
)
|
|
420
|
+
if not isinstance(command["ref"], str) or not command["ref"]:
|
|
421
|
+
raise ConfigurationError(f"{where}.commands[{index}].ref must be non-empty")
|
|
422
|
+
if not isinstance(command["documented_as"], str) or not command["documented_as"]:
|
|
423
|
+
raise ConfigurationError(
|
|
424
|
+
f"{where}.commands[{index}].documented_as must be non-empty"
|
|
425
|
+
)
|
|
426
|
+
if not isinstance(command["exit_code"], int):
|
|
427
|
+
raise ConfigurationError(f"{where}.commands[{index}].exit_code must be an integer")
|
|
428
|
+
_strings(command["stdout_contains"], f"{where}.commands[{index}].stdout_contains")
|
|
429
|
+
_strings(command["stderr_contains"], f"{where}.commands[{index}].stderr_contains")
|
|
430
|
+
elif kind == "configuration":
|
|
431
|
+
_relative(data.get("repository"), f"{where}.repository")
|
|
432
|
+
elif kind == "cited-limit":
|
|
433
|
+
for field in ("answer", "citation"):
|
|
434
|
+
if not isinstance(data.get(field), str) or not data[field]:
|
|
435
|
+
raise ConfigurationError(f"{where}.{field} must be non-empty")
|
|
436
|
+
_strings(data.get("forbidden", []), f"{where}.forbidden")
|
|
437
|
+
elif kind == "mutation-red":
|
|
438
|
+
_relative(data.get("repository"), f"{where}.repository")
|
|
439
|
+
_relative(data.get("fact"), f"{where}.fact")
|
|
440
|
+
fact_sha256 = data.get("fact_sha256")
|
|
441
|
+
if (
|
|
442
|
+
not isinstance(fact_sha256, str)
|
|
443
|
+
or not re.fullmatch(r"[0-9a-f]{64}", fact_sha256)
|
|
444
|
+
):
|
|
445
|
+
raise ConfigurationError(
|
|
446
|
+
f"{where}.fact_sha256 must be a lowercase SHA-256 digest"
|
|
447
|
+
)
|
|
448
|
+
if data.get("expected_state") not in {
|
|
449
|
+
"sensitive",
|
|
450
|
+
"insensitive",
|
|
451
|
+
"invalid",
|
|
452
|
+
"unsupported",
|
|
453
|
+
}:
|
|
454
|
+
raise ConfigurationError(
|
|
455
|
+
f"{where}.expected_state must be sensitive, insensitive, "
|
|
456
|
+
"invalid, or unsupported"
|
|
457
|
+
)
|
|
458
|
+
return data
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def load_evaluation_tasks(path: Path) -> tuple[EvaluationTask, ...]:
|
|
462
|
+
try:
|
|
463
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
464
|
+
except OSError as exc:
|
|
465
|
+
raise ConfigurationError(f"cannot read evaluation fixtures {path}: {exc}") from exc
|
|
466
|
+
except yaml.YAMLError as exc:
|
|
467
|
+
raise ConfigurationError(f"invalid evaluation fixture YAML: {exc}") from exc
|
|
468
|
+
root = _mapping(raw, "evaluation fixtures")
|
|
469
|
+
if set(root) != {"version", "tasks"} or root.get("version") != 1:
|
|
470
|
+
raise ConfigurationError("evaluation fixtures must contain version 1 and tasks")
|
|
471
|
+
raw_tasks = root.get("tasks")
|
|
472
|
+
if not isinstance(raw_tasks, list) or not raw_tasks:
|
|
473
|
+
raise ConfigurationError("evaluation fixtures.tasks must be a non-empty list")
|
|
474
|
+
tasks: list[EvaluationTask] = []
|
|
475
|
+
ids: set[str] = set()
|
|
476
|
+
for index, raw_task in enumerate(raw_tasks):
|
|
477
|
+
where = f"evaluation fixtures.tasks[{index}]"
|
|
478
|
+
data = _mapping(raw_task, where)
|
|
479
|
+
unknown = sorted(set(data) - TASK_KEYS)
|
|
480
|
+
if unknown:
|
|
481
|
+
raise ConfigurationError(f"{where} has unknown key(s): {', '.join(unknown)}")
|
|
482
|
+
identifier = data.get("id")
|
|
483
|
+
if not isinstance(identifier, str) or not ID.fullmatch(identifier):
|
|
484
|
+
raise ConfigurationError(f"{where}.id must be a stable kebab-case identifier")
|
|
485
|
+
if identifier in ids:
|
|
486
|
+
raise ConfigurationError(f"duplicate evaluation task id: {identifier}")
|
|
487
|
+
ids.add(identifier)
|
|
488
|
+
audience = data.get("audience")
|
|
489
|
+
if audience not in {"human", "agent"}:
|
|
490
|
+
raise ConfigurationError(f"{where}.audience must be human or agent")
|
|
491
|
+
prompt = data.get("prompt")
|
|
492
|
+
if not isinstance(prompt, str) or not prompt.strip():
|
|
493
|
+
raise ConfigurationError(f"{where}.prompt must be non-empty")
|
|
494
|
+
context = tuple(
|
|
495
|
+
_relative(value, f"{where}.context")
|
|
496
|
+
for value in _strings(data.get("context"), f"{where}.context", nonempty=True)
|
|
497
|
+
)
|
|
498
|
+
model = _model(data.get("model"), f"{where}.model") if audience == "agent" else None
|
|
499
|
+
if audience == "human" and data.get("model") is not None:
|
|
500
|
+
raise ConfigurationError(f"{where}.model is only valid for agent tasks")
|
|
501
|
+
scorer = _scorer(data.get("scorer"), f"{where}.scorer")
|
|
502
|
+
if audience == "human" and scorer["type"] != "command":
|
|
503
|
+
raise ConfigurationError(f"{where} human tasks must use the command scorer")
|
|
504
|
+
if audience == "agent" and scorer["type"] == "command":
|
|
505
|
+
raise ConfigurationError(f"{where} agent tasks cannot use the command scorer")
|
|
506
|
+
tasks.append(EvaluationTask(identifier, audience, prompt.strip(), context, model, scorer))
|
|
507
|
+
return tuple(tasks)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _context(root: Path, task: EvaluationTask) -> tuple[str, str]:
|
|
511
|
+
records = []
|
|
512
|
+
digest = hashlib.sha256()
|
|
513
|
+
for relative in task.context:
|
|
514
|
+
try:
|
|
515
|
+
content = (root / relative).read_text(encoding="utf-8")
|
|
516
|
+
except OSError as exc:
|
|
517
|
+
raise ConfigurationError(f"cannot read task context {relative}: {exc}") from exc
|
|
518
|
+
records.append({"path": relative.as_posix(), "content": content})
|
|
519
|
+
digest.update(relative.as_posix().encode())
|
|
520
|
+
digest.update(b"\0")
|
|
521
|
+
digest.update(content.encode())
|
|
522
|
+
prompt = json.dumps({
|
|
523
|
+
"schema": "sourcebound.round-trip.v1",
|
|
524
|
+
"task": task.prompt,
|
|
525
|
+
"context": records,
|
|
526
|
+
"response_type": task.scorer["type"],
|
|
527
|
+
}, sort_keys=True, separators=(",", ":"))
|
|
528
|
+
return prompt, digest.hexdigest()
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _command_score(
|
|
532
|
+
root: Path,
|
|
533
|
+
manifest: Manifest,
|
|
534
|
+
task: EvaluationTask,
|
|
535
|
+
) -> tuple[bool, str]:
|
|
536
|
+
scorer = task.scorer
|
|
537
|
+
commands = {command.id: command for command in manifest.commands}
|
|
538
|
+
context = "\n".join((root / path).read_text(encoding="utf-8") for path in task.context)
|
|
539
|
+
for index, expected in enumerate(scorer["commands"]):
|
|
540
|
+
if expected["documented_as"] not in context:
|
|
541
|
+
return False, (
|
|
542
|
+
f"command {index + 1} is absent from supplied docs as "
|
|
543
|
+
f"{expected['documented_as']!r}"
|
|
544
|
+
)
|
|
545
|
+
command: CommandSpec | None = commands.get(expected["ref"])
|
|
546
|
+
if command is None:
|
|
547
|
+
raise ConfigurationError(f"evaluation command is not allowlisted: {expected['ref']}")
|
|
548
|
+
try:
|
|
549
|
+
proc = subprocess.run(
|
|
550
|
+
resolve_argv(command.argv),
|
|
551
|
+
cwd=root,
|
|
552
|
+
text=True,
|
|
553
|
+
capture_output=True,
|
|
554
|
+
timeout=command.timeout_seconds,
|
|
555
|
+
check=False,
|
|
556
|
+
env=_command_environment(),
|
|
557
|
+
)
|
|
558
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
559
|
+
return False, f"command {index + 1} could not run: {exc}"
|
|
560
|
+
if proc.returncode != expected["exit_code"]:
|
|
561
|
+
return False, f"command {index + 1} exited {proc.returncode}"
|
|
562
|
+
for stream, content in (("stdout", proc.stdout), ("stderr", proc.stderr)):
|
|
563
|
+
for required in expected[f"{stream}_contains"]:
|
|
564
|
+
if required not in content:
|
|
565
|
+
return False, f"command {index + 1} {stream} omitted {required!r}"
|
|
566
|
+
return True, f"{len(scorer['commands'])} documented command(s) matched expected output"
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _structured_score(response: str, scorer: dict[str, Any]) -> tuple[bool, str]:
|
|
570
|
+
try:
|
|
571
|
+
observed = json.loads(response)
|
|
572
|
+
except json.JSONDecodeError:
|
|
573
|
+
return False, "response is not valid JSON"
|
|
574
|
+
if observed != scorer["expected"]:
|
|
575
|
+
return False, "structured output does not match the expected value"
|
|
576
|
+
return True, "structured output matches exactly"
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _configuration_score(
|
|
580
|
+
root: Path, response: str, scorer: dict[str, Any]
|
|
581
|
+
) -> tuple[bool, str]:
|
|
582
|
+
source = root / _relative(scorer["repository"], "configuration scorer repository")
|
|
583
|
+
if not source.is_dir():
|
|
584
|
+
raise ConfigurationError(f"configuration scorer repository does not exist: {source}")
|
|
585
|
+
with tempfile.TemporaryDirectory(prefix="sourcebound-eval-config-") as temporary:
|
|
586
|
+
destination = Path(temporary) / "repo"
|
|
587
|
+
shutil.copytree(source, destination, ignore=shutil.ignore_patterns(".git"))
|
|
588
|
+
manifest_path = destination / ".sourcebound.yml"
|
|
589
|
+
manifest_path.write_text(response, encoding="utf-8")
|
|
590
|
+
try:
|
|
591
|
+
load_manifest(manifest_path)
|
|
592
|
+
results = evaluate(destination, manifest_path)
|
|
593
|
+
except CleanDocsError as exc:
|
|
594
|
+
return False, str(exc)
|
|
595
|
+
if any(result.changed for result in results):
|
|
596
|
+
return False, "configuration is valid but check reports drift"
|
|
597
|
+
return True, "configuration passes schema validation and check"
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _cited_limit_score(
|
|
601
|
+
root: Path, task: EvaluationTask, response: str, scorer: dict[str, Any]
|
|
602
|
+
) -> tuple[bool, str]:
|
|
603
|
+
context = "\n".join((root / path).read_text(encoding="utf-8") for path in task.context)
|
|
604
|
+
answer = scorer["answer"]
|
|
605
|
+
citation = scorer["citation"]
|
|
606
|
+
if answer.lower() not in context.lower():
|
|
607
|
+
raise ConfigurationError(f"cited-limit answer is absent from task context: {answer}")
|
|
608
|
+
citation_path, separator, citation_anchor = citation.partition("#")
|
|
609
|
+
if not separator or not citation_path or not citation_anchor:
|
|
610
|
+
raise ConfigurationError("cited-limit citation must be path#anchor")
|
|
611
|
+
canonical = root / _relative(citation_path, "cited-limit citation path")
|
|
612
|
+
try:
|
|
613
|
+
canonical_text = canonical.read_text(encoding="utf-8")
|
|
614
|
+
except OSError as exc:
|
|
615
|
+
raise ConfigurationError(f"cannot read cited limitation {citation_path}: {exc}") from exc
|
|
616
|
+
anchors = {
|
|
617
|
+
re.sub(r"[ _]+", "-", re.sub(r"[^a-z0-9 _-]", "", match.group(1).lower()))
|
|
618
|
+
for line in canonical_text.splitlines()
|
|
619
|
+
if (match := re.match(r"^#{1,6}\s+(.+?)\s*$", line))
|
|
620
|
+
}
|
|
621
|
+
if citation_anchor not in anchors:
|
|
622
|
+
raise ConfigurationError(f"cited-limit anchor does not exist: {citation}")
|
|
623
|
+
if answer.lower() not in response.lower():
|
|
624
|
+
return False, "response omits the canonical limitation"
|
|
625
|
+
if citation not in response:
|
|
626
|
+
return False, "response omits the canonical citation"
|
|
627
|
+
for forbidden in scorer.get("forbidden", []):
|
|
628
|
+
if forbidden.lower() in response.lower():
|
|
629
|
+
return False, f"response infers unsupported behavior: {forbidden}"
|
|
630
|
+
return True, "response states and cites the canonical limitation"
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _mutation_red_score(
|
|
634
|
+
root: Path,
|
|
635
|
+
response: str,
|
|
636
|
+
scorer: dict[str, Any],
|
|
637
|
+
*,
|
|
638
|
+
excluded_worktree_path: Path | None,
|
|
639
|
+
) -> tuple[bool, str]:
|
|
640
|
+
try:
|
|
641
|
+
proposal = json.loads(response)
|
|
642
|
+
except json.JSONDecodeError:
|
|
643
|
+
return False, "response is not a valid binding proposal JSON object"
|
|
644
|
+
target = root / _relative(scorer["repository"], "mutation-red repository")
|
|
645
|
+
if not target.is_dir():
|
|
646
|
+
raise ConfigurationError(
|
|
647
|
+
f"mutation-red repository does not exist: {target}"
|
|
648
|
+
)
|
|
649
|
+
fact_path = root / _relative(scorer["fact"], "mutation-red fact")
|
|
650
|
+
fact, fact_bytes = load_json_object(fact_path, "mutation-red fact")
|
|
651
|
+
receipt = evaluate_binding_sensitivity(
|
|
652
|
+
target,
|
|
653
|
+
proposal,
|
|
654
|
+
fact,
|
|
655
|
+
proposal_bytes=response.encode(),
|
|
656
|
+
fact_bytes=fact_bytes,
|
|
657
|
+
expected_fact_file_sha256=scorer["fact_sha256"],
|
|
658
|
+
excluded_worktree_path=(
|
|
659
|
+
excluded_worktree_path if target.resolve() == root.resolve() else None
|
|
660
|
+
),
|
|
661
|
+
)
|
|
662
|
+
receipt_digest = hashlib.sha256(
|
|
663
|
+
json.dumps(receipt, sort_keys=True, separators=(",", ":")).encode()
|
|
664
|
+
).hexdigest()
|
|
665
|
+
expected = scorer["expected_state"]
|
|
666
|
+
if receipt["state"] != expected:
|
|
667
|
+
return (
|
|
668
|
+
False,
|
|
669
|
+
f"sensitivity state {receipt['state']} did not match {expected}; "
|
|
670
|
+
f"receipt sha256={receipt_digest}",
|
|
671
|
+
)
|
|
672
|
+
return (
|
|
673
|
+
True,
|
|
674
|
+
f"sensitivity state {expected} matched; semantic relationship remains "
|
|
675
|
+
f"unauthorized; receipt sha256={receipt_digest}",
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _provider(
|
|
680
|
+
root: Path, task: EvaluationTask, mode: str
|
|
681
|
+
) -> ResponseProvider:
|
|
682
|
+
assert task.model is not None
|
|
683
|
+
model = task.model
|
|
684
|
+
if mode == "replay":
|
|
685
|
+
if model.adapter != "recorded" or model.response is None:
|
|
686
|
+
raise ConfigurationError(
|
|
687
|
+
f"replay mode requires a recorded response for task {task.id}"
|
|
688
|
+
)
|
|
689
|
+
return RecordedResponseProvider(root / model.response, model.name)
|
|
690
|
+
if model.adapter != "command" or not model.argv:
|
|
691
|
+
raise ConfigurationError(f"live mode requires a command adapter for task {task.id}")
|
|
692
|
+
return CommandResponseProvider(
|
|
693
|
+
model.argv,
|
|
694
|
+
model.name,
|
|
695
|
+
root,
|
|
696
|
+
timeout_seconds=model.timeout_seconds,
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def run_evaluation(
|
|
701
|
+
root: Path,
|
|
702
|
+
manifest_path: Path,
|
|
703
|
+
fixture_path: Path,
|
|
704
|
+
*,
|
|
705
|
+
mode: str = "replay",
|
|
706
|
+
record_dir: Path | None = None,
|
|
707
|
+
) -> EvaluationReport:
|
|
708
|
+
if mode not in {"replay", "live"}:
|
|
709
|
+
raise ConfigurationError("evaluation mode must be replay or live")
|
|
710
|
+
if mode == "live" and record_dir is None:
|
|
711
|
+
raise ConfigurationError("live evaluation requires --record-dir")
|
|
712
|
+
root = root.resolve()
|
|
713
|
+
manifest = load_manifest(manifest_path)
|
|
714
|
+
tasks = load_evaluation_tasks(fixture_path)
|
|
715
|
+
results: list[TaskResult] = []
|
|
716
|
+
for task in tasks:
|
|
717
|
+
prompt, corpus_digest = _context(root, task)
|
|
718
|
+
prompt_digest = hashlib.sha256(prompt.encode()).hexdigest()
|
|
719
|
+
response = None
|
|
720
|
+
model_record = None
|
|
721
|
+
if task.audience == "human":
|
|
722
|
+
ok, detail = _command_score(root, manifest, task)
|
|
723
|
+
claim = "deterministic-command"
|
|
724
|
+
else:
|
|
725
|
+
provider = _provider(root, task, mode)
|
|
726
|
+
if mode == "live":
|
|
727
|
+
assert record_dir is not None
|
|
728
|
+
scorer_digest = hashlib.sha256(json.dumps(
|
|
729
|
+
task.scorer, sort_keys=True, separators=(",", ":")
|
|
730
|
+
).encode()).hexdigest()
|
|
731
|
+
commit, before_digest, worktree_files = _repository_state(
|
|
732
|
+
root, record_dir
|
|
733
|
+
)
|
|
734
|
+
run_path = record_dir / f"{task.id}.run.json"
|
|
735
|
+
run_record = _provider_run_record(
|
|
736
|
+
task=task,
|
|
737
|
+
provider=provider,
|
|
738
|
+
commit=commit,
|
|
739
|
+
worktree_digest=before_digest,
|
|
740
|
+
worktree_files=worktree_files,
|
|
741
|
+
corpus_digest=corpus_digest,
|
|
742
|
+
prompt_digest=prompt_digest,
|
|
743
|
+
prompt_bytes=len(prompt.encode()),
|
|
744
|
+
scorer_digest=scorer_digest,
|
|
745
|
+
)
|
|
746
|
+
atomic_write(
|
|
747
|
+
run_path,
|
|
748
|
+
json.dumps(run_record, indent=2, sort_keys=True) + "\n",
|
|
749
|
+
)
|
|
750
|
+
try:
|
|
751
|
+
response = provider.complete(prompt)
|
|
752
|
+
except CleanDocsError as exc:
|
|
753
|
+
_commit, after_digest, _files = _repository_state(
|
|
754
|
+
root, record_dir
|
|
755
|
+
)
|
|
756
|
+
state = (
|
|
757
|
+
"conflict"
|
|
758
|
+
if after_digest != before_digest
|
|
759
|
+
else "failed"
|
|
760
|
+
)
|
|
761
|
+
failed = _complete_provider_run(
|
|
762
|
+
run_record,
|
|
763
|
+
state=state,
|
|
764
|
+
after_digest=after_digest,
|
|
765
|
+
error=exc,
|
|
766
|
+
)
|
|
767
|
+
atomic_write(
|
|
768
|
+
run_path,
|
|
769
|
+
json.dumps(failed, indent=2, sort_keys=True) + "\n",
|
|
770
|
+
)
|
|
771
|
+
raise
|
|
772
|
+
_commit, after_digest, _files = _repository_state(root, record_dir)
|
|
773
|
+
if after_digest != before_digest:
|
|
774
|
+
conflict = _complete_provider_run(
|
|
775
|
+
run_record,
|
|
776
|
+
state="conflict",
|
|
777
|
+
after_digest=after_digest,
|
|
778
|
+
response=response,
|
|
779
|
+
)
|
|
780
|
+
atomic_write(
|
|
781
|
+
run_path,
|
|
782
|
+
json.dumps(conflict, indent=2, sort_keys=True) + "\n",
|
|
783
|
+
)
|
|
784
|
+
raise ConfigurationError(
|
|
785
|
+
f"provider changed repository bytes for task {task.id}"
|
|
786
|
+
)
|
|
787
|
+
atomic_write(record_dir / f"{task.id}.txt", response)
|
|
788
|
+
completed = _complete_provider_run(
|
|
789
|
+
run_record,
|
|
790
|
+
state="completed",
|
|
791
|
+
after_digest=after_digest,
|
|
792
|
+
response=response,
|
|
793
|
+
)
|
|
794
|
+
atomic_write(
|
|
795
|
+
run_path,
|
|
796
|
+
json.dumps(completed, indent=2, sort_keys=True) + "\n",
|
|
797
|
+
)
|
|
798
|
+
else:
|
|
799
|
+
response = provider.complete(prompt)
|
|
800
|
+
scorer_type = task.scorer["type"]
|
|
801
|
+
if scorer_type == "structured-output":
|
|
802
|
+
ok, detail = _structured_score(response, task.scorer)
|
|
803
|
+
elif scorer_type == "configuration":
|
|
804
|
+
ok, detail = _configuration_score(root, response, task.scorer)
|
|
805
|
+
elif scorer_type == "mutation-red":
|
|
806
|
+
ok, detail = _mutation_red_score(
|
|
807
|
+
root,
|
|
808
|
+
response,
|
|
809
|
+
task.scorer,
|
|
810
|
+
excluded_worktree_path=record_dir if mode == "live" else None,
|
|
811
|
+
)
|
|
812
|
+
else:
|
|
813
|
+
assert scorer_type == "cited-limit"
|
|
814
|
+
ok, detail = _cited_limit_score(root, task, response, task.scorer)
|
|
815
|
+
claim = "deterministic-replay" if mode == "replay" else "model-specific-live"
|
|
816
|
+
model_record = {"adapter": provider.adapter, "name": provider.name}
|
|
817
|
+
results.append(TaskResult(
|
|
818
|
+
id=task.id,
|
|
819
|
+
audience=task.audience,
|
|
820
|
+
ok=ok,
|
|
821
|
+
scorer=task.scorer["type"],
|
|
822
|
+
scorer_sha256=hashlib.sha256(json.dumps(
|
|
823
|
+
task.scorer, sort_keys=True, separators=(",", ":")
|
|
824
|
+
).encode()).hexdigest(),
|
|
825
|
+
claim=claim,
|
|
826
|
+
corpus_sha256=corpus_digest,
|
|
827
|
+
prompt_sha256=prompt_digest,
|
|
828
|
+
response_sha256=hashlib.sha256(response.encode()).hexdigest() if response is not None else None,
|
|
829
|
+
model=model_record,
|
|
830
|
+
detail=detail,
|
|
831
|
+
))
|
|
832
|
+
audit_report = audit(root)
|
|
833
|
+
hygiene = tuple(asdict(finding) for finding in audit_report.findings) + tuple(
|
|
834
|
+
{**asdict(finding), "rule": "stale-baseline", "recorded_rule": finding.rule}
|
|
835
|
+
for finding in audit_report.stale_baseline
|
|
836
|
+
)
|
|
837
|
+
return EvaluationReport(
|
|
838
|
+
mode=mode,
|
|
839
|
+
human_tasks=tuple(result for result in results if result.audience == "human"),
|
|
840
|
+
agent_tasks=tuple(result for result in results if result.audience == "agent"),
|
|
841
|
+
hygiene_findings=hygiene,
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def write_evaluation_history(path: Path, report: EvaluationReport) -> None:
|
|
846
|
+
records = []
|
|
847
|
+
if path.exists():
|
|
848
|
+
try:
|
|
849
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
850
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
851
|
+
raise ConfigurationError(f"cannot read evaluation history {path}: {exc}") from exc
|
|
852
|
+
if not isinstance(raw, dict) or raw.get("schema") != "sourcebound.evaluation-history.v1":
|
|
853
|
+
raise ConfigurationError("evaluation history has an unsupported schema")
|
|
854
|
+
if not isinstance(raw.get("records"), list):
|
|
855
|
+
raise ConfigurationError("evaluation history records must be a list")
|
|
856
|
+
records.extend(raw["records"])
|
|
857
|
+
for result in report.human_tasks + report.agent_tasks:
|
|
858
|
+
record = asdict(result)
|
|
859
|
+
payload = json.dumps(record, sort_keys=True, separators=(",", ":"))
|
|
860
|
+
record["record_id"] = hashlib.sha256(payload.encode()).hexdigest()
|
|
861
|
+
records.append(record)
|
|
862
|
+
unique = {record["record_id"]: record for record in records}
|
|
863
|
+
rendered = json.dumps({
|
|
864
|
+
"schema": "sourcebound.evaluation-history.v1",
|
|
865
|
+
"records": [unique[key] for key in sorted(unique)],
|
|
866
|
+
}, indent=2, sort_keys=True) + "\n"
|
|
867
|
+
atomic_write(path, rendered)
|