techtree 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 (174) hide show
  1. techtree/__init__.py +35 -0
  2. techtree/__main__.py +14 -0
  3. techtree/canonical.py +239 -0
  4. techtree/catalog/__init__.py +25 -0
  5. techtree/catalog/repository.py +400 -0
  6. techtree/catalog/service.py +419 -0
  7. techtree/cli/__init__.py +1 -0
  8. techtree/cli/app.py +416 -0
  9. techtree/cli/commands/__init__.py +1 -0
  10. techtree/cli/commands/climb.py +1223 -0
  11. techtree/cli/commands/doctor.py +147 -0
  12. techtree/cli/commands/engine.py +207 -0
  13. techtree/cli/commands/proof.py +556 -0
  14. techtree/cli/commands/publish.py +447 -0
  15. techtree/cli/commands/release.py +303 -0
  16. techtree/cli/commands/run.py +1067 -0
  17. techtree/cli/commands/setup.py +181 -0
  18. techtree/cli/commands/skill.py +221 -0
  19. techtree/cli/commands/uplift.py +698 -0
  20. techtree/cli/commands/withdraw.py +212 -0
  21. techtree/cli/confirm.py +47 -0
  22. techtree/cli/context.py +96 -0
  23. techtree/cli/invoke.py +220 -0
  24. techtree/cli/output.py +280 -0
  25. techtree/constants.py +138 -0
  26. techtree/crypto.py +128 -0
  27. techtree/doctor/__init__.py +1 -0
  28. techtree/doctor/checks.py +675 -0
  29. techtree/doctor/execution_checks.py +435 -0
  30. techtree/doctor/service.py +326 -0
  31. techtree/drafts/__init__.py +32 -0
  32. techtree/drafts/source.py +146 -0
  33. techtree/drafts/store.py +992 -0
  34. techtree/engines/__init__.py +1 -0
  35. techtree/engines/bundle.py +251 -0
  36. techtree/engines/installer.py +679 -0
  37. techtree/engines/registry.py +235 -0
  38. techtree/engines/runner.py +170 -0
  39. techtree/errors.py +262 -0
  40. techtree/fs.py +234 -0
  41. techtree/harness.py +108 -0
  42. techtree/identity/__init__.py +41 -0
  43. techtree/identity/models.py +113 -0
  44. techtree/identity/service.py +199 -0
  45. techtree/identity/store.py +263 -0
  46. techtree/ids.py +85 -0
  47. techtree/manifests/__init__.py +39 -0
  48. techtree/manifests/builder.py +433 -0
  49. techtree/manifests/compare.py +376 -0
  50. techtree/models/__init__.py +282 -0
  51. techtree/models/base.py +201 -0
  52. techtree/models/campaign.py +484 -0
  53. techtree/models/catalog.py +227 -0
  54. techtree/models/cli.py +151 -0
  55. techtree/models/climb.py +254 -0
  56. techtree/models/data_policy.py +130 -0
  57. techtree/models/engine.py +156 -0
  58. techtree/models/episode_receipt.py +130 -0
  59. techtree/models/evaluation_backend.py +113 -0
  60. techtree/models/experiment.py +154 -0
  61. techtree/models/run.py +214 -0
  62. techtree/models/skill.py +156 -0
  63. techtree/models/uplift_report.py +158 -0
  64. techtree/models/validation.py +299 -0
  65. techtree/paths.py +116 -0
  66. techtree/presentation/__init__.py +31 -0
  67. techtree/presentation/build.py +1242 -0
  68. techtree/presentation/compact.py +246 -0
  69. techtree/presentation/evidence.py +169 -0
  70. techtree/presentation/models.py +358 -0
  71. techtree/presentation/rich.py +312 -0
  72. techtree/presentation/sanitize.py +156 -0
  73. techtree/publication/__init__.py +44 -0
  74. techtree/publication/address.py +180 -0
  75. techtree/publication/coordinates.py +26 -0
  76. techtree/publication/journal.py +212 -0
  77. techtree/publication/keccak.py +183 -0
  78. techtree/publication/models.py +209 -0
  79. techtree/publication/offer.py +35 -0
  80. techtree/publication/service.py +618 -0
  81. techtree/publication/transport.py +296 -0
  82. techtree/publication/verify.py +242 -0
  83. techtree/publication/withdraw.py +156 -0
  84. techtree/py.typed +0 -0
  85. techtree/receipts/__init__.py +52 -0
  86. techtree/receipts/bundle.py +578 -0
  87. techtree/receipts/compare.py +1065 -0
  88. techtree/receipts/episode.py +672 -0
  89. techtree/receipts/execution.py +630 -0
  90. techtree/receipts/observed.py +474 -0
  91. techtree/receipts/set.py +336 -0
  92. techtree/receipts/uplift.py +655 -0
  93. techtree/receipts/verify.py +1055 -0
  94. techtree/release/__init__.py +9 -0
  95. techtree/release/bootstrap.py +509 -0
  96. techtree/release/checks.py +376 -0
  97. techtree/release/document.py +125 -0
  98. techtree/release/generate.py +221 -0
  99. techtree/release/models.py +293 -0
  100. techtree/release/provenance.py +109 -0
  101. techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
  102. techtree/resources/catalog/catalog.json +32 -0
  103. techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
  104. techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
  105. techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
  106. techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
  107. techtree/resources/engines/default/engine.json +20 -0
  108. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
  109. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
  110. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
  111. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
  112. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
  113. techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
  114. techtree/resources/engines/default/pyproject.toml +23 -0
  115. techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
  116. techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
  117. techtree/resources/engines/default/tools/normalize_validation.py +222 -0
  118. techtree/resources/engines/default/uv.lock +1758 -0
  119. techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
  120. techtree/resources/release/build-provenance.json +4 -0
  121. techtree/resources/release/release-core.json +24 -0
  122. techtree/runs/__init__.py +31 -0
  123. techtree/runs/artifacts.py +750 -0
  124. techtree/runs/child_registry.py +228 -0
  125. techtree/runs/events.py +478 -0
  126. techtree/runs/executor.py +140 -0
  127. techtree/runs/fake.py +741 -0
  128. techtree/runs/launcher.py +253 -0
  129. techtree/runs/machine.py +489 -0
  130. techtree/runs/real.py +789 -0
  131. techtree/runs/service.py +616 -0
  132. techtree/runs/store.py +555 -0
  133. techtree/runs/validation.py +259 -0
  134. techtree/runs/variants.py +684 -0
  135. techtree/settings.py +143 -0
  136. techtree/skills/__init__.py +14 -0
  137. techtree/skills/archive.py +282 -0
  138. techtree/skills/policy.py +62 -0
  139. techtree/skills/scanner.py +394 -0
  140. techtree/skills/service.py +752 -0
  141. techtree/skills/starter.py +434 -0
  142. techtree/tasksets/__init__.py +1 -0
  143. techtree/tasksets/membership.py +269 -0
  144. techtree/tasksets/provider.py +207 -0
  145. techtree/tasksets/resolver.py +311 -0
  146. techtree/tasksets/service.py +484 -0
  147. techtree/tasksets/verifiers_cli.py +538 -0
  148. techtree/uplift/__init__.py +20 -0
  149. techtree/uplift/context.py +544 -0
  150. techtree/uplift/derive.py +203 -0
  151. techtree/uplift/public_tasks.py +151 -0
  152. techtree/uplift/service.py +719 -0
  153. techtree/uplift/source.py +160 -0
  154. techtree/verifiers/__init__.py +31 -0
  155. techtree/verifiers/budget.py +219 -0
  156. techtree/verifiers/child.py +633 -0
  157. techtree/verifiers/compiler.py +432 -0
  158. techtree/verifiers/config.py +365 -0
  159. techtree/verifiers/credentials.py +321 -0
  160. techtree/verifiers/image.py +126 -0
  161. techtree/verifiers/models.py +527 -0
  162. techtree/verifiers/outputs.py +368 -0
  163. techtree/verifiers/progress.py +192 -0
  164. techtree/verifiers/supervisor.py +341 -0
  165. techtree/verifiers/verify.py +782 -0
  166. techtree/version.py +39 -0
  167. techtree/worker/__init__.py +18 -0
  168. techtree/worker/execute.py +487 -0
  169. techtree/worker/main.py +57 -0
  170. techtree-0.1.0.dist-info/METADATA +344 -0
  171. techtree-0.1.0.dist-info/RECORD +174 -0
  172. techtree-0.1.0.dist-info/WHEEL +4 -0
  173. techtree-0.1.0.dist-info/entry_points.txt +3 -0
  174. techtree-0.1.0.dist-info/licenses/LICENSE +21 -0
