agentloop-profiler 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. agentloop/__init__.py +136 -0
  2. agentloop/ablation_protocol.py +229 -0
  3. agentloop/ablations.py +564 -0
  4. agentloop/aggregate_cli.py +88 -0
  5. agentloop/aggregate_stats.py +175 -0
  6. agentloop/aggregates.py +532 -0
  7. agentloop/audit.py +39 -0
  8. agentloop/autoinstrument.py +145 -0
  9. agentloop/batch_experiments.py +295 -0
  10. agentloop/batching.py +95 -0
  11. agentloop/budget_types.py +335 -0
  12. agentloop/budgets.py +539 -0
  13. agentloop/calibration.py +872 -0
  14. agentloop/checkpoint_state.py +306 -0
  15. agentloop/checkpoint_store.py +86 -0
  16. agentloop/ci.py +202 -0
  17. agentloop/cli.py +1314 -0
  18. agentloop/client.py +228 -0
  19. agentloop/completion.py +345 -0
  20. agentloop/completion_types.py +199 -0
  21. agentloop/config.py +50 -0
  22. agentloop/context_controls.py +443 -0
  23. agentloop/context_experiments.py +337 -0
  24. agentloop/context_types.py +216 -0
  25. agentloop/costs.py +667 -0
  26. agentloop/cursor_validation.py +48 -0
  27. agentloop/decorators.py +445 -0
  28. agentloop/demo.py +300 -0
  29. agentloop/doctor.py +341 -0
  30. agentloop/drift.py +361 -0
  31. agentloop/drift_cli.py +147 -0
  32. agentloop/drift_types.py +110 -0
  33. agentloop/drift_windows.py +340 -0
  34. agentloop/entrypoint.py +201 -0
  35. agentloop/estimates.py +133 -0
  36. agentloop/events.py +75 -0
  37. agentloop/experiment_artifacts.py +80 -0
  38. agentloop/experiment_observations.py +169 -0
  39. agentloop/experiment_reports.py +545 -0
  40. agentloop/experiment_types.py +355 -0
  41. agentloop/experiments.py +392 -0
  42. agentloop/exporters.py +117 -0
  43. agentloop/finding_benchmark_types.py +131 -0
  44. agentloop/finding_benchmarks.py +424 -0
  45. agentloop/findings.py +419 -0
  46. agentloop/graph.py +464 -0
  47. agentloop/harness.py +825 -0
  48. agentloop/harness_bench/__init__.py +1 -0
  49. agentloop/harness_bench/cli.py +77 -0
  50. agentloop/harness_bench/drivers.py +96 -0
  51. agentloop/harness_bench/offline.py +507 -0
  52. agentloop/harness_evidence.py +585 -0
  53. agentloop/harness_usage.py +91 -0
  54. agentloop/html_report.py +860 -0
  55. agentloop/integrations/__init__.py +1 -0
  56. agentloop/integrations/crewai.py +144 -0
  57. agentloop/integrations/harbor/__init__.py +1 -0
  58. agentloop/integrations/harbor/atif.py +982 -0
  59. agentloop/integrations/harbor/atif_validation.py +329 -0
  60. agentloop/integrations/harbor/cli.py +207 -0
  61. agentloop/integrations/harbor/manifest.py +86 -0
  62. agentloop/integrations/harbor/trial_evidence.py +196 -0
  63. agentloop/integrations/harbor/trials.py +1109 -0
  64. agentloop/integrations/harbor/verifier.py +118 -0
  65. agentloop/integrations/langgraph.py +150 -0
  66. agentloop/integrations/langgraph_harness.py +466 -0
  67. agentloop/integrations/langgraph_recovery.py +366 -0
  68. agentloop/integrations/omnigent/__init__.py +1 -0
  69. agentloop/integrations/omnigent/cli.py +78 -0
  70. agentloop/integrations/omnigent/evidence.py +93 -0
  71. agentloop/integrations/omnigent/telemetry.py +422 -0
  72. agentloop/integrations/openai.py +415 -0
  73. agentloop/integrations/openai_agents.py +291 -0
  74. agentloop/integrations/sdk.py +82 -0
  75. agentloop/integrations/vercel_ai.py +83 -0
  76. agentloop/interoperability/__init__.py +5 -0
  77. agentloop/interoperability/artifacts.py +112 -0
  78. agentloop/interoperability/cli.py +59 -0
  79. agentloop/interoperability/cohort_evidence.py +175 -0
  80. agentloop/interoperability/cohorts.py +681 -0
  81. agentloop/interoperability/contracts.py +427 -0
  82. agentloop/interoperability/coordination.py +554 -0
  83. agentloop/interoperability/evidence.py +215 -0
  84. agentloop/interoperability/otlp_jsonl.py +775 -0
  85. agentloop/interoperability/otlp_validation.py +496 -0
  86. agentloop/interoperability/registry.py +561 -0
  87. agentloop/interoperability/validation.py +333 -0
  88. agentloop/intervention_service.py +110 -0
  89. agentloop/intervention_store.py +118 -0
  90. agentloop/interventions.py +190 -0
  91. agentloop/issues.py +90 -0
  92. agentloop/judgment_adapters.py +125 -0
  93. agentloop/judgment_benchmark_types.py +98 -0
  94. agentloop/judgment_benchmarks.py +606 -0
  95. agentloop/judgment_types.py +241 -0
  96. agentloop/judgment_views.py +38 -0
  97. agentloop/judgments.py +342 -0
  98. agentloop/loop_guards.py +274 -0
  99. agentloop/loop_types.py +66 -0
  100. agentloop/markdown.py +102 -0
  101. agentloop/metrics.py +342 -0
  102. agentloop/migrations.py +303 -0
  103. agentloop/model_routing.py +470 -0
  104. agentloop/onboarding.py +315 -0
  105. agentloop/operations.py +44 -0
  106. agentloop/optimizer.py +199 -0
  107. agentloop/otel.py +715 -0
  108. agentloop/otel_ids.py +81 -0
  109. agentloop/otel_semantics.py +134 -0
  110. agentloop/parallelism.py +141 -0
  111. agentloop/patches.py +603 -0
  112. agentloop/plan_export.py +126 -0
  113. agentloop/policy_promotion.py +325 -0
  114. agentloop/promotion_evidence.py +304 -0
  115. agentloop/promotion_types.py +209 -0
  116. agentloop/quality.py +446 -0
  117. agentloop/ranking.py +529 -0
  118. agentloop/recovery_evidence.py +96 -0
  119. agentloop/replay.py +753 -0
  120. agentloop/retention.py +375 -0
  121. agentloop/retention_types.py +113 -0
  122. agentloop/reuse_experiments.py +247 -0
  123. agentloop/routing_checks.py +116 -0
  124. agentloop/routing_types.py +241 -0
  125. agentloop/rules.py +396 -0
  126. agentloop/runtime.py +286 -0
  127. agentloop/savings.py +158 -0
  128. agentloop/scheduling_plan.py +99 -0
  129. agentloop/scheduling_types.py +169 -0
  130. agentloop/schema.py +257 -0
  131. agentloop/semantic_rules.py +200 -0
  132. agentloop/semantic_waste.py +345 -0
  133. agentloop/semantic_waste_types.py +88 -0
  134. agentloop/server.py +494 -0
  135. agentloop/store.py +1429 -0
  136. agentloop/structural_experiments.py +431 -0
  137. agentloop/structural_types.py +113 -0
  138. agentloop/structured_quality.py +1020 -0
  139. agentloop/studies.py +629 -0
  140. agentloop/study_statistics.py +58 -0
  141. agentloop/substitution_evidence.py +199 -0
  142. agentloop/substitution_export.py +190 -0
  143. agentloop/substitution_reports.py +407 -0
  144. agentloop/substitution_types.py +284 -0
  145. agentloop/substitutions.py +267 -0
  146. agentloop/timing.py +142 -0
  147. agentloop/tokens.py +188 -0
  148. agentloop/tool_scheduling.py +410 -0
  149. agentloop/trace_sources.py +31 -0
  150. agentloop/tracer.py +494 -0
  151. agentloop/value.py +306 -0
  152. agentloop/version.py +3 -0
  153. agentloop/workflow_types.py +264 -0
  154. agentloop/workflows.py +284 -0
  155. agentloop_profiler-0.1.0.dist-info/METADATA +513 -0
  156. agentloop_profiler-0.1.0.dist-info/RECORD +166 -0
  157. agentloop_profiler-0.1.0.dist-info/WHEEL +5 -0
  158. agentloop_profiler-0.1.0.dist-info/entry_points.txt +2 -0
  159. agentloop_profiler-0.1.0.dist-info/licenses/LICENSE +201 -0
  160. agentloop_profiler-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +101 -0
  161. agentloop_profiler-0.1.0.dist-info/top_level.txt +2 -0
  162. dashboard/__init__.py +5 -0
  163. dashboard/app.py +922 -0
  164. dashboard/streamlit_compat.py +63 -0
  165. dashboard/trace_upload.py +57 -0
  166. dashboard/value_view.py +105 -0
