agentflow-proof 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agentflow/coverage.py ADDED
@@ -0,0 +1,438 @@
1
+ """Cross-artifact coverage and referential-integrity checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Optional, Set
6
+
7
+ from .contracts import recorded_review_depth, review_depth_satisfies
8
+
9
+
10
+ _CRITERION_STATUSES = ("satisfied", "failed", "missing", "unmapped")
11
+
12
+
13
+ def _step_ids(plan: Dict[str, Any]) -> Set[str]:
14
+ return {
15
+ step["id"]
16
+ for step in plan.get("steps", [])
17
+ if isinstance(step, dict) and isinstance(step.get("id"), str)
18
+ }
19
+
20
+
21
+ def build_coverage(
22
+ plan: Dict[str, Any],
23
+ evidence: List[Dict[str, Any]],
24
+ context_receipts: List[Dict[str, Any]],
25
+ runtime_config: Optional[Dict[str, Any]] = None,
26
+ ) -> Dict[str, Any]:
27
+ steps = _step_ids(plan)
28
+ step_evidence_ids: Set[str] = set()
29
+ step_evidence_by_step: Dict[str, Set[str]] = {}
30
+ for step in plan.get("steps", []):
31
+ if not isinstance(step, dict):
32
+ continue
33
+ step_id = step.get("id")
34
+ ids = step.get("evidence_ids", [])
35
+ if isinstance(step_id, str):
36
+ step_evidence_by_step[step_id] = set()
37
+ for evidence_id in ids if isinstance(ids, list) else []:
38
+ if isinstance(evidence_id, str):
39
+ step_evidence_ids.add(evidence_id)
40
+ if isinstance(step_id, str):
41
+ step_evidence_by_step[step_id].add(evidence_id)
42
+
43
+ evidence_by_id = {
44
+ item["id"]: item
45
+ for item in evidence
46
+ if isinstance(item, dict) and isinstance(item.get("id"), str)
47
+ }
48
+ plan_evidence_ids = {
49
+ evidence_id
50
+ for evidence_id in plan.get("evidence_ids", [])
51
+ if isinstance(evidence_id, str)
52
+ }
53
+
54
+ receipt_steps: Set[str] = set()
55
+ dangling_used_for: List[Dict[str, str]] = []
56
+ for receipt in context_receipts:
57
+ receipt_id = str(receipt.get("id", ""))
58
+ used_for = receipt.get("used_for", [])
59
+ if not isinstance(used_for, list):
60
+ continue
61
+ for step_id in used_for:
62
+ if not isinstance(step_id, str):
63
+ continue
64
+ if step_id in steps:
65
+ receipt_steps.add(step_id)
66
+ else:
67
+ dangling_used_for.append({"receipt_id": receipt_id, "step_id": step_id})
68
+
69
+ dangling_supports: List[Dict[str, str]] = []
70
+ supported_by_evidence: Set[str] = set()
71
+ evidence_supporting_steps: Set[str] = set()
72
+ for item in evidence:
73
+ evidence_id = str(item.get("id", ""))
74
+ supports = item.get("supports", [])
75
+ if not isinstance(supports, list):
76
+ continue
77
+ for step_id in supports:
78
+ if not isinstance(step_id, str):
79
+ continue
80
+ if step_id in steps:
81
+ supported_by_evidence.add(step_id)
82
+ evidence_supporting_steps.add(evidence_id)
83
+ else:
84
+ dangling_supports.append({"evidence_id": evidence_id, "step_id": step_id})
85
+
86
+ referenced_evidence_ids = plan_evidence_ids | step_evidence_ids
87
+ missing_plan_evidence_ids = sorted(
88
+ evidence_id for evidence_id in referenced_evidence_ids if evidence_id not in evidence_by_id
89
+ )
90
+ used_evidence_ids = step_evidence_ids | plan_evidence_ids | evidence_supporting_steps
91
+ unused_evidence_ids = sorted(
92
+ evidence_id
93
+ for evidence_id, item in evidence_by_id.items()
94
+ if evidence_id not in used_evidence_ids and item.get("kind") != "review"
95
+ )
96
+ steps_with_step_evidence = {
97
+ step_id
98
+ for step_id, evidence_ids in step_evidence_by_step.items()
99
+ if any(evidence_id in evidence_by_id for evidence_id in evidence_ids)
100
+ }
101
+ steps_without_support = sorted(
102
+ step_id
103
+ for step_id in steps
104
+ if step_id not in steps_with_step_evidence
105
+ and step_id not in supported_by_evidence
106
+ and step_id not in receipt_steps
107
+ )
108
+
109
+ dangling_route_runtimes: List[Dict[str, str]] = []
110
+ if runtime_config:
111
+ runtimes = runtime_config.get("runtimes", {})
112
+ runtime_ids = set(runtimes.keys()) if isinstance(runtimes, dict) else set()
113
+ routes = runtime_config.get("routes", {})
114
+ default_runtime = runtime_config.get("default_runtime")
115
+ if isinstance(routes, dict):
116
+ for route_name, route in routes.items():
117
+ if not isinstance(route, dict):
118
+ continue
119
+ primary = route.get("primary")
120
+ primary_field = "primary"
121
+ if primary is None and isinstance(default_runtime, str):
122
+ primary = default_runtime
123
+ primary_field = "default_runtime"
124
+ if isinstance(primary, str) and primary not in runtime_ids:
125
+ dangling_route_runtimes.append(
126
+ {"route": route_name, "field": primary_field, "runtime": primary}
127
+ )
128
+ fallbacks = route.get("fallbacks", [])
129
+ if isinstance(fallbacks, list):
130
+ for fallback in fallbacks:
131
+ if isinstance(fallback, str) and fallback not in runtime_ids:
132
+ dangling_route_runtimes.append(
133
+ {
134
+ "route": route_name,
135
+ "field": "fallbacks",
136
+ "runtime": fallback,
137
+ }
138
+ )
139
+
140
+ return {
141
+ "steps_without_support": steps_without_support,
142
+ "missing_plan_evidence_ids": missing_plan_evidence_ids,
143
+ "unused_evidence_ids": unused_evidence_ids,
144
+ "dangling_supports": dangling_supports,
145
+ "dangling_used_for": dangling_used_for,
146
+ "dangling_route_runtimes": dangling_route_runtimes,
147
+ }
148
+
149
+
150
+ def _wraps_run(command: Any, run: List[str]) -> bool:
151
+ # env-style wrappers prepend argv (["env", "K=V", *run]); anything that
152
+ # rewrites or extends the gate command is not evidence for that gate.
153
+ return (
154
+ isinstance(command, list)
155
+ and len(command) > len(run)
156
+ and command[-len(run):] == run
157
+ )
158
+
159
+
160
+ def _latest_command_receipt(
161
+ step_id: str,
162
+ gate: Dict[str, Any],
163
+ validation_alias: Optional[str],
164
+ receipts: List[Dict[str, Any]],
165
+ ) -> Optional[Dict[str, Any]]:
166
+ run = gate.get("run")
167
+ if not isinstance(run, list):
168
+ return None
169
+ aliases = {item for item in (" ".join(run), validation_alias) if item}
170
+ for receipt in reversed(receipts):
171
+ if receipt.get("step_id") != step_id:
172
+ continue
173
+ command = receipt.get("command")
174
+ if command == run:
175
+ return receipt
176
+ # A gate label alone is not criterion proof: the labeled receipt must
177
+ # still have run the gate command (allowing env-prefix wrapping).
178
+ if receipt.get("gate") in aliases and _wraps_run(command, run):
179
+ return receipt
180
+ return None
181
+
182
+
183
+ def _combined_status(statuses: List[str]) -> str:
184
+ for status in ("failed", "missing", "unmapped"):
185
+ if status in statuses:
186
+ return status
187
+ return "satisfied"
188
+
189
+
190
+ def build_requirement_coverage(
191
+ plan: Dict[str, Any],
192
+ evidence: List[Dict[str, Any]],
193
+ receipts: List[Dict[str, Any]],
194
+ review_runs: List[Dict[str, Any]],
195
+ plan_sha256: str,
196
+ ) -> Dict[str, Any]:
197
+ """Project optional plan criteria onto explicitly mapped deterministic evidence."""
198
+ requirements = plan.get("requirements")
199
+ if not isinstance(requirements, list) or not requirements:
200
+ return {}
201
+
202
+ steps_by_criterion: Dict[str, List[str]] = {}
203
+ evidence_by_criterion: Dict[str, List[Dict[str, Any]]] = {}
204
+ recorded_evidence_ids = {
205
+ item["id"]
206
+ for item in evidence
207
+ if isinstance(item, dict) and isinstance(item.get("id"), str)
208
+ }
209
+ for step in plan.get("steps", []):
210
+ if not isinstance(step, dict) or not isinstance(step.get("id"), str):
211
+ continue
212
+ step_id = step["id"]
213
+ for criterion_id in step.get("criterion_ids", []):
214
+ if isinstance(criterion_id, str):
215
+ steps_by_criterion.setdefault(criterion_id, []).append(step_id)
216
+ validation = step.get("validation", [])
217
+ for gate_index, gate in enumerate(step.get("gates", [])):
218
+ if not isinstance(gate, dict):
219
+ continue
220
+ if gate.get("kind") == "inspection":
221
+ evidence_id = gate.get("evidence_id")
222
+ status = (
223
+ "satisfied" if evidence_id in recorded_evidence_ids else "missing"
224
+ )
225
+ for criterion_id in gate.get("criterion_ids", []):
226
+ if isinstance(criterion_id, str):
227
+ evidence_by_criterion.setdefault(criterion_id, []).append(
228
+ {
229
+ "kind": "inspection",
230
+ "step_id": step_id,
231
+ "evidence_id": evidence_id,
232
+ "status": status,
233
+ }
234
+ )
235
+ continue
236
+ if gate.get("kind") != "command":
237
+ continue
238
+ run = gate.get("run")
239
+ if not isinstance(run, list):
240
+ continue
241
+ validation_alias = (
242
+ validation[gate_index]
243
+ if isinstance(validation, list)
244
+ and gate_index < len(validation)
245
+ and isinstance(validation[gate_index], str)
246
+ else None
247
+ )
248
+ receipt = _latest_command_receipt(
249
+ step_id, gate, validation_alias, receipts
250
+ )
251
+ status = "missing"
252
+ if receipt is not None:
253
+ failed = (
254
+ receipt.get("exit_code") != 0
255
+ or receipt.get("decision") in ("blocked", "timeout")
256
+ or receipt.get("timed_out") is True
257
+ )
258
+ status = "failed" if failed else "satisfied"
259
+ for criterion_id in gate.get("criterion_ids", []):
260
+ if not isinstance(criterion_id, str):
261
+ continue
262
+ row = {
263
+ "kind": "command",
264
+ "step_id": step_id,
265
+ "gate": " ".join(run),
266
+ "status": status,
267
+ }
268
+ if receipt is not None and isinstance(receipt.get("id"), str):
269
+ row["receipt_id"] = receipt["id"]
270
+ evidence_by_criterion.setdefault(criterion_id, []).append(row)
271
+
272
+ projected_requirements: List[Dict[str, Any]] = []
273
+ counts = {status: 0 for status in _CRITERION_STATUSES}
274
+ for requirement in requirements:
275
+ projected_criteria: List[Dict[str, Any]] = []
276
+ for criterion in requirement.get("acceptance_criteria", []):
277
+ criterion_id = criterion["id"]
278
+ criterion_evidence = list(evidence_by_criterion.get(criterion_id, []))
279
+ review = criterion.get("review")
280
+ if isinstance(review, dict):
281
+ minimum_depth = review["minimum_depth"]
282
+ qualifying = [
283
+ run
284
+ for run in review_runs
285
+ if run.get("plan_sha256") == plan_sha256
286
+ and review_depth_satisfies(
287
+ recorded_review_depth(run.get("depth_profile")), minimum_depth
288
+ )
289
+ ]
290
+ review_row: Dict[str, Any] = {
291
+ "kind": "review",
292
+ "minimum_depth": minimum_depth,
293
+ "status": "missing",
294
+ }
295
+ if qualifying:
296
+ run = qualifying[-1]
297
+ review_row.update(
298
+ {
299
+ "status": (
300
+ "satisfied"
301
+ if run.get("gate_status") == "pass"
302
+ and not run.get("active_blocking")
303
+ else "failed"
304
+ ),
305
+ "review_run_id": run.get("review_run_id"),
306
+ "plan_sha256": plan_sha256,
307
+ "depth_profile": recorded_review_depth(
308
+ run.get("depth_profile")
309
+ ),
310
+ }
311
+ )
312
+ criterion_evidence.append(review_row)
313
+ status = (
314
+ _combined_status([row["status"] for row in criterion_evidence])
315
+ if criterion_evidence
316
+ else "unmapped"
317
+ )
318
+ counts[status] += 1
319
+ projected_criteria.append(
320
+ {
321
+ "id": criterion_id,
322
+ "text": criterion["text"],
323
+ "status": status,
324
+ "steps": steps_by_criterion.get(criterion_id, []),
325
+ "evidence": criterion_evidence,
326
+ }
327
+ )
328
+ projected_requirements.append(
329
+ {
330
+ "id": requirement["id"],
331
+ "text": requirement["text"],
332
+ "status": _combined_status(
333
+ [criterion["status"] for criterion in projected_criteria]
334
+ ),
335
+ "acceptance_criteria": projected_criteria,
336
+ }
337
+ )
338
+ return {
339
+ "requirements": projected_requirements,
340
+ "criterion_status_counts": counts,
341
+ }
342
+
343
+
344
+ def build_design_decision_coverage(plan: Dict[str, Any]) -> Dict[str, Any]:
345
+ """Project optional design decisions in canonical plan order."""
346
+ decisions = plan.get("design_decisions")
347
+ if not isinstance(decisions, list) or not decisions:
348
+ return {}
349
+
350
+ steps_by_decision: Dict[str, List[str]] = {}
351
+ for step in plan.get("steps", []):
352
+ if not isinstance(step, dict) or not isinstance(step.get("id"), str):
353
+ continue
354
+ for decision_id in step.get("design_decision_ids", []):
355
+ if isinstance(decision_id, str):
356
+ steps_by_decision.setdefault(decision_id, []).append(step["id"])
357
+
358
+ return {
359
+ "design_decisions": [
360
+ {
361
+ "id": decision["id"],
362
+ "text": decision["text"],
363
+ "references": list(decision.get("references", [])),
364
+ "steps": list(steps_by_decision.get(decision["id"], [])),
365
+ }
366
+ for decision in decisions
367
+ ]
368
+ }
369
+
370
+
371
+ def evaluate_context_budget(
372
+ plan: Dict[str, Any],
373
+ context_receipts: List[Dict[str, Any]],
374
+ failures: List[Dict[str, Any]],
375
+ ) -> List[Dict[str, Any]]:
376
+ budget = plan.get("context_budget", {})
377
+ if not isinstance(budget, dict):
378
+ return []
379
+
380
+ findings: List[Dict[str, Any]] = []
381
+ total_bytes = sum(
382
+ item.get("bytes", 0)
383
+ for item in context_receipts
384
+ if isinstance(item.get("bytes", 0), int)
385
+ )
386
+ distinct_sources = {
387
+ item.get("source")
388
+ for item in context_receipts
389
+ if isinstance(item.get("source"), str)
390
+ }
391
+
392
+ max_total_bytes = budget.get("max_total_bytes")
393
+ if isinstance(max_total_bytes, int) and total_bytes > max_total_bytes:
394
+ findings.append(
395
+ {
396
+ "id": "context_max_total_bytes_exceeded",
397
+ "status": "warning",
398
+ "severity": "warning",
399
+ "message": (
400
+ f"context receipts total {total_bytes} bytes exceeds "
401
+ f"max_total_bytes {max_total_bytes}"
402
+ ),
403
+ }
404
+ )
405
+
406
+ max_files = budget.get("max_files")
407
+ if isinstance(max_files, int) and len(distinct_sources) > max_files:
408
+ findings.append(
409
+ {
410
+ "id": "context_max_files_exceeded",
411
+ "status": "warning",
412
+ "severity": "warning",
413
+ "message": (
414
+ f"context receipts use {len(distinct_sources)} sources "
415
+ f"exceeding max_files {max_files}"
416
+ ),
417
+ }
418
+ )
419
+
420
+ max_log_lines = budget.get("max_log_lines_per_failure")
421
+ if isinstance(max_log_lines, int):
422
+ for failure in failures:
423
+ relevant_lines = failure.get("relevant_lines", [])
424
+ if isinstance(relevant_lines, list) and len(relevant_lines) > max_log_lines:
425
+ findings.append(
426
+ {
427
+ "id": "failure_log_lines_exceeded",
428
+ "status": "warning",
429
+ "severity": "info",
430
+ "message": (
431
+ f"failure for {failure.get('command', 'unknown')} records "
432
+ f"{len(relevant_lines)} relevant lines exceeding "
433
+ f"max_log_lines_per_failure {max_log_lines}"
434
+ ),
435
+ }
436
+ )
437
+
438
+ return findings