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.
Files changed (71) hide show
  1. clean_docs/__init__.py +26 -0
  2. clean_docs/__main__.py +3 -0
  3. clean_docs/accessibility.py +182 -0
  4. clean_docs/adapters/__init__.py +1 -0
  5. clean_docs/adapters/event_capture.py +56 -0
  6. clean_docs/adapters/mdx_dependencies.json +714 -0
  7. clean_docs/adapters/mdx_parser.mjs +29992 -0
  8. clean_docs/applicability.py +330 -0
  9. clean_docs/audit.py +1120 -0
  10. clean_docs/bootstrap.py +507 -0
  11. clean_docs/capabilities.py +200 -0
  12. clean_docs/changed.py +452 -0
  13. clean_docs/claims.py +840 -0
  14. clean_docs/cli.py +1612 -0
  15. clean_docs/context.py +307 -0
  16. clean_docs/corpus.py +377 -0
  17. clean_docs/demo.py +369 -0
  18. clean_docs/doctor.py +184 -0
  19. clean_docs/emit/__init__.py +4 -0
  20. clean_docs/emit/llms_txt.py +102 -0
  21. clean_docs/emit/stepwise.py +168 -0
  22. clean_docs/engine.py +324 -0
  23. clean_docs/errors.py +20 -0
  24. clean_docs/evaluation.py +867 -0
  25. clean_docs/execution.py +138 -0
  26. clean_docs/explain.py +123 -0
  27. clean_docs/extractors/__init__.py +19 -0
  28. clean_docs/extractors/command.py +51 -0
  29. clean_docs/extractors/inventory.py +176 -0
  30. clean_docs/extractors/json_pointer.py +84 -0
  31. clean_docs/extractors/python_literal.py +104 -0
  32. clean_docs/extractors/static.py +111 -0
  33. clean_docs/feedback.py +1390 -0
  34. clean_docs/impact.py +1624 -0
  35. clean_docs/improvements.py +1178 -0
  36. clean_docs/inventory.py +474 -0
  37. clean_docs/isolation.py +157 -0
  38. clean_docs/manifest.py +898 -0
  39. clean_docs/mdx.py +272 -0
  40. clean_docs/migration.py +121 -0
  41. clean_docs/models.py +194 -0
  42. clean_docs/outcomes.py +296 -0
  43. clean_docs/performance.py +123 -0
  44. clean_docs/phrasing.py +448 -0
  45. clean_docs/plugins.py +249 -0
  46. clean_docs/policy.py +536 -0
  47. clean_docs/projections.py +255 -0
  48. clean_docs/regions.py +75 -0
  49. clean_docs/release.py +232 -0
  50. clean_docs/renderers.py +57 -0
  51. clean_docs/residue.py +311 -0
  52. clean_docs/review_contracts.py +862 -0
  53. clean_docs/review_ledger.py +297 -0
  54. clean_docs/review_limits.py +9 -0
  55. clean_docs/sensitivity.py +602 -0
  56. clean_docs/snapshot.py +212 -0
  57. clean_docs/standard.py +281 -0
  58. clean_docs/standards/default.json +309 -0
  59. clean_docs/standards/exemplars.md +87 -0
  60. clean_docs/standards/v0-migrations.json +26 -0
  61. clean_docs/symbols.py +47 -0
  62. clean_docs/templates.py +96 -0
  63. clean_docs/verdict.py +1397 -0
  64. clean_docs/visuals.py +346 -0
  65. clean_docs/write_gate.py +170 -0
  66. sourcebound-1.2.1.dist-info/LICENSE +21 -0
  67. sourcebound-1.2.1.dist-info/METADATA +109 -0
  68. sourcebound-1.2.1.dist-info/RECORD +71 -0
  69. sourcebound-1.2.1.dist-info/WHEEL +5 -0
  70. sourcebound-1.2.1.dist-info/entry_points.txt +2 -0
  71. sourcebound-1.2.1.dist-info/top_level.txt +1 -0
@@ -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)