techtree/runs/fake.py ADDED
@@ -0,0 +1,741 @@
1
+ """The development executor, and why nothing it produces is evidence.
2
+ Spec PR8 §8.6.
3
+
4
+ The product loop — prepare, start, detach, watch, cancel, read a result — has
5
+ to be finished and trustworthy long before there is a real evaluation behind
6
+ it. This executor is what makes that possible: it walks every phase, writes
7
+ every artifact, and produces a complete, structurally valid
8
+ :class:`~techtree.models.uplift_report.UpliftReport` without calling a model,
9
+ starting a container, or running a Verifiers evaluation.
10
+
11
+ The numbers in that report are invented. Everything in this module is arranged
12
+ so that no reader, human or machine, can mistake them for measurements:
13
+
14
+ * Every receipt reports ``execution_backend = "fake"``, and the frozen model
15
+ refuses such a receipt unless its score and evidence statuses are both
16
+ ``development_only``.
17
+ * Every receipt's subject runtime is ``not_executed``, because none was.
18
+ * The report's decision and proof grade are ``development_only``, its
19
+ publication status is ``blocked``, and it is not publication eligible. The
20
+ frozen model refuses any other combination.
21
+ * The traces are :class:`FakeTracePayload` documents under ``fake/``, which is
22
+ a local development shape and deliberately not a Verifiers Trace.
23
+
24
+ What *is* real is every check around the numbers. The staged inputs are
25
+ verified, the validation provider is consulted and its answer checked, the
26
+ receipt count and order are re-read from disk and required to match the
27
+ Campaign's committed membership, the manifest comparison is recomputed from
28
+ the staged manifests and required to equal the prepared one, and the
29
+ aggregation joins the two variants by task hash rather than by file order. A
30
+ real executor replacing this one keeps all of that and changes only where the
31
+ rewards come from.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import math
37
+ from collections.abc import Callable, Iterable, Sequence
38
+ from datetime import datetime
39
+ from typing import Final, Literal
40
+
41
+ from techtree.canonical import canonical_json_bytes, digest_object, sha256_digest_bytes
42
+ from techtree.constants import EPISODE_RECEIPT_SCHEMA_VERSION, UPLIFT_SCHEMA_VERSION
43
+ from techtree.errors import ValidationError, VerificationError
44
+ from techtree.ids import new_id
45
+ from techtree.manifests.compare import assert_controlled_comparison, compare_manifests
46
+ from techtree.models.base import ArtifactRef, Digest, JsonValue, ProtocolModel
47
+ from techtree.models.episode_receipt import (
48
+ EpisodeReceipt,
49
+ EvidenceStatus,
50
+ NamedTraceReceipt,
51
+ ScoreStatus,
52
+ SubjectRuntimeReceipt,
53
+ )
54
+ from techtree.models.evaluation_backend import SUPPORTED_EVALUATION_BACKEND_KINDS
55
+ from techtree.models.experiment import ExperimentVariant, ManifestComparison
56
+ from techtree.models.run import RunPhase, RunRequest
57
+ from techtree.models.uplift_report import (
58
+ ComparisonStatus,
59
+ ExecutionStatus,
60
+ PrimaryUpliftResult,
61
+ PublicationStatus,
62
+ TaskDelta,
63
+ UpliftDecision,
64
+ UpliftReport,
65
+ UpliftStatuses,
66
+ )
67
+ from techtree.runs.artifacts import RunInputBundle
68
+ from techtree.runs.events import (
69
+ DETAIL_CURRENT,
70
+ DETAIL_LABEL,
71
+ DETAIL_RESULT_DIGEST,
72
+ DETAIL_TOTAL,
73
+ PROGRESS_UPDATED,
74
+ RUN_COMPLETED,
75
+ )
76
+ from techtree.runs.executor import (
77
+ ExecutionContext,
78
+ interruptible_sleep,
79
+ raise_if_cancel_requested,
80
+ )
81
+ from techtree.runs.validation import TasksetValidationOutcome
82
+
83
+ __all__ = [
84
+ "FAKE_RECEIPT_INVALID",
85
+ "FAKE_REPORT_INVALID",
86
+ "FAKE_TRACE_SCHEMA_VERSION",
87
+ "SUBJECT_TRACE_ROLE",
88
+ "FakeRunExecutor",
89
+ "FakeTracePayload",
90
+ "aggregate_fake_results",
91
+ "build_development_uplift_report",
92
+ "build_fake_episode_receipt",
93
+ "default_fake_rewards",
94
+ ]
95
+
96
+ #: Stable error codes. Spec PR8 §8.16.
97
+ FAKE_RECEIPT_INVALID: Final = "fake_receipt_invalid"
98
+ FAKE_REPORT_INVALID: Final = "fake_report_invalid"
99
+
100
+ #: A local development artifact, not a Verifiers Trace. The name says v1 with
101
+ #: no ``alpha`` because it is not a protocol object and will never be
102
+ #: negotiated with anyone.
103
+ FAKE_TRACE_SCHEMA_VERSION: Final = "techtree.fake-trace.v1"
104
+
105
+ #: The one named trace role a v0.1 episode carries.
106
+ SUBJECT_TRACE_ROLE: Final = "subject"
107
+
108
+ #: What a fake success and a fake failure score. Two values, so that a delta
109
+ #: exists at all; no scientific meaning attaches to either.
110
+ _SUCCESS_REWARD: Final = 1.0
111
+ _FAILURE_REWARD: Final = 0.0
112
+
113
+ #: Fractions of the taskset the two variants "succeed" on, before the rule
114
+ #: that a candidate must out-score a baseline is applied.
115
+ _BASELINE_SUCCESS_FRACTION: Final = 0.25
116
+ _CANDIDATE_SUCCESS_FRACTION: Final = 0.85
117
+
118
+ #: How many hexadecimal characters a derived identifier carries, matching the
119
+ #: 32 that :mod:`techtree.ids` issues.
120
+ _DERIVED_ID_LENGTH: Final = 32
121
+
122
+
123
+ class FakeTracePayload(ProtocolModel):
124
+ """One invented episode's trace. A development artifact, never evidence."""
125
+
126
+ schema_version: Literal["techtree.fake-trace.v1"]
127
+ run_id: str
128
+ variant: ExperimentVariant
129
+ position: int
130
+ task_hash: Digest
131
+ reward_name: str
132
+ reward: float
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Reward vectors
137
+ # ---------------------------------------------------------------------------
138
+
139
+
140
+ def default_fake_rewards(task_count: int) -> tuple[list[float], list[float]]:
141
+ """Return the baseline and candidate reward vectors for a taskset.
142
+
143
+ Successes occupy the first positions so that a fixture is deterministic
144
+ and a failing assertion is readable. The positions carry no meaning: the
145
+ tasks are not ordered by difficulty, and nothing about task *n* makes it
146
+ the one a baseline gets right.
147
+ """
148
+ if task_count < 0:
149
+ raise ValidationError(
150
+ f"a taskset does not have {task_count} tasks",
151
+ code=FAKE_RECEIPT_INVALID,
152
+ details={"task_count": task_count},
153
+ )
154
+ if task_count == 0:
155
+ return [], []
156
+
157
+ baseline_successes = max(math.floor(task_count * _BASELINE_SUCCESS_FRACTION), 1)
158
+ candidate_successes = min(
159
+ max(
160
+ math.floor(task_count * _CANDIDATE_SUCCESS_FRACTION),
161
+ baseline_successes + 1,
162
+ ),
163
+ task_count,
164
+ )
165
+ return (
166
+ _reward_vector(task_count, baseline_successes),
167
+ _reward_vector(task_count, candidate_successes),
168
+ )
169
+
170
+
171
+ def _reward_vector(task_count: int, successes: int) -> list[float]:
172
+ return [
173
+ _SUCCESS_REWARD if position < successes else _FAILURE_REWARD
174
+ for position in range(task_count)
175
+ ]
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Receipts
180
+ # ---------------------------------------------------------------------------
181
+
182
+
183
+ def build_fake_episode_receipt(
184
+ *,
185
+ request: RunRequest,
186
+ inputs: RunInputBundle,
187
+ variant: ExperimentVariant,
188
+ position: int,
189
+ task_hash: Digest,
190
+ reward: float,
191
+ trace_artifact: ArtifactRef,
192
+ ) -> EpisodeReceipt:
193
+ """Build one receipt carrying every generic Campaign reference exactly.
194
+
195
+ Nothing here is derived, defaulted, or re-resolved: the Campaign, the
196
+ improvement program, the public context, the DataPolicy, and the
197
+ OutcomeContract all come from the immutable request, and the evaluation
198
+ backend from the Campaign this run owns. A future real executor writes the
199
+ same fields from the same places.
200
+ """
201
+ campaign = inputs.campaign
202
+ if campaign.evaluation_backend != request.evaluation_backend:
203
+ raise VerificationError(
204
+ "the Campaign this run owns names a different evaluation backend "
205
+ "than the run's request",
206
+ code=FAKE_RECEIPT_INVALID,
207
+ details={"run_id": request.run_id},
208
+ )
209
+
210
+ trace_digest = trace_artifact.digest
211
+ episode_digest = _episode_digest(
212
+ run_id=request.run_id,
213
+ variant=variant,
214
+ position=position,
215
+ task_hash=task_hash,
216
+ trace_digest=trace_digest,
217
+ )
218
+
219
+ return EpisodeReceipt(
220
+ schema_version=EPISODE_RECEIPT_SCHEMA_VERSION,
221
+ id=_derived_id("receipt", episode_digest),
222
+ run_id=request.run_id,
223
+ campaign_spec_digest=request.campaign_spec_digest,
224
+ program_ref=request.program_ref,
225
+ public_context=request.public_context,
226
+ data_policy_digest=request.data_policy_digest,
227
+ outcome_contract_digest=request.outcome_contract_digest,
228
+ evaluation_backend=campaign.evaluation_backend,
229
+ # No subject ran. Anything else here would be the single most
230
+ # misleading field a fake receipt could carry.
231
+ subject_runtime=SubjectRuntimeReceipt(kind="not_executed"),
232
+ variant=variant,
233
+ experiment_manifest_digest=_manifest_digest(request, variant),
234
+ episode_id=_derived_id("episode", episode_digest),
235
+ episode_digest=episode_digest,
236
+ task_hash=task_hash,
237
+ named_traces={
238
+ SUBJECT_TRACE_ROLE: [
239
+ NamedTraceReceipt(
240
+ role=SUBJECT_TRACE_ROLE,
241
+ trace_id=_derived_id("trace", trace_digest),
242
+ trace_digest=trace_digest,
243
+ task_hash=task_hash,
244
+ rewards={campaign.scoring.primary_reward: reward},
245
+ metrics={},
246
+ ok=True,
247
+ )
248
+ ]
249
+ },
250
+ score_status=ScoreStatus.DEVELOPMENT_ONLY,
251
+ evidence_status=EvidenceStatus.DEVELOPMENT_ONLY,
252
+ execution_backend="fake",
253
+ artifacts=[trace_artifact],
254
+ )
255
+
256
+
257
+ def _manifest_digest(request: RunRequest, variant: ExperimentVariant) -> Digest:
258
+ if variant is ExperimentVariant.BASELINE:
259
+ return request.baseline_manifest_digest
260
+ return request.candidate_manifest_digest
261
+
262
+
263
+ def _episode_digest(
264
+ *,
265
+ run_id: str,
266
+ variant: ExperimentVariant,
267
+ position: int,
268
+ task_hash: Digest,
269
+ trace_digest: Digest,
270
+ ) -> Digest:
271
+ """Return a content digest for one invented episode."""
272
+ return sha256_digest_bytes(
273
+ canonical_json_bytes(
274
+ {
275
+ "run_id": run_id,
276
+ "variant": variant.value,
277
+ "position": position,
278
+ "task_hash": task_hash,
279
+ "trace_digest": trace_digest,
280
+ }
281
+ )
282
+ )
283
+
284
+
285
+ def _derived_id(prefix: str, digest: Digest) -> str:
286
+ """Return a deterministic identifier in the shape ids.py issues."""
287
+ _, _, hexadecimal = digest.partition(":")
288
+ return f"{prefix}_{hexadecimal[:_DERIVED_ID_LENGTH]}"
289
+
290
+
291
+ # ---------------------------------------------------------------------------
292
+ # Aggregation
293
+ # ---------------------------------------------------------------------------
294
+
295
+
296
+ def aggregate_fake_results(
297
+ *,
298
+ reward_name: str,
299
+ baseline: Sequence[EpisodeReceipt],
300
+ candidate: Sequence[EpisodeReceipt],
301
+ ordered_task_hashes: Sequence[Digest],
302
+ ) -> tuple[PrimaryUpliftResult, list[TaskDelta]]:
303
+ """Join the two variants by task hash and compute the headline result.
304
+
305
+ The join is by task hash and never by position in a list. Two variants
306
+ that happened to write their receipts in different orders would still be
307
+ compared task for task, and a variant missing a task is a failure rather
308
+ than a shorter list.
309
+ """
310
+ baseline_rewards = _rewards_by_task(baseline, reward_name, "baseline")
311
+ candidate_rewards = _rewards_by_task(candidate, reward_name, "candidate")
312
+ committed = list(ordered_task_hashes)
313
+
314
+ _require_membership(baseline_rewards.keys(), committed, "baseline")
315
+ _require_membership(candidate_rewards.keys(), committed, "candidate")
316
+
317
+ deltas = [
318
+ TaskDelta(
319
+ task_hash=task_hash,
320
+ baseline_reward=baseline_rewards[task_hash],
321
+ candidate_reward=candidate_rewards[task_hash],
322
+ delta=candidate_rewards[task_hash] - baseline_rewards[task_hash],
323
+ )
324
+ for task_hash in committed
325
+ ]
326
+
327
+ baseline_mean = _mean(delta.baseline_reward for delta in deltas)
328
+ candidate_mean = _mean(delta.candidate_reward for delta in deltas)
329
+ absolute = candidate_mean - baseline_mean
330
+
331
+ return (
332
+ PrimaryUpliftResult(
333
+ reward_name=reward_name,
334
+ baseline_mean=baseline_mean,
335
+ candidate_mean=candidate_mean,
336
+ absolute_delta=absolute,
337
+ # A relative improvement over nothing is not a number, and
338
+ # reporting zero or infinity would each be a different lie.
339
+ relative_delta=None if baseline_mean == 0.0 else absolute / baseline_mean,
340
+ wins=sum(1 for delta in deltas if delta.delta > 0),
341
+ losses=sum(1 for delta in deltas if delta.delta < 0),
342
+ ties=sum(1 for delta in deltas if delta.delta == 0),
343
+ ),
344
+ deltas,
345
+ )
346
+
347
+
348
+ def _rewards_by_task(
349
+ receipts: Sequence[EpisodeReceipt],
350
+ reward_name: str,
351
+ label: str,
352
+ ) -> dict[Digest, float]:
353
+ rewards: dict[Digest, float] = {}
354
+ for receipt in receipts:
355
+ traces = receipt.named_traces.get(SUBJECT_TRACE_ROLE, [])
356
+ if len(traces) != 1:
357
+ raise VerificationError(
358
+ f"a {label} receipt carries {len(traces)} subject traces; one "
359
+ "episode has exactly one",
360
+ code=FAKE_RECEIPT_INVALID,
361
+ details={"task_hash": receipt.task_hash},
362
+ )
363
+ trace = traces[0]
364
+ if trace.task_hash != receipt.task_hash:
365
+ raise VerificationError(
366
+ f"a {label} receipt's trace was recorded against a different "
367
+ "task than the receipt",
368
+ code=FAKE_RECEIPT_INVALID,
369
+ details={"task_hash": receipt.task_hash},
370
+ )
371
+ if reward_name not in trace.rewards:
372
+ raise VerificationError(
373
+ f"a {label} receipt records no {reward_name} reward, which is "
374
+ "the reward the Campaign is scored on",
375
+ code=FAKE_RECEIPT_INVALID,
376
+ details={"task_hash": receipt.task_hash, "reward": reward_name},
377
+ )
378
+ if receipt.task_hash in rewards:
379
+ raise VerificationError(
380
+ f"the {label} variant scored one task twice",
381
+ code=FAKE_RECEIPT_INVALID,
382
+ details={"task_hash": receipt.task_hash},
383
+ )
384
+ rewards[receipt.task_hash] = trace.rewards[reward_name]
385
+ return rewards
386
+
387
+
388
+ def _require_membership(
389
+ scored: Iterable[Digest],
390
+ committed: Sequence[Digest],
391
+ label: str,
392
+ ) -> None:
393
+ scored_set = set(scored)
394
+ committed_set = set(committed)
395
+ if scored_set == committed_set:
396
+ return
397
+ missing: list[JsonValue] = [item for item in sorted(committed_set - scored_set)]
398
+ unexpected: list[JsonValue] = [item for item in sorted(scored_set - committed_set)]
399
+ raise VerificationError(
400
+ f"the {label} variant scored a different set of tasks than the "
401
+ "Campaign commits to",
402
+ code=FAKE_RECEIPT_INVALID,
403
+ details={"missing": missing, "unexpected": unexpected},
404
+ )
405
+
406
+
407
+ def _mean(values: Iterable[float]) -> float:
408
+ collected = list(values)
409
+ if not collected:
410
+ return 0.0
411
+ return sum(collected) / len(collected)
412
+
413
+
414
+ # ---------------------------------------------------------------------------
415
+ # The report
416
+ # ---------------------------------------------------------------------------
417
+
418
+
419
+ def build_development_uplift_report(
420
+ *,
421
+ request: RunRequest,
422
+ inputs: RunInputBundle,
423
+ validation: TasksetValidationOutcome,
424
+ baseline_receipts: Sequence[EpisodeReceipt],
425
+ candidate_receipts: Sequence[EpisodeReceipt],
426
+ comparison: ManifestComparison,
427
+ created_at: datetime,
428
+ ) -> UpliftReport:
429
+ """Build the one report a fake run is allowed to produce."""
430
+ primary, deltas = aggregate_fake_results(
431
+ reward_name=inputs.campaign.scoring.primary_reward,
432
+ baseline=baseline_receipts,
433
+ candidate=candidate_receipts,
434
+ ordered_task_hashes=inputs.ordered_task_hashes,
435
+ )
436
+
437
+ try:
438
+ return UpliftReport(
439
+ schema_version=UPLIFT_SCHEMA_VERSION,
440
+ id=new_id("uplift"),
441
+ run_id=request.run_id,
442
+ campaign_spec_digest=request.campaign_spec_digest,
443
+ program_ref=request.program_ref,
444
+ public_context=request.public_context,
445
+ data_policy_digest=request.data_policy_digest,
446
+ outcome_contract_digest=request.outcome_contract_digest,
447
+ evaluation_backend=inputs.campaign.evaluation_backend,
448
+ taskset_validation_receipt_digest=digest_object(validation.receipt),
449
+ baseline_manifest_digest=request.baseline_manifest_digest,
450
+ candidate_manifest_digest=request.candidate_manifest_digest,
451
+ statuses=UpliftStatuses(
452
+ # The run really did complete; only what it measured is fake,
453
+ # and the other four statuses are where that is stated.
454
+ execution=ExecutionStatus.COMPLETED,
455
+ score=ScoreStatus.DEVELOPMENT_ONLY,
456
+ evidence=EvidenceStatus.DEVELOPMENT_ONLY,
457
+ comparison=ComparisonStatus.DEVELOPMENT_ONLY,
458
+ publication=PublicationStatus.BLOCKED,
459
+ ),
460
+ manifest_comparison=comparison,
461
+ primary_result=primary,
462
+ task_deltas=deltas,
463
+ decision=UpliftDecision.DEVELOPMENT_ONLY,
464
+ proof_grade="development_only",
465
+ publication_eligible=False,
466
+ created_at=created_at,
467
+ )
468
+ except ValueError as error:
469
+ raise ValidationError(
470
+ f"the development report is not a valid report: {error}",
471
+ code=FAKE_REPORT_INVALID,
472
+ details={"run_id": request.run_id},
473
+ ) from error
474
+
475
+
476
+ # ---------------------------------------------------------------------------
477
+ # The executor
478
+ # ---------------------------------------------------------------------------
479
+
480
+
481
+ class FakeRunExecutor:
482
+ """Walks a run through every phase without evaluating anything."""
483
+
484
+ def __init__(
485
+ self,
486
+ *,
487
+ step_delay_seconds: float = 0.1,
488
+ baseline_rewards: Sequence[float] | None = None,
489
+ candidate_rewards: Sequence[float] | None = None,
490
+ ) -> None:
491
+ if step_delay_seconds < 0:
492
+ raise ValidationError(
493
+ "a step delay is not negative",
494
+ details={"step_delay_seconds": step_delay_seconds},
495
+ )
496
+ self._step_delay = step_delay_seconds
497
+ self._baseline_rewards = (
498
+ None if baseline_rewards is None else list(baseline_rewards)
499
+ )
500
+ self._candidate_rewards = (
501
+ None if candidate_rewards is None else list(candidate_rewards)
502
+ )
503
+
504
+ def execute(self, context: ExecutionContext) -> UpliftReport:
505
+ """Execute the full development-only flow."""
506
+ request = context.request
507
+ run_id = request.run_id
508
+ store = context.run_store
509
+
510
+ inputs = context.artifact_store.load_inputs(run_id, request)
511
+ self._require_fake_executor(request)
512
+ self._require_supported_backend(inputs)
513
+
514
+ def check() -> None:
515
+ raise_if_cancel_requested(store, run_id)
516
+
517
+ store.append(run_id, phase=RunPhase.VALIDATING_TASKSET)
518
+ validation = context.validation_provider.validate(run_id=run_id, inputs=inputs)
519
+ context.artifact_store.write_validation_marker(
520
+ run_id, validation.marker_document()
521
+ )
522
+ self._require_valid_validation(inputs, validation)
523
+ check()
524
+
525
+ task_hashes = inputs.ordered_task_hashes
526
+ baseline_rewards, candidate_rewards = self._reward_vectors(len(task_hashes))
527
+
528
+ store.append(run_id, phase=RunPhase.RUNNING_BASELINE)
529
+ self._score_variant(
530
+ context,
531
+ inputs=inputs,
532
+ variant=ExperimentVariant.BASELINE,
533
+ task_hashes=task_hashes,
534
+ rewards=baseline_rewards,
535
+ check=check,
536
+ )
537
+
538
+ store.append(run_id, phase=RunPhase.RUNNING_CANDIDATE)
539
+ self._score_variant(
540
+ context,
541
+ inputs=inputs,
542
+ variant=ExperimentVariant.CANDIDATE,
543
+ task_hashes=task_hashes,
544
+ rewards=candidate_rewards,
545
+ check=check,
546
+ )
547
+
548
+ store.append(run_id, phase=RunPhase.BUILDING_RECEIPTS)
549
+ baseline_receipts = context.artifact_store.episode_receipts(
550
+ run_id, ExperimentVariant.BASELINE
551
+ )
552
+ candidate_receipts = context.artifact_store.episode_receipts(
553
+ run_id, ExperimentVariant.CANDIDATE
554
+ )
555
+ self._require_receipts(baseline_receipts, task_hashes, "baseline")
556
+ self._require_receipts(candidate_receipts, task_hashes, "candidate")
557
+ check()
558
+
559
+ store.append(run_id, phase=RunPhase.VERIFYING_COMPARISON)
560
+ comparison = self._recompute_comparison(inputs)
561
+ check()
562
+
563
+ store.append(run_id, phase=RunPhase.BUILDING_REPORT)
564
+ report = build_development_uplift_report(
565
+ request=request,
566
+ inputs=inputs,
567
+ validation=validation,
568
+ baseline_receipts=baseline_receipts,
569
+ candidate_receipts=candidate_receipts,
570
+ comparison=comparison,
571
+ created_at=context.clock(),
572
+ )
573
+ store.write_result(run_id, report)
574
+ store.append(
575
+ run_id,
576
+ phase=RunPhase.COMPLETED,
577
+ kind=RUN_COMPLETED,
578
+ details={DETAIL_RESULT_DIGEST: digest_object(report)},
579
+ )
580
+ return report
581
+
582
+ # -- phases -------------------------------------------------------------
583
+
584
+ def _score_variant(
585
+ self,
586
+ context: ExecutionContext,
587
+ *,
588
+ inputs: RunInputBundle,
589
+ variant: ExperimentVariant,
590
+ task_hashes: Sequence[Digest],
591
+ rewards: Sequence[float],
592
+ check: Callable[[], None],
593
+ ) -> None:
594
+ """Invent one reward per committed task, in Campaign order."""
595
+ run_id = context.request.run_id
596
+ reward_name = inputs.campaign.scoring.primary_reward
597
+ total = len(task_hashes)
598
+
599
+ for position, task_hash in enumerate(task_hashes):
600
+ check()
601
+ trace = context.artifact_store.write_fake_trace(
602
+ run_id,
603
+ variant=variant,
604
+ position=position,
605
+ payload=FakeTracePayload(
606
+ schema_version=FAKE_TRACE_SCHEMA_VERSION,
607
+ run_id=run_id,
608
+ variant=variant,
609
+ position=position,
610
+ task_hash=task_hash,
611
+ reward_name=reward_name,
612
+ reward=rewards[position],
613
+ ),
614
+ )
615
+ context.artifact_store.write_episode_receipt(
616
+ run_id,
617
+ position=position,
618
+ receipt=build_fake_episode_receipt(
619
+ request=context.request,
620
+ inputs=inputs,
621
+ variant=variant,
622
+ position=position,
623
+ task_hash=task_hash,
624
+ reward=rewards[position],
625
+ trace_artifact=trace,
626
+ ),
627
+ )
628
+ # phase=None: cancellation may land between the receipt write and
629
+ # this append, so the store, not this executor, names the phase.
630
+ context.run_store.append(
631
+ run_id,
632
+ phase=None,
633
+ kind=PROGRESS_UPDATED,
634
+ details={
635
+ DETAIL_CURRENT: position + 1,
636
+ DETAIL_TOTAL: total,
637
+ DETAIL_LABEL: f"{variant.value} episodes",
638
+ },
639
+ )
640
+ interruptible_sleep(seconds=self._step_delay, check=check)
641
+
642
+ def _recompute_comparison(self, inputs: RunInputBundle) -> ManifestComparison:
643
+ """Recompute the comparison and require it to equal the prepared one."""
644
+ comparison = compare_manifests(
645
+ inputs.baseline,
646
+ inputs.candidate,
647
+ inputs.campaign.mutation_contract,
648
+ )
649
+ assert_controlled_comparison(comparison)
650
+ if comparison != inputs.comparison:
651
+ raise VerificationError(
652
+ "the comparison recomputed from this run's own manifests is "
653
+ "not the comparison the draft was prepared with",
654
+ code=FAKE_REPORT_INVALID,
655
+ details={"run_id": inputs.request.run_id},
656
+ )
657
+ return comparison
658
+
659
+ # -- preconditions ------------------------------------------------------
660
+
661
+ def _require_fake_executor(self, request: RunRequest) -> None:
662
+ if request.executor_kind != "fake":
663
+ raise ValidationError(
664
+ f"this run asks for a {request.executor_kind} executor, and "
665
+ "this is the fake one",
666
+ code=FAKE_REPORT_INVALID,
667
+ details={"run_id": request.run_id},
668
+ )
669
+
670
+ def _require_supported_backend(self, inputs: RunInputBundle) -> None:
671
+ kind = inputs.campaign.evaluation_backend.kind
672
+ if kind not in SUPPORTED_EVALUATION_BACKEND_KINDS:
673
+ raise ValidationError(
674
+ f"this Campaign is evaluated by {kind.value}, which this build "
675
+ "does not run",
676
+ code=FAKE_REPORT_INVALID,
677
+ details={
678
+ "run_id": inputs.request.run_id,
679
+ "evaluation_backend": kind.value,
680
+ },
681
+ )
682
+
683
+ def _require_valid_validation(
684
+ self,
685
+ inputs: RunInputBundle,
686
+ validation: TasksetValidationOutcome,
687
+ ) -> None:
688
+ """Refuse to score a taskset the provider did not vouch for."""
689
+ if validation.receipt.status != "valid":
690
+ raise VerificationError(
691
+ "the taskset validation this run was given reports "
692
+ f"{validation.receipt.status}, so nothing may be scored on it",
693
+ code=FAKE_REPORT_INVALID,
694
+ details={
695
+ "run_id": inputs.request.run_id,
696
+ "status": validation.receipt.status,
697
+ },
698
+ )
699
+ committed = inputs.ordered_task_hashes
700
+ if validation.lock.ordered_task_hashes != committed:
701
+ raise VerificationError(
702
+ "the validated taskset does not hold the tasks the Campaign commits to",
703
+ code=FAKE_REPORT_INVALID,
704
+ details={"run_id": inputs.request.run_id},
705
+ )
706
+
707
+ def _require_receipts(
708
+ self,
709
+ receipts: Sequence[EpisodeReceipt],
710
+ task_hashes: Sequence[Digest],
711
+ label: str,
712
+ ) -> None:
713
+ """Require the receipts on disk to be the Campaign's tasks, in order."""
714
+ found = [receipt.task_hash for receipt in receipts]
715
+ if found != list(task_hashes):
716
+ expected_detail: list[JsonValue] = [item for item in task_hashes]
717
+ found_detail: list[JsonValue] = [item for item in found]
718
+ raise VerificationError(
719
+ f"the {label} receipts on disk are not this Campaign's tasks "
720
+ "in the order it commits to them",
721
+ code=FAKE_RECEIPT_INVALID,
722
+ details={"expected": expected_detail, "found": found_detail},
723
+ )
724
+
725
+ def _reward_vectors(self, task_count: int) -> tuple[list[float], list[float]]:
726
+ """Return the injected reward vectors, or the default ones."""
727
+ if self._baseline_rewards is None or self._candidate_rewards is None:
728
+ return default_fake_rewards(task_count)
729
+
730
+ for label, vector in (
731
+ ("baseline", self._baseline_rewards),
732
+ ("candidate", self._candidate_rewards),
733
+ ):
734
+ if len(vector) != task_count:
735
+ raise ValidationError(
736
+ f"the injected {label} rewards cover {len(vector)} tasks "
737
+ f"and this Campaign commits to {task_count}",
738
+ code=FAKE_RECEIPT_INVALID,
739
+ details={"task_count": task_count, "rewards": len(vector)},
740
+ )
741
+ return list(self._baseline_rewards), list(self._candidate_rewards)