@@ -0,0 +1,424 @@
1
+ """Evaluate canonical findings against a frozen corpus without executing judges."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import platform
7
+ from collections import Counter, defaultdict
8
+ from time import perf_counter_ns
9
+
10
+ from agentloop import rules
11
+ from agentloop.finding_benchmark_types import FINDING_BENCHMARK_VERSION, FindingBenchmark
12
+ from agentloop.judgment_types import canonical, fingerprint, finite, text
13
+ from agentloop.markdown import markdown_table_cell
14
+ from agentloop.semantic_waste import read_semantic_waste
15
+ from agentloop.timing import event_interval_ms
16
+ from agentloop.tracer import AgentTrace
17
+
18
+
19
+ def _copy(value):
20
+ return json.loads(canonical(value))
21
+
22
+
23
+ def _ratio(numerator, denominator):
24
+ return numerator / denominator if denominator else None
25
+
26
+
27
+ def _evidence(case, trace):
28
+ semantic = read_semantic_waste(trace)
29
+ selected = [
30
+ row
31
+ for row in (semantic or {}).get("cases", [])
32
+ if row["definition"]["family"] == case["rule_id"]
33
+ ]
34
+ checks = {
35
+ "timing": bool(trace.events)
36
+ and all(event_interval_ms(event) is not None for event in trace.events),
37
+ "tokens": bool(trace.events)
38
+ and all(
39
+ event.token_provenance in {"provider", "tokenizer", "user_supplied"}
40
+ for event in trace.events
41
+ if event.event_type == "model_call"
42
+ ),
43
+ "cost": all(
44
+ finite(event.metadata.get("provider_reported_cost_usd"))
45
+ and event.metadata["provider_reported_cost_usd"] >= 0
46
+ for event in trace.events
47
+ if event.event_type == "model_call"
48
+ ),
49
+ "semantic": bool(selected)
50
+ and all(row["status"] in {"supported", "not_supported", "retained"} for row in selected),
51
+ }
52
+ backends = {}
53
+ for row in selected:
54
+ for judgment in row["judgments"]:
55
+ identity = judgment["judge"]
56
+ backends[fingerprint(identity)] = identity
57
+ return {
58
+ "required": case["required_evidence"],
59
+ "missing": [key for key in case["required_evidence"] if not checks[key]],
60
+ "accounting": "recorded timing; exact token provenance; reported model billing; validated saved semantic investigations",
61
+ "semantic_statuses": [
62
+ {
63
+ "id": row["definition"]["investigation_id"],
64
+ "status": row["status"],
65
+ "basis": row["basis"],
66
+ }
67
+ for row in selected
68
+ ],
69
+ "backends": [backends[key] for key in sorted(backends)],
70
+ }
71
+
72
+
73
+ def run_finding_benchmark(protocol: FindingBenchmark, *, split, source_revision):
74
+ """Analyze each selected native trace once. No callbacks, retries or tuning.
75
+
76
+ Timings include native report construction and all canonical rules, not an
77
+ invented allocation of that time to individual rules. Only the case's labeled
78
+ rule is scored; other rules' outputs are outside that case's label scope.
79
+ """
80
+ if type(protocol) is not FindingBenchmark:
81
+ raise ValueError("a frozen FindingBenchmark is required")
82
+ text(source_revision, "source_revision")
83
+ cases = protocol.cases(split)
84
+ registry = {rule.rule_id: rule.version for rule in rules.BUILTIN_RULES}
85
+ identities = {case["rule_id"] for case in cases}
86
+ if not identities <= registry.keys():
87
+ raise ValueError("corpus refers to an unavailable rule")
88
+ rows = []
89
+ for case in cases:
90
+ trace = AgentTrace.from_dict(_copy(case["trace"]))
91
+ start = perf_counter_ns()
92
+ try:
93
+ report = trace.report()
94
+ findings = [
95
+ item for item in report["finding_candidates"] if item["rule_id"] == case["rule_id"]
96
+ ]
97
+ errors = [item for item in report["rule_errors"] if item["rule_id"] == case["rule_id"]]
98
+ status = "error" if errors else "emitted" if findings else "abstained"
99
+ except Exception:
100
+ # Keep failed analyses in the denominator without publishing payloads.
101
+ findings, status = [], "error"
102
+ elapsed = (perf_counter_ns() - start) / 1_000_000
103
+ rows.append(
104
+ {
105
+ "case_id": case["id"],
106
+ "rule_id": case["rule_id"],
107
+ "rule_version": registry[case["rule_id"]],
108
+ "trace_hash": fingerprint(case["trace"]),
109
+ "status": status,
110
+ "findings": _copy(findings),
111
+ "analysis_ms": elapsed,
112
+ "evidence": _evidence(case, trace),
113
+ }
114
+ )
115
+ result = {
116
+ "schema_version": FINDING_BENCHMARK_VERSION,
117
+ "corpus_hash": protocol.corpus_hash,
118
+ "split": split,
119
+ "policy": protocol.to_dict()["policy"],
120
+ "source_revision": source_revision,
121
+ "python": platform.python_version(),
122
+ "rules": {key: registry[key] for key in sorted(identities)},
123
+ "rows": rows,
124
+ }
125
+ return {**result, "summary": summarize_finding_benchmark(protocol, result)}
126
+
127
+
128
+ def _validated_rows(protocol, result):
129
+ if (
130
+ result.get("schema_version") != FINDING_BENCHMARK_VERSION
131
+ or result.get("corpus_hash") != protocol.corpus_hash
132
+ ):
133
+ raise ValueError("result does not match the frozen corpus")
134
+ if result.get("policy") != protocol.to_dict()["policy"]:
135
+ raise ValueError("frozen policy mismatch")
136
+ text(result.get("source_revision"), "source_revision")
137
+ cases = protocol.cases(result.get("split"))
138
+ versions = result.get("rules")
139
+ if not isinstance(versions, dict) or set(versions) != {case["rule_id"] for case in cases}:
140
+ raise ValueError("result must declare every planned rule")
141
+ for version in versions.values():
142
+ text(version, "rule version")
143
+ rows = result.get("rows")
144
+ if not isinstance(rows, list):
145
+ raise ValueError("result rows must be a list")
146
+ by_id, expected = {}, {case["id"]: case for case in cases}
147
+ for row in rows:
148
+ identity = row.get("case_id")
149
+ if identity not in expected or identity in by_id:
150
+ raise ValueError("duplicate or unexpected result case")
151
+ case = expected[identity]
152
+ if (
153
+ row.get("trace_hash") != fingerprint(case["trace"])
154
+ or row.get("rule_id") != case["rule_id"]
155
+ or row.get("rule_version") != versions[case["rule_id"]]
156
+ ):
157
+ raise ValueError("result provenance mismatch")
158
+ status, findings = row.get("status"), row.get("findings")
159
+ if status not in {"error", "abstained", "emitted"} or not isinstance(findings, list):
160
+ raise ValueError("invalid result status or findings")
161
+ if status != "error" and bool(findings) != (status == "emitted"):
162
+ raise ValueError("result status contradicts its findings")
163
+ if not finite(row.get("analysis_ms")) or row["analysis_ms"] < 0:
164
+ raise ValueError("analysis timing must be finite and nonnegative")
165
+ for finding in findings:
166
+ if (
167
+ finding.get("rule_id") != row["rule_id"]
168
+ or finding.get("rule_version") != row["rule_version"]
169
+ ):
170
+ raise ValueError("finding rule identity mismatch")
171
+ text(finding.get("type"), "finding family")
172
+ spans = finding.get("affected_nodes")
173
+ if not isinstance(spans, list) or any(not isinstance(span, str) for span in spans):
174
+ raise ValueError("finding spans must be strings")
175
+ text(finding.get("confidence"), "finding confidence")
176
+ # Reconstruct evidence completeness and backend provenance from the frozen
177
+ # trace, never from a mutable summary or caller-supplied claimed score.
178
+ row = {**row, "evidence": _evidence(case, AgentTrace.from_dict(_copy(case["trace"])))}
179
+ by_id[identity] = row
180
+ return [
181
+ (
182
+ case,
183
+ by_id.get(
184
+ case["id"],
185
+ {
186
+ "case_id": case["id"],
187
+ "rule_id": case["rule_id"],
188
+ "rule_version": versions[case["rule_id"]],
189
+ "status": "missing",
190
+ "findings": [],
191
+ "analysis_ms": None,
192
+ "evidence": _evidence(case, AgentTrace.from_dict(_copy(case["trace"]))),
193
+ },
194
+ ),
195
+ )
196
+ for case in cases
197
+ ]
198
+
199
+
200
+ def _score(case, row):
201
+ known = case["label"] in {"opportunity", "no_opportunity"}
202
+ expected = {(item["family"], tuple(sorted(item["spans"]))) for item in case["opportunities"]}
203
+ matched, scored = set(), []
204
+ for finding in row["findings"]:
205
+ signature = (finding["type"], tuple(sorted(finding["affected_nodes"])))
206
+ correct = signature in expected and signature not in matched if known else None
207
+ if correct:
208
+ matched.add(signature)
209
+ observations = finding.get("observations") or {}
210
+ scored.append(
211
+ {
212
+ "confidence": finding["confidence"],
213
+ "basis": observations.get("basis", "deterministic_rule"),
214
+ "correct": correct,
215
+ }
216
+ )
217
+ return scored, len(expected - matched)
218
+
219
+
220
+ def _aggregate(pairs):
221
+ counts = Counter(
222
+ {
223
+ key: 0
224
+ for key in (
225
+ "planned_cases",
226
+ "emitted_cases",
227
+ "abstained_cases",
228
+ "error_cases",
229
+ "missing_cases",
230
+ "opportunity_cases",
231
+ "negative_cases",
232
+ "unknown_cases",
233
+ "ambiguous_cases",
234
+ "negative_cases_with_findings",
235
+ "true_positive_findings",
236
+ "false_positive_findings",
237
+ "unscored_findings",
238
+ "missed_opportunities",
239
+ "complete_evidence_cases",
240
+ "incomplete_evidence_findings",
241
+ )
242
+ }
243
+ )
244
+ confidence, timings, backends = defaultdict(Counter), [], {}
245
+ for case, row in pairs:
246
+ counts["planned_cases"] += 1
247
+ counts[f"{row['status']}_cases"] += 1
248
+ counts[
249
+ {
250
+ "opportunity": "opportunity_cases",
251
+ "no_opportunity": "negative_cases",
252
+ "unknown": "unknown_cases",
253
+ "ambiguous": "ambiguous_cases",
254
+ }[case["label"]]
255
+ ] += 1
256
+ counts["complete_evidence_cases"] += not row["evidence"]["missing"]
257
+ if row["evidence"]["missing"]:
258
+ counts["incomplete_evidence_findings"] += len(row["findings"])
259
+ counts["negative_cases_with_findings"] += case["label"] == "no_opportunity" and bool(
260
+ row["findings"]
261
+ )
262
+ scored, missed = _score(case, row)
263
+ counts["missed_opportunities"] += missed
264
+ for finding in scored:
265
+ key = (
266
+ "unscored_findings"
267
+ if finding["correct"] is None
268
+ else "true_positive_findings"
269
+ if finding["correct"]
270
+ else "false_positive_findings"
271
+ )
272
+ counts[key] += 1
273
+ confidence[(finding["basis"], finding["confidence"])][key] += 1
274
+ if row["analysis_ms"] is not None:
275
+ timings.append(row["analysis_ms"])
276
+ for backend in row["evidence"]["backends"]:
277
+ backends[fingerprint(backend)] = backend
278
+ return {
279
+ **counts,
280
+ "precision": _ratio(
281
+ counts["true_positive_findings"],
282
+ counts["true_positive_findings"] + counts["false_positive_findings"],
283
+ ),
284
+ "false_positive_rate": _ratio(
285
+ counts["negative_cases_with_findings"], counts["negative_cases"]
286
+ ),
287
+ "coverage": _ratio(counts["emitted_cases"], counts["planned_cases"]),
288
+ "abstention_rate": _ratio(counts["abstained_cases"], counts["planned_cases"]),
289
+ "evidence_completeness": _ratio(counts["complete_evidence_cases"], counts["planned_cases"]),
290
+ "confidence_counts": [
291
+ {"basis": key[0], "confidence": key[1], **dict(value)}
292
+ for key, value in sorted(confidence.items())
293
+ ],
294
+ "confidence_semantics": "ordinal per rule; not probabilities; numeric calibration unavailable",
295
+ "analysis_timing": {
296
+ "scope": "native report including all canonical rules per selected trace",
297
+ "measured_cases": len(timings),
298
+ "total_ms": sum(timings),
299
+ "mean_ms": _ratio(sum(timings), len(timings)),
300
+ },
301
+ "backends": [backends[key] for key in sorted(backends)],
302
+ }
303
+
304
+
305
+ def summarize_finding_benchmark(protocol, result):
306
+ pairs = _validated_rows(protocol, result)
307
+ grouped = defaultdict(list)
308
+ for case, row in pairs:
309
+ grouped[(row["rule_id"], row["rule_version"])].append((case, row))
310
+ overall = _aggregate(pairs)
311
+ # The same word may represent unrelated evidence policies across rules.
312
+ overall["confidence_counts"] = []
313
+ overall["confidence_semantics"] = (
314
+ "See per-rule and evidence-basis strata; confidence is never pooled across rules."
315
+ )
316
+ return {
317
+ "synthetic": protocol.to_dict()["synthetic"],
318
+ "unit": "exact family and affected-span finding; FPR is any emission per known-negative case",
319
+ "overall": overall,
320
+ "per_rule": [
321
+ {"rule_id": identity, "rule_version": version, **_aggregate(group)}
322
+ for (identity, version), group in sorted(grouped.items())
323
+ ],
324
+ "limits": "Corpus-specific diagnostics, not universal precision or production savings. Abstention means no emitted finding, not a proven negative. Unknown and ambiguous labels are not correctness trials. Timings include saved evidence validation, not judge inference.",
325
+ }
326
+
327
+
328
+ def compare_finding_benchmarks(protocol, baseline, candidate, *, review_ref=None):
329
+ """Strict zero-regression gate on the same frozen evaluation split.
330
+
331
+ A review reference acknowledges a regression, not missing/invalid evidence.
332
+ It is a recorded host declaration, not an authentication mechanism.
333
+ """
334
+ if baseline.get("split") != "evaluation" or candidate.get("split") != "evaluation":
335
+ raise ValueError("release gates require the held-out evaluation split")
336
+ before = summarize_finding_benchmark(protocol, baseline)
337
+ after = summarize_finding_benchmark(protocol, candidate)
338
+ if review_ref is not None:
339
+ text(review_ref, "review_ref")
340
+ old = {row["rule_id"]: row for row in before["per_rule"]}
341
+ changes, incomplete = [], []
342
+ for row in after["per_rule"]:
343
+ previous = old[row["rule_id"]]
344
+ for key in ("error_cases", "missing_cases"):
345
+ if row[key] or previous[key]:
346
+ incomplete.append({"rule_id": row["rule_id"], "metric": key})
347
+ for key in (
348
+ "false_positive_findings",
349
+ "negative_cases_with_findings",
350
+ "missed_opportunities",
351
+ "unscored_findings",
352
+ "incomplete_evidence_findings",
353
+ ):
354
+ if row[key] > previous[key]:
355
+ changes.append(
356
+ {
357
+ "rule_id": row["rule_id"],
358
+ "baseline_version": previous["rule_version"],
359
+ "candidate_version": row["rule_version"],
360
+ "metric": key,
361
+ "baseline": previous[key],
362
+ "candidate": row[key],
363
+ }
364
+ )
365
+ return {
366
+ "passed": not incomplete and (not changes or review_ref is not None),
367
+ "regressions": changes,
368
+ "incomplete": incomplete,
369
+ "review_ref": review_ref,
370
+ "review_applied": bool(changes and review_ref and not incomplete),
371
+ "corpus_hash": protocol.corpus_hash,
372
+ "policy": protocol.to_dict()["policy"],
373
+ "baseline_revision": baseline["source_revision"],
374
+ "candidate_revision": candidate["source_revision"],
375
+ "threshold_policy": "No increase in false positives, missed opportunities, unscored emissions or findings with incomplete required evidence; errors/missing cases always fail.",
376
+ }
377
+
378
+
379
+ def finding_benchmark_markdown(protocol, result, gate=None):
380
+ summary = summarize_finding_benchmark(protocol, result)
381
+ lines = [
382
+ "# Finding benchmark",
383
+ "",
384
+ summary["limits"],
385
+ "",
386
+ f"Corpus: `{protocol.corpus_hash}`",
387
+ "",
388
+ "| Rule | Version | Cases | TP | FP | Unknown/ambiguous | Abstentions | Precision | FPR |",
389
+ "| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
390
+ ]
391
+ for row in summary["per_rule"]:
392
+ values = [
393
+ row["rule_id"],
394
+ row["rule_version"],
395
+ row["planned_cases"],
396
+ row["true_positive_findings"],
397
+ row["false_positive_findings"],
398
+ row["unknown_cases"] + row["ambiguous_cases"],
399
+ row["abstained_cases"],
400
+ row["precision"],
401
+ row["false_positive_rate"],
402
+ ]
403
+ lines.append(
404
+ "| "
405
+ + " | ".join(
406
+ markdown_table_cell("unavailable" if value is None else str(value))
407
+ for value in values
408
+ )
409
+ + " |"
410
+ )
411
+ lines.extend(
412
+ [
413
+ "",
414
+ "Confidence labels are ordinal within each rule; they are not calibrated probabilities. JSON retains coverage, evidence completeness, backend/config identities, per-case findings and measured analysis overhead.",
415
+ ]
416
+ )
417
+ if gate is not None:
418
+ lines.extend(
419
+ [
420
+ "",
421
+ f"Release gate: {'PASS' if gate['passed'] else 'FAIL'}; regressions: {len(gate['regressions'])}; incomplete: {len(gate['incomplete'])}; explicit review: {markdown_table_cell(gate['review_ref'] or 'none')}.",
422
+ ]
423
+ )
424
+ return "\n".join(lines) + "\n"