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,199 @@
1
+ """Frozen decision-step measurements, separate from model-only profile totals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict
7
+
8
+ from agentloop.events import AgentEvent
9
+ from agentloop.judgment_types import (
10
+ JudgeIdentity,
11
+ JudgeUsage,
12
+ canonical,
13
+ fingerprint,
14
+ finite,
15
+ is_hash,
16
+ text,
17
+ )
18
+ from agentloop.substitution_types import SUBSTITUTION_VERSION, TRIAL_KEY
19
+ from agentloop.workflow_types import stage_summary, workflow_summary
20
+
21
+ _STATES = {"completed", "unknown", "failed", "timed_out", "invalid_result", "disabled", "missing"}
22
+
23
+
24
+ def _binding(trace):
25
+ events = []
26
+ for event in trace.events:
27
+ value = AgentEvent.from_dict(event.to_dict()).to_dict()
28
+ value["metadata"] = {
29
+ key: item
30
+ for key, item in value["metadata"].items()
31
+ if key not in {"otel_span_id", "otel_trace_id"}
32
+ }
33
+ events.append(value)
34
+ return fingerprint(
35
+ {
36
+ "run_id": trace.run_id,
37
+ "started_at": trace.started_at,
38
+ "ended_at": trace.ended_at,
39
+ "elapsed_ms": float(trace.elapsed_ms) if trace.elapsed_ms is not None else None,
40
+ "events": events,
41
+ "workflow": trace.metadata.get("agentloop.workflow"),
42
+ "trial_metadata": {
43
+ key: trace.metadata.get(key)
44
+ for key in (
45
+ "plan_hash",
46
+ "example_id",
47
+ "task_id",
48
+ "repetition",
49
+ "condition",
50
+ "synthetic",
51
+ "success",
52
+ )
53
+ },
54
+ }
55
+ )
56
+
57
+
58
+ def attach_trial_evidence(
59
+ trace,
60
+ *,
61
+ plan_hash,
62
+ example_id,
63
+ repetition,
64
+ implementation,
65
+ status,
66
+ latency_ms,
67
+ usage,
68
+ cost_ref,
69
+ input_hash,
70
+ output_hash,
71
+ ):
72
+ record = {
73
+ "schema_version": SUBSTITUTION_VERSION,
74
+ "run_id": trace.run_id,
75
+ "plan_hash": plan_hash,
76
+ "example_id": example_id,
77
+ "repetition": repetition,
78
+ "implementation": implementation,
79
+ "outcome": status,
80
+ "latency_ms": latency_ms,
81
+ "usage": asdict(usage),
82
+ "cost_ref": cost_ref,
83
+ "input_hash": input_hash,
84
+ "output_hash": output_hash,
85
+ "source_hash": _binding(trace),
86
+ "scope": "declared_decision_step",
87
+ "timing_basis": "callback_wall_time" if latency_ms is not None else "not_observed",
88
+ }
89
+ record["evidence_hash"] = fingerprint(record)
90
+ if TRIAL_KEY in trace.metadata:
91
+ raise ValueError("trial evidence cannot replace an existing receipt")
92
+ trace.metadata[TRIAL_KEY] = json.loads(canonical(record))
93
+ if read_trial_evidence(trace)["status"] != "valid":
94
+ trace.metadata.pop(TRIAL_KEY)
95
+ raise ValueError("invalid decision trial evidence")
96
+ return json.loads(canonical(record))
97
+
98
+
99
+ def read_trial_evidence(trace):
100
+ if TRIAL_KEY not in trace.metadata:
101
+ return (
102
+ {"status": "invalid", "scope": "declared_decision_step"}
103
+ if trace.metadata.get("source") == "agentloop_substitution"
104
+ else None
105
+ )
106
+ empty = {"status": "invalid", "scope": "declared_decision_step"}
107
+ try:
108
+ record = json.loads(canonical(trace.metadata[TRIAL_KEY]))
109
+ if not isinstance(record, dict):
110
+ return empty
111
+ if record.get("schema_version") != SUBSTITUTION_VERSION:
112
+ return {**empty, "status": "unsupported"}
113
+ digest = record.pop("evidence_hash")
114
+ if (
115
+ digest != fingerprint(record)
116
+ or record["run_id"] != trace.run_id
117
+ or record["source_hash"] != _binding(trace)
118
+ ):
119
+ return empty
120
+ if record["scope"] != "declared_decision_step" or record["outcome"] not in _STATES:
121
+ return empty
122
+ for key in ("plan_hash", "input_hash"):
123
+ if not is_hash(record[key]):
124
+ return empty
125
+ if record["output_hash"] is not None and not is_hash(record["output_hash"]):
126
+ return empty
127
+ if (record["outcome"] == "completed") != (record["output_hash"] is not None):
128
+ return empty
129
+ text(record["example_id"], "example_id")
130
+ if type(record["repetition"]) is not int or record["repetition"] < 0:
131
+ return empty
132
+ declaration = record["implementation"]
133
+ text(declaration["name"], "implementation name")
134
+ JudgeIdentity(**declaration["identity"])
135
+ if declaration["kind"] not in {"model", "classifier", "rule", "tool", "external_service"}:
136
+ return empty
137
+ JudgeUsage(**record["usage"])
138
+ for key in ("plan_hash", "example_id", "repetition"):
139
+ if trace.metadata.get(key) != record[key]:
140
+ return empty
141
+ if (
142
+ trace.metadata.get("condition") != declaration["name"]
143
+ or type(trace.metadata.get("synthetic")) is not bool
144
+ ):
145
+ return empty
146
+ if trace.metadata.get("task_id") != record["example_id"]:
147
+ return empty
148
+ workflow = workflow_summary(trace.metadata)
149
+ if workflow is None or workflow.get("schema_status") != "supported":
150
+ return empty
151
+ failed = record["outcome"] in {"failed", "timed_out", "invalid_result"}
152
+ expected_state = (
153
+ "failed"
154
+ if failed
155
+ else ("unknown" if record["outcome"] in {"disabled", "missing"} else "completed")
156
+ )
157
+ if workflow["status"] != expected_state:
158
+ return empty
159
+ text(record["cost_ref"], "cost_ref", optional=True)
160
+ latency = record["latency_ms"]
161
+ if latency is None:
162
+ if (
163
+ record["outcome"] not in {"disabled", "missing"}
164
+ or record["timing_basis"] != "not_observed"
165
+ or trace.events
166
+ ):
167
+ return empty
168
+ elif (
169
+ not finite(latency)
170
+ or latency < 0
171
+ or latency != trace.elapsed_ms
172
+ or record["timing_basis"] != "callback_wall_time"
173
+ or len(trace.events) != 1
174
+ or trace.events[0].duration_ms != latency
175
+ ):
176
+ return empty
177
+ if latency is not None and trace.events[0].status != ("error" if failed else "ok"):
178
+ return empty
179
+ if latency is not None:
180
+ stage = stage_summary(trace.events[0])
181
+ if (
182
+ stage is None
183
+ or stage.get("schema_status") != "supported"
184
+ or stage.get("version") != declaration["identity"]["version"]
185
+ or stage.get("kind") != declaration["kind"]
186
+ or stage.get("outcome") != record["outcome"]
187
+ or stage.get("output_ref")
188
+ != (
189
+ "sha256:" + record["output_hash"] if record["output_hash"] is not None else None
190
+ )
191
+ ):
192
+ return empty
193
+ if record["outcome"] == "disabled" and JudgeUsage(**record["usage"]) != JudgeUsage(
194
+ 0, 0, 0, "not_dispatched", "not_dispatched"
195
+ ):
196
+ return empty
197
+ return {**record, "status": "valid", "evidence_hash": digest}
198
+ except (TypeError, ValueError, KeyError, AttributeError):
199
+ return empty
@@ -0,0 +1,190 @@
1
+ """Export frozen decision trials without invoking an implementation or scorer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from agentloop.markdown import markdown_heading, markdown_table_cell
9
+ from agentloop.structured_quality import attach_quality_report
10
+ from agentloop.studies import study_to_markdown, summarize_study
11
+ from agentloop.substitution_reports import (
12
+ _copy,
13
+ index_trials,
14
+ load_bundle,
15
+ paired_assessments,
16
+ summarize_substitution_experiment,
17
+ )
18
+ from agentloop.tracer import AgentTrace
19
+
20
+
21
+ def substitution_to_markdown(report):
22
+ lines = [
23
+ f"# Model substitution: {markdown_heading(report['name'])}",
24
+ "",
25
+ report["interpretation"],
26
+ "",
27
+ "Measurement scope: declared decision-step usage and callback latency. Reported/calculated usage is separate from model-only profile totals; unknown values are not zero.",
28
+ "",
29
+ ]
30
+ if report["synthetic"]:
31
+ lines.extend(
32
+ [
33
+ "Synthetic data: these results do not establish real-model quality, latency or savings.",
34
+ "",
35
+ ]
36
+ )
37
+ lines.extend(
38
+ [
39
+ "## Measurements",
40
+ "",
41
+ "| Implementation | Planned | Unavailable output rate | Observed latency ms | Observed cost USD | Cost basis |",
42
+ "| --- | ---: | ---: | ---: | ---: | --- |",
43
+ ]
44
+ )
45
+ for item in report["implementations"]:
46
+ cells = [
47
+ item["name"],
48
+ item["planned"],
49
+ item["unavailable_output_rate"],
50
+ item["measurements"]["latency_ms"]["observed_mean"],
51
+ item["measurements"]["cost_usd"]["observed_mean"],
52
+ json.dumps(item["cost_basis_counts"], sort_keys=True),
53
+ ]
54
+ lines.append(
55
+ "| "
56
+ + " | ".join(
57
+ markdown_table_cell("unavailable" if value is None else str(value))
58
+ for value in cells
59
+ )
60
+ + " |"
61
+ )
62
+ lines.extend(
63
+ [
64
+ "",
65
+ "## Independent quality and baseline agreement",
66
+ "",
67
+ "A full quality decision requires every planned example and repetition. Observed quality means use completely assessed examples only. Baseline agreement is a separate descriptive metric, not independent quality evidence.",
68
+ "",
69
+ "| Candidate | Complete examples | Baseline quality | Candidate quality | Quality preserved on examples | Baseline agreement |",
70
+ "| --- | ---: | ---: | ---: | --- | ---: |",
71
+ ]
72
+ )
73
+ for item in report["comparisons"]:
74
+ cells = [
75
+ item["condition"],
76
+ item["complete_quality_examples"],
77
+ item["observed_baseline_quality"],
78
+ item["observed_candidate_quality"],
79
+ item["quality_preserved_on_examples"],
80
+ item["baseline_agreement"]["observed_rate"],
81
+ ]
82
+ lines.append(
83
+ "| "
84
+ + " | ".join(
85
+ markdown_table_cell("unavailable" if value is None else str(value))
86
+ for value in cells
87
+ )
88
+ + " |"
89
+ )
90
+ return "\n".join(lines) + "\n"
91
+
92
+
93
+ def export_substitution_studies(bundle, out, *, max_invocations=10000):
94
+ """Write native quality-bound traces and one paired study per candidate.
95
+
96
+ Failed/unknown outputs have real trial receipts and remain in each study.
97
+ Missing or ambiguous source records are not fabricated during export.
98
+ Baseline executions are shared controls across these separate comparisons.
99
+ """
100
+ owned, examples = load_bundle(bundle, max_invocations)
101
+ slots, extras = index_trials(owned, examples)
102
+ pairs, pair_extras = paired_assessments(owned, slots, examples)
103
+ if (
104
+ extras
105
+ or pair_extras
106
+ or any(item["trace"] is None for item in slots.values())
107
+ or any(item["quality"] is None for item in pairs)
108
+ ):
109
+ raise ValueError(
110
+ "study export requires unambiguous receipts and saved quality assessments; failed outputs remain valid study inputs"
111
+ )
112
+ root = Path(out).resolve()
113
+ documents, manifests = {}, []
114
+ for index, name in enumerate(
115
+ key for key in owned["implementations"] if key != owned["baseline"]
116
+ ):
117
+ directory = f"candidate-{index:03d}"
118
+ conditions = {"baseline": [], "candidate": []}
119
+ selected = [item for item in pairs if item["condition"] == name]
120
+ for row_index, pair in enumerate(selected):
121
+ for side, condition in (("baseline", owned["baseline"]), ("candidate", name)):
122
+ trial = slots[condition, pair["example_id"], pair["repetition"]]
123
+ trace = AgentTrace.from_dict(_copy(trial["trace"].to_dict()))
124
+ attach_quality_report(trace, pair["quality"], side=side)
125
+ filename = f"{side}-{row_index:06d}.json"
126
+ documents[f"{directory}/{filename}"] = trace.to_dict()
127
+ conditions[side].append(filename)
128
+ manifest = {
129
+ "schema_version": "1.0",
130
+ "name": owned["protocol"]["name"] + " / " + name,
131
+ "baseline": "baseline",
132
+ "conditions": conditions,
133
+ "pairing_keys": ["plan_hash", "example_id", "repetition"],
134
+ }
135
+ documents[f"{directory}/study.json"] = manifest
136
+ manifests.append(root / directory / "study.json")
137
+ comparison = summarize_substitution_experiment(owned, max_invocations=max_invocations)
138
+ documents["bundle.json"] = owned
139
+ documents["comparison.json"] = comparison
140
+ encoded = {
141
+ relative: json.dumps(value, indent=2, allow_nan=False) + "\n"
142
+ for relative, value in documents.items()
143
+ }
144
+ encoded["comparison.md"] = substitution_to_markdown(comparison)
145
+ for relative, content in encoded.items():
146
+ target = root / relative
147
+ if not target.resolve().is_relative_to(root):
148
+ raise ValueError("artifact path escapes the output directory")
149
+ if target.exists() and target.read_text(encoding="utf-8") != content:
150
+ raise FileExistsError(
151
+ f"refusing to replace a different experiment artifact: {relative}"
152
+ )
153
+ # Derived reports must also be protected. Their content is deterministic for
154
+ # these saved traces; checking after computation never reruns a scorer.
155
+ for manifest in manifests:
156
+ targets = [manifest.with_name(name) for name in ("study-report.json", "study-report.md")]
157
+ if any(not target.resolve().is_relative_to(root) for target in targets):
158
+ raise ValueError("study report path escapes the output directory")
159
+ if any(target.exists() for target in targets):
160
+ if not manifest.exists():
161
+ raise FileExistsError("orphaned study report would be overwritten")
162
+ existing = summarize_study(manifest)
163
+ expected = [
164
+ json.dumps(existing, indent=2, allow_nan=False) + "\n",
165
+ study_to_markdown(existing),
166
+ ]
167
+ for target, content in zip(targets, expected):
168
+ if target.exists() and target.read_text(encoding="utf-8") != content:
169
+ raise FileExistsError("refusing to replace a different saved study report")
170
+ for relative, content in encoded.items():
171
+ target = root / relative
172
+ target.parent.mkdir(parents=True, exist_ok=True)
173
+ if not target.exists():
174
+ target.write_text(content, encoding="utf-8")
175
+ reports = []
176
+ for manifest in manifests:
177
+ report = summarize_study(manifest)
178
+ derived = {
179
+ "study-report.json": json.dumps(report, indent=2, allow_nan=False) + "\n",
180
+ "study-report.md": study_to_markdown(report),
181
+ }
182
+ for name, content in derived.items():
183
+ target = manifest.with_name(name)
184
+ if target.exists() and target.read_text(encoding="utf-8") != content:
185
+ raise FileExistsError("refusing to replace a different saved study report")
186
+ for name, content in derived.items():
187
+ if not manifest.with_name(name).exists():
188
+ manifest.with_name(name).write_text(content, encoding="utf-8")
189
+ reports.append(report)
190
+ return {"directory": str(root), "studies": reports, "comparison": comparison}