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,82 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def instrument(obj: Any, *, kind: str | None = None, name: str | None = None) -> Any:
7
+ """Best-effort one-line instrumentation for common agent objects.
8
+
9
+ Explicit `kind` values:
10
+ - `openai`
11
+ - `crewai_crew`
12
+ - `crewai_agent`
13
+ - `crewai_task`
14
+ - `langgraph_state_graph`
15
+ - `langgraph_runnable`
16
+
17
+ Without `kind`, AgentLoop uses duck-typing to choose the safest wrapper.
18
+ """
19
+
20
+ normalized = kind.lower().replace("-", "_") if kind else None
21
+
22
+ if normalized == "openai" or _looks_like_openai_client(obj):
23
+ from agentloop.integrations.openai import instrument_openai_client
24
+
25
+ return instrument_openai_client(obj)
26
+
27
+ if normalized == "crewai_crew" or _looks_like_crewai_crew(obj):
28
+ from agentloop.integrations.crewai import instrument_crew
29
+
30
+ return instrument_crew(obj, name=name)
31
+
32
+ if normalized == "crewai_agent":
33
+ from agentloop.integrations.crewai import instrument_agent
34
+
35
+ return instrument_agent(obj, name=name)
36
+
37
+ if normalized == "crewai_task":
38
+ from agentloop.integrations.crewai import instrument_task
39
+
40
+ return instrument_task(obj, name=name)
41
+
42
+ if normalized == "langgraph_state_graph" or _looks_like_state_graph(obj):
43
+ from agentloop.integrations.langgraph import instrument_state_graph
44
+
45
+ return instrument_state_graph(obj)
46
+
47
+ if normalized == "langgraph_runnable" or _looks_like_runnable(obj):
48
+ from agentloop.integrations.langgraph import trace_runnable
49
+
50
+ return trace_runnable(
51
+ obj, name=name or getattr(obj, "name", None) or obj.__class__.__name__
52
+ )
53
+
54
+ raise ValueError(
55
+ "Could not infer object type for AgentLoop instrumentation. "
56
+ "Pass kind='openai', 'crewai_crew', 'crewai_agent', 'crewai_task', "
57
+ "'langgraph_state_graph', or 'langgraph_runnable'."
58
+ )
59
+
60
+
61
+ def _looks_like_openai_client(obj: Any) -> bool:
62
+ responses = getattr(obj, "responses", None)
63
+ chat = getattr(obj, "chat", None)
64
+ completions = getattr(chat, "completions", None) if chat is not None else None
65
+ return bool(
66
+ responses is not None
67
+ and callable(getattr(responses, "create", None))
68
+ or completions is not None
69
+ and callable(getattr(completions, "create", None))
70
+ )
71
+
72
+
73
+ def _looks_like_crewai_crew(obj: Any) -> bool:
74
+ return callable(getattr(obj, "kickoff", None)) or callable(getattr(obj, "kickoff_async", None))
75
+
76
+
77
+ def _looks_like_state_graph(obj: Any) -> bool:
78
+ return callable(getattr(obj, "add_node", None)) and callable(getattr(obj, "compile", None))
79
+
80
+
81
+ def _looks_like_runnable(obj: Any) -> bool:
82
+ return callable(getattr(obj, "invoke", None)) or callable(getattr(obj, "ainvoke", None))
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from agentloop.events import AgentEvent, new_event_id, utc_now_iso
6
+ from agentloop.tracer import AgentTrace
7
+
8
+
9
+ def _get(data: Any, key: str, default: Any = None) -> Any:
10
+ if isinstance(data, dict):
11
+ return data.get(key, default)
12
+ return getattr(data, key, default)
13
+
14
+
15
+ def trace_from_vercel_ai_events(
16
+ events: list[dict[str, Any]],
17
+ *,
18
+ name: str = "vercel_ai_run",
19
+ run_id: str | None = None,
20
+ metadata: dict[str, Any] | None = None,
21
+ ) -> AgentTrace:
22
+ """Convert Vercel AI SDK-style telemetry events into an AgentLoop trace.
23
+
24
+ The adapter intentionally accepts plain dictionaries so JS/TS apps can POST or
25
+ export telemetry without importing Python code. Expected fields are flexible:
26
+ `type`/`event_type`, `name`, `duration_ms`, `model`, `input_tokens`,
27
+ `output_tokens`, `status`, and `metadata`.
28
+ """
29
+
30
+ trace = AgentTrace(
31
+ name=name, run_id=run_id, metadata={"integration": "vercel_ai", **(metadata or {})}
32
+ )
33
+ for item in events:
34
+ event_type = str(_get(item, "event_type", _get(item, "type", "model_call")))
35
+ if event_type in {"text", "generate", "stream", "model"}:
36
+ event_type = "model_call"
37
+ elif event_type in {"tool", "tool-call", "step"}:
38
+ event_type = "tool_call"
39
+
40
+ trace.add_event(
41
+ AgentEvent(
42
+ event_id=str(_get(item, "event_id", new_event_id())),
43
+ run_id=trace.run_id,
44
+ event_type=event_type,
45
+ name=str(_get(item, "name", event_type)),
46
+ started_at=str(_get(item, "started_at", utc_now_iso())),
47
+ ended_at=str(_get(item, "ended_at", utc_now_iso())),
48
+ duration_ms=float(_get(item, "duration_ms", 0.0) or 0.0),
49
+ parent_id=_get(item, "parent_id"),
50
+ model=_get(item, "model"),
51
+ input_tokens=int(_get(item, "input_tokens", _get(item, "prompt_tokens", 0)) or 0),
52
+ output_tokens=int(
53
+ _get(item, "output_tokens", _get(item, "completion_tokens", 0)) or 0
54
+ ),
55
+ input_text=_get(item, "input_text"),
56
+ output_text=_get(item, "output_text"),
57
+ status=str(_get(item, "status", "ok")),
58
+ error=_get(item, "error"),
59
+ metadata={"integration": "vercel_ai", **(_get(item, "metadata", {}) or {})},
60
+ )
61
+ )
62
+ return trace
63
+
64
+
65
+ TYPESCRIPT_SNIPPET = r"""
66
+ // Minimal Vercel AI SDK telemetry bridge for AgentLoop.
67
+ // Push these events to your backend, then convert them with
68
+ // agentloop.integrations.vercel_ai.trace_from_vercel_ai_events(...).
69
+
70
+ const agentloopEvents: any[] = [];
71
+
72
+ export function recordAgentLoopEvent(event: any) {
73
+ agentloopEvents.push({
74
+ ...event,
75
+ started_at: event.started_at ?? new Date().toISOString(),
76
+ ended_at: event.ended_at ?? new Date().toISOString(),
77
+ });
78
+ }
79
+
80
+ export function getAgentLoopEvents() {
81
+ return agentloopEvents;
82
+ }
83
+ """
@@ -0,0 +1,5 @@
1
+ """Internal, dependency-free boundaries for external execution evidence.
2
+
3
+ These contracts are additive to native traces. Importing this package does not
4
+ discover plugins, load external runtimes, execute task code or contact services.
5
+ """
@@ -0,0 +1,112 @@
1
+ """Stable local native projections and receipts shared by external importers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from hashlib import sha256
7
+ from pathlib import Path
8
+ from typing import Any, Iterable
9
+
10
+ from agentloop.interoperability.contracts import ImportReceipt
11
+ from agentloop.interoperability.validation import (
12
+ ImportValidationError,
13
+ indirect_path,
14
+ relative_reference,
15
+ )
16
+ from agentloop.interventions import canonical_json
17
+ from agentloop.tracer import AgentTrace
18
+
19
+
20
+ def native_bytes(trace: AgentTrace) -> bytes:
21
+ return (
22
+ json.dumps(trace.to_dict(), indent=2, ensure_ascii=False, allow_nan=False) + "\n"
23
+ ).encode("utf-8")
24
+
25
+
26
+ def write_artifact(root: Path, reference: str, data: bytes) -> None:
27
+ """Reject indirect paths and conflicting bytes under an explicit output root."""
28
+ write_artifact_chunks(root, reference, (data,))
29
+
30
+
31
+ def output_artifact_path(root: Path, reference: str) -> Path:
32
+ """Check every generated child reference before creating files or directories."""
33
+ relative_reference(reference)
34
+ current = root
35
+ for part in reference.split("/"):
36
+ current = current / part
37
+ try:
38
+ indirect = indirect_path(current)
39
+ except FileNotFoundError:
40
+ indirect = False
41
+ if indirect:
42
+ raise ImportValidationError(
43
+ "unsafe_path", "output", "output references cannot be symlinks"
44
+ )
45
+ return current
46
+
47
+
48
+ def write_artifact_chunks(root: Path, reference: str, chunks: Iterable[bytes]) -> None:
49
+ """Write bounded chunks, comparing an existing artifact without loading it all."""
50
+ target = output_artifact_path(root, reference)
51
+ target.parent.mkdir(parents=True, exist_ok=True)
52
+ if target.exists():
53
+ with target.open("rb") as stream:
54
+ for chunk in chunks:
55
+ if stream.read(len(chunk)) != chunk:
56
+ raise ImportValidationError(
57
+ "output_conflict", "output", "existing artifact has conflicting content"
58
+ )
59
+ if stream.read(1):
60
+ raise ImportValidationError(
61
+ "output_conflict", "output", "existing artifact has conflicting content"
62
+ )
63
+ return
64
+ # Open exclusively before entering cleanup, so a failed competing open
65
+ # cannot remove an artifact owned by another writer.
66
+ stream = target.open("xb")
67
+ try:
68
+ with stream:
69
+ for chunk in chunks:
70
+ stream.write(chunk)
71
+ except BaseException:
72
+ # Close before unlinking on Windows. A producer/write error must not
73
+ # leave a partial immutable artifact that blocks a successful retry.
74
+ target.unlink(missing_ok=True)
75
+ raise
76
+
77
+
78
+ def write_import_bundle(
79
+ traces: Iterable[AgentTrace],
80
+ receipts: Iterable[ImportReceipt],
81
+ out: str | Path,
82
+ inventory: dict[str, Any],
83
+ ) -> Path:
84
+ """Verify captured byte hashes before exporting a reproducible import bundle."""
85
+ traces, receipts = tuple(traces), tuple(receipts)
86
+ expected = {
87
+ item["run_id"]: item["trace_sha256"]
88
+ for receipt in receipts
89
+ for item in receipt.to_dict()["traces"]
90
+ }
91
+ if len({trace.run_id for trace in traces}) != len(traces):
92
+ raise ImportValidationError(
93
+ "source_conflict", "native_trace", "duplicate native trace identity"
94
+ )
95
+ for trace in traces:
96
+ if sha256(native_bytes(trace)).hexdigest() != expected.get(trace.run_id):
97
+ raise ImportValidationError(
98
+ "source_conflict", "native_trace", "trace changed after receipt capture"
99
+ )
100
+ root = Path(out)
101
+ root.mkdir(parents=True, exist_ok=True)
102
+ root = root.resolve()
103
+ for trace in traces:
104
+ write_artifact(root, f"traces/{trace.run_id}.json", native_bytes(trace))
105
+ for receipt in receipts:
106
+ write_artifact(
107
+ root,
108
+ f"receipts/{receipt.receipt_id}.json",
109
+ (canonical_json(receipt.to_dict()) + "\n").encode(),
110
+ )
111
+ write_artifact(root, "inventory.json", (canonical_json(inventory) + "\n").encode())
112
+ return root / "inventory.json"
@@ -0,0 +1,59 @@
1
+ """Offline batch telemetry import; no collectors, SDKs or providers are started."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from agentloop.interoperability.validation import ImportValidationError
10
+
11
+ telemetry_app = typer.Typer(help="Import bounded OTLP JSON/JSONL evidence offline.")
12
+
13
+
14
+ @telemetry_app.command("import-jsonl")
15
+ def import_jsonl_command(
16
+ path: Path,
17
+ out: Path = typer.Option(
18
+ ..., help="Output directory for qualified traces, receipts and inventory."
19
+ ),
20
+ source: str = typer.Option("otel", help="Source system: otel, harbor or omnigent."),
21
+ strict: bool = typer.Option(False, help="Fail on an independent malformed record."),
22
+ single_json: bool = typer.Option(
23
+ False, help="Read one bounded JSON document instead of JSONL."
24
+ ),
25
+ synthetic: bool = typer.Option(False, help="Explicitly mark owned synthetic fixtures."),
26
+ ) -> None:
27
+ """Stream a completed export and preserve every invalid/conflicting record."""
28
+ from agentloop.interoperability.otlp_jsonl import OtlpOptions, import_otlp
29
+
30
+ try:
31
+ result = import_otlp(
32
+ path,
33
+ options=OtlpOptions(
34
+ system=source, continue_on_error=not strict, synthetic_fixture=synthetic
35
+ ),
36
+ jsonl=not single_json,
37
+ )
38
+ except (ImportValidationError, OSError) as exc:
39
+ message = (
40
+ str(exc)
41
+ if isinstance(exc, ImportValidationError)
42
+ else "Telemetry artifacts could not be read"
43
+ )
44
+ raise typer.BadParameter(message, param_hint="path") from None
45
+ try:
46
+ inventory = result.write(out)
47
+ except (ImportValidationError, OSError) as exc:
48
+ message = (
49
+ str(exc) if isinstance(exc, ImportValidationError) else "Output could not be written"
50
+ )
51
+ raise typer.BadParameter(message, param_hint="--out") from None
52
+ stats = result.inventory()
53
+ typer.echo(
54
+ f"{stats['valid_records']} records imported; {stats['invalid_records']} invalid; {stats['native_traces']} trace projections; {stats['identical_duplicate_spans']} identical duplicate spans; {stats['conflicting_span_segments']} conflicting segments."
55
+ )
56
+ typer.echo(
57
+ "Source spans/evaluations are externally reported. Exported telemetry alone establishes no task-correctness gate."
58
+ )
59
+ typer.echo(f"Inventory: {inventory}")
@@ -0,0 +1,175 @@
1
+ """Receipt-only cohort observations with independent acceptance conditions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from hashlib import sha256
7
+ from typing import Any
8
+
9
+ from agentloop.integrations.harbor.trial_evidence import PAIRING_KEYS, read_trial
10
+ from agentloop.interoperability.validation import ImportValidationError, validate_json_tree
11
+ from agentloop.interventions import canonical_json
12
+
13
+ COHORT_KEY = "agentloop.external_cohort"
14
+ COHORT_PAIRING_KEYS = (*PAIRING_KEYS, "task_id", "verifier_config_hash")
15
+
16
+
17
+ def _fail(reason: str) -> None:
18
+ raise ImportValidationError("invalid_cohort_evidence", COHORT_KEY, reason)
19
+
20
+
21
+ def acceptance(
22
+ outcome: dict,
23
+ *,
24
+ import_status: str,
25
+ trajectory_available: bool,
26
+ usage_available: bool,
27
+ missingness_policy: str,
28
+ ) -> bool | None:
29
+ """Keep recorded correctness separate from usable completed-run evidence."""
30
+ if (
31
+ outcome["execution_status"] in {"failed", "timed_out", "cancelled"}
32
+ or outcome["quality_pass"] is False
33
+ ):
34
+ return False
35
+ complete = (
36
+ import_status == "imported"
37
+ and trajectory_available
38
+ and usage_available
39
+ and outcome["execution_status"] == "completed"
40
+ and outcome["verifier_status"] == "available"
41
+ )
42
+ if not complete or outcome["quality_pass"] is None:
43
+ return False if missingness_policy == "reject" else None
44
+ return True
45
+
46
+
47
+ def cohort_record(
48
+ *,
49
+ source_receipt: dict | None,
50
+ import_status: str,
51
+ trajectory_available: bool,
52
+ usage_available: bool,
53
+ missingness_policy: str,
54
+ condition: str,
55
+ task_id: str | None,
56
+ verifier_config_hash: str | None,
57
+ ) -> dict:
58
+ value = {
59
+ "schema_version": "1.0",
60
+ "source_receipt_id": source_receipt["receipt_id"] if source_receipt else None,
61
+ "source_receipt_sha256": sha256(canonical_json(source_receipt).encode()).hexdigest()
62
+ if source_receipt
63
+ else None,
64
+ "import_status": import_status,
65
+ "trajectory_available": trajectory_available,
66
+ "usage_available": usage_available,
67
+ "missingness_policy": missingness_policy,
68
+ "condition": condition,
69
+ "task_id": task_id,
70
+ "verifier_config_hash": verifier_config_hash,
71
+ }
72
+ value["sha256"] = sha256(canonical_json(value).encode()).hexdigest()
73
+ return value
74
+
75
+
76
+ def read_cohort(trace: Any) -> dict | None:
77
+ if COHORT_KEY not in trace.metadata:
78
+ return None
79
+ value = trace.metadata[COHORT_KEY]
80
+ validate_json_tree(value)
81
+ if (
82
+ not isinstance(value, dict)
83
+ or set(value)
84
+ != {
85
+ "schema_version",
86
+ "source_receipt_id",
87
+ "source_receipt_sha256",
88
+ "import_status",
89
+ "trajectory_available",
90
+ "usage_available",
91
+ "missingness_policy",
92
+ "condition",
93
+ "task_id",
94
+ "verifier_config_hash",
95
+ "sha256",
96
+ }
97
+ or value["schema_version"] != "1.0"
98
+ ):
99
+ _fail("unsupported cohort observation schema")
100
+ if (
101
+ value["sha256"]
102
+ != sha256(
103
+ canonical_json({key: item for key, item in value.items() if key != "sha256"}).encode()
104
+ ).hexdigest()
105
+ ):
106
+ _fail("cohort record changed after capture")
107
+ if (
108
+ type(value["trajectory_available"]) is not bool
109
+ or type(value["usage_available"]) is not bool
110
+ or not isinstance(value["missingness_policy"], str)
111
+ or value["missingness_policy"] not in {"indeterminate", "reject"}
112
+ ):
113
+ _fail("invalid source coverage/missingness flags")
114
+ if not isinstance(value["import_status"], str) or value["import_status"] not in {
115
+ "imported",
116
+ "invalid",
117
+ "missing",
118
+ "missing_trajectory",
119
+ }:
120
+ _fail("unsupported import status")
121
+ for key in ("condition", "task_id", "source_receipt_id"):
122
+ item = value[key]
123
+ if item is None and key != "condition":
124
+ continue
125
+ if not isinstance(item, str) or not item.strip() or len(item.encode()) > 512:
126
+ _fail("invalid cohort identity label")
127
+ for key in ("source_receipt_sha256", "verifier_config_hash"):
128
+ item = value[key]
129
+ if item is not None and (
130
+ not isinstance(item, str)
131
+ or len(item) != 64
132
+ or any(char not in "0123456789abcdef" for char in item)
133
+ ):
134
+ _fail("source/configuration hash must be SHA-256 or null")
135
+ if (value["source_receipt_id"] is None) != (value["source_receipt_sha256"] is None):
136
+ _fail("source receipt identity and digest must be present together")
137
+ if any(
138
+ trace.metadata.get(key) != value[key]
139
+ for key in ("task_id", "verifier_config_hash", "condition")
140
+ ):
141
+ _fail("source pairing labels differ from their captured record")
142
+ trial = read_trial(trace)
143
+ if trial is None:
144
+ _fail("cohort acceptance requires captured external trial evidence")
145
+ if value["source_receipt_id"] is not None and value["source_receipt_id"] != trial["receipt_id"]:
146
+ _fail("cohort receipt reference differs from captured trial source")
147
+ result = json.loads(canonical_json(value))
148
+ result["accepted"] = acceptance(
149
+ trial["outcome"],
150
+ import_status=value["import_status"],
151
+ trajectory_available=value["trajectory_available"],
152
+ usage_available=value["usage_available"],
153
+ missingness_policy=value["missingness_policy"],
154
+ )
155
+ return result
156
+
157
+
158
+ def require_cohort_pair(baseline: Any, candidate: Any) -> None:
159
+ left, right = read_cohort(baseline), read_cohort(candidate)
160
+ if left is None and right is None:
161
+ return
162
+ if left is None or right is None:
163
+ _fail("a cohort comparison requires two cohort observations")
164
+ for key in ("task_id", "verifier_config_hash"):
165
+ if baseline.metadata.get(key) is None or baseline.metadata.get(
166
+ key
167
+ ) != candidate.metadata.get(key):
168
+ _fail("task/verifier identity is missing or incompatible")
169
+ if any(
170
+ item["import_status"] != "imported"
171
+ or not item["trajectory_available"]
172
+ or not item["usage_available"]
173
+ for item in (left, right)
174
+ ):
175
+ _fail("cohort source coverage is incomplete; use the full-population study report")