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.
- techtree/__init__.py +35 -0
- techtree/__main__.py +14 -0
- techtree/canonical.py +239 -0
- techtree/catalog/__init__.py +25 -0
- techtree/catalog/repository.py +400 -0
- techtree/catalog/service.py +419 -0
- techtree/cli/__init__.py +1 -0
- techtree/cli/app.py +416 -0
- techtree/cli/commands/__init__.py +1 -0
- techtree/cli/commands/climb.py +1223 -0
- techtree/cli/commands/doctor.py +147 -0
- techtree/cli/commands/engine.py +207 -0
- techtree/cli/commands/proof.py +556 -0
- techtree/cli/commands/publish.py +447 -0
- techtree/cli/commands/release.py +303 -0
- techtree/cli/commands/run.py +1067 -0
- techtree/cli/commands/setup.py +181 -0
- techtree/cli/commands/skill.py +221 -0
- techtree/cli/commands/uplift.py +698 -0
- techtree/cli/commands/withdraw.py +212 -0
- techtree/cli/confirm.py +47 -0
- techtree/cli/context.py +96 -0
- techtree/cli/invoke.py +220 -0
- techtree/cli/output.py +280 -0
- techtree/constants.py +138 -0
- techtree/crypto.py +128 -0
- techtree/doctor/__init__.py +1 -0
- techtree/doctor/checks.py +675 -0
- techtree/doctor/execution_checks.py +435 -0
- techtree/doctor/service.py +326 -0
- techtree/drafts/__init__.py +32 -0
- techtree/drafts/source.py +146 -0
- techtree/drafts/store.py +992 -0
- techtree/engines/__init__.py +1 -0
- techtree/engines/bundle.py +251 -0
- techtree/engines/installer.py +679 -0
- techtree/engines/registry.py +235 -0
- techtree/engines/runner.py +170 -0
- techtree/errors.py +262 -0
- techtree/fs.py +234 -0
- techtree/harness.py +108 -0
- techtree/identity/__init__.py +41 -0
- techtree/identity/models.py +113 -0
- techtree/identity/service.py +199 -0
- techtree/identity/store.py +263 -0
- techtree/ids.py +85 -0
- techtree/manifests/__init__.py +39 -0
- techtree/manifests/builder.py +433 -0
- techtree/manifests/compare.py +376 -0
- techtree/models/__init__.py +282 -0
- techtree/models/base.py +201 -0
- techtree/models/campaign.py +484 -0
- techtree/models/catalog.py +227 -0
- techtree/models/cli.py +151 -0
- techtree/models/climb.py +254 -0
- techtree/models/data_policy.py +130 -0
- techtree/models/engine.py +156 -0
- techtree/models/episode_receipt.py +130 -0
- techtree/models/evaluation_backend.py +113 -0
- techtree/models/experiment.py +154 -0
- techtree/models/run.py +214 -0
- techtree/models/skill.py +156 -0
- techtree/models/uplift_report.py +158 -0
- techtree/models/validation.py +299 -0
- techtree/paths.py +116 -0
- techtree/presentation/__init__.py +31 -0
- techtree/presentation/build.py +1242 -0
- techtree/presentation/compact.py +246 -0
- techtree/presentation/evidence.py +169 -0
- techtree/presentation/models.py +358 -0
- techtree/presentation/rich.py +312 -0
- techtree/presentation/sanitize.py +156 -0
- techtree/publication/__init__.py +44 -0
- techtree/publication/address.py +180 -0
- techtree/publication/coordinates.py +26 -0
- techtree/publication/journal.py +212 -0
- techtree/publication/keccak.py +183 -0
- techtree/publication/models.py +209 -0
- techtree/publication/offer.py +35 -0
- techtree/publication/service.py +618 -0
- techtree/publication/transport.py +296 -0
- techtree/publication/verify.py +242 -0
- techtree/publication/withdraw.py +156 -0
- techtree/py.typed +0 -0
- techtree/receipts/__init__.py +52 -0
- techtree/receipts/bundle.py +578 -0
- techtree/receipts/compare.py +1065 -0
- techtree/receipts/episode.py +672 -0
- techtree/receipts/execution.py +630 -0
- techtree/receipts/observed.py +474 -0
- techtree/receipts/set.py +336 -0
- techtree/receipts/uplift.py +655 -0
- techtree/receipts/verify.py +1055 -0
- techtree/release/__init__.py +9 -0
- techtree/release/bootstrap.py +509 -0
- techtree/release/checks.py +376 -0
- techtree/release/document.py +125 -0
- techtree/release/generate.py +221 -0
- techtree/release/models.py +293 -0
- techtree/release/provenance.py +109 -0
- techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
- techtree/resources/catalog/catalog.json +32 -0
- techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
- techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
- techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
- techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
- techtree/resources/engines/default/engine.json +20 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
- techtree/resources/engines/default/pyproject.toml +23 -0
- techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
- techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
- techtree/resources/engines/default/tools/normalize_validation.py +222 -0
- techtree/resources/engines/default/uv.lock +1758 -0
- techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
- techtree/resources/release/build-provenance.json +4 -0
- techtree/resources/release/release-core.json +24 -0
- techtree/runs/__init__.py +31 -0
- techtree/runs/artifacts.py +750 -0
- techtree/runs/child_registry.py +228 -0
- techtree/runs/events.py +478 -0
- techtree/runs/executor.py +140 -0
- techtree/runs/fake.py +741 -0
- techtree/runs/launcher.py +253 -0
- techtree/runs/machine.py +489 -0
- techtree/runs/real.py +789 -0
- techtree/runs/service.py +616 -0
- techtree/runs/store.py +555 -0
- techtree/runs/validation.py +259 -0
- techtree/runs/variants.py +684 -0
- techtree/settings.py +143 -0
- techtree/skills/__init__.py +14 -0
- techtree/skills/archive.py +282 -0
- techtree/skills/policy.py +62 -0
- techtree/skills/scanner.py +394 -0
- techtree/skills/service.py +752 -0
- techtree/skills/starter.py +434 -0
- techtree/tasksets/__init__.py +1 -0
- techtree/tasksets/membership.py +269 -0
- techtree/tasksets/provider.py +207 -0
- techtree/tasksets/resolver.py +311 -0
- techtree/tasksets/service.py +484 -0
- techtree/tasksets/verifiers_cli.py +538 -0
- techtree/uplift/__init__.py +20 -0
- techtree/uplift/context.py +544 -0
- techtree/uplift/derive.py +203 -0
- techtree/uplift/public_tasks.py +151 -0
- techtree/uplift/service.py +719 -0
- techtree/uplift/source.py +160 -0
- techtree/verifiers/__init__.py +31 -0
- techtree/verifiers/budget.py +219 -0
- techtree/verifiers/child.py +633 -0
- techtree/verifiers/compiler.py +432 -0
- techtree/verifiers/config.py +365 -0
- techtree/verifiers/credentials.py +321 -0
- techtree/verifiers/image.py +126 -0
- techtree/verifiers/models.py +527 -0
- techtree/verifiers/outputs.py +368 -0
- techtree/verifiers/progress.py +192 -0
- techtree/verifiers/supervisor.py +341 -0
- techtree/verifiers/verify.py +782 -0
- techtree/version.py +39 -0
- techtree/worker/__init__.py +18 -0
- techtree/worker/execute.py +487 -0
- techtree/worker/main.py +57 -0
- techtree-0.1.0.dist-info/METADATA +344 -0
- techtree-0.1.0.dist-info/RECORD +174 -0
- techtree-0.1.0.dist-info/WHEEL +4 -0
- techtree-0.1.0.dist-info/entry_points.txt +3 -0
- 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)
|