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
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""Deriving the two experiment variants from one Campaign. Spec PR6 §6.4.
|
|
2
|
+
|
|
3
|
+
The Campaign is the experiment. A manifest is the Campaign with the one open
|
|
4
|
+
choice made — which skill the subject harness carries — and nothing else decided
|
|
5
|
+
differently. Everything in this module exists to keep that sentence literally
|
|
6
|
+
true.
|
|
7
|
+
|
|
8
|
+
Four rules make it true rather than merely intended.
|
|
9
|
+
|
|
10
|
+
*Copy, never re-derive.* The taskset, environment, agents, mutation contract,
|
|
11
|
+
evaluation backend, execution plan, scoring rule, evidence requirements,
|
|
12
|
+
budgets, DataPolicy digest, and OutcomeContract digest are copied out of the
|
|
13
|
+
Campaign exactly as they were found. Nothing is normalized, defaulted, or
|
|
14
|
+
recomputed on the way through, because a value this module produced would be a
|
|
15
|
+
value the Campaign did not commit to.
|
|
16
|
+
|
|
17
|
+
*Copy deeply.* A frozen model still holds real lists and dicts. Deep copies are
|
|
18
|
+
what stop a manifest and the Campaign it came from — or a baseline and its
|
|
19
|
+
candidate — from sharing a container a later caller could mutate.
|
|
20
|
+
|
|
21
|
+
*Carry no local path and no public policy.* A manifest is a scientific document
|
|
22
|
+
two machines must be able to produce identically, so the candidate skill enters
|
|
23
|
+
as a content address and nothing about the Climb's schedule, leaderboard, or
|
|
24
|
+
publication policy enters at all. The public wrapper is referenced through
|
|
25
|
+
``public_context`` and never folded into ``configuration``; that separation is
|
|
26
|
+
what lets :mod:`techtree.manifests.compare` compare configurations alone.
|
|
27
|
+
|
|
28
|
+
*Identify the skill by its content tree.* The harness-facing reference carries
|
|
29
|
+
``SkillArtifact.root_digest``, not the archive digest. The archive is transport
|
|
30
|
+
integrity — the bytes that happen to move the skill from here to there — while
|
|
31
|
+
the tree digest is what the experiment actually inserted. Two archives of the
|
|
32
|
+
same tree are the same science; the manifest has to say so.
|
|
33
|
+
|
|
34
|
+
Timestamps and identifiers can be injected. A test that pins both gets a
|
|
35
|
+
byte-stable manifest, which is the only way to assert on a digest.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from collections.abc import Sequence
|
|
41
|
+
from datetime import UTC, datetime
|
|
42
|
+
from typing import Final
|
|
43
|
+
|
|
44
|
+
from techtree.canonical import digest_object
|
|
45
|
+
from techtree.constants import EXPERIMENT_SCHEMA_VERSION
|
|
46
|
+
from techtree.errors import ValidationError
|
|
47
|
+
from techtree.models.base import ArtifactRef, Digest, JsonValue
|
|
48
|
+
from techtree.models.campaign import (
|
|
49
|
+
AgentSpec,
|
|
50
|
+
CampaignSpec,
|
|
51
|
+
HarnessSpec,
|
|
52
|
+
MutationKind,
|
|
53
|
+
PublicContext,
|
|
54
|
+
)
|
|
55
|
+
from techtree.models.experiment import (
|
|
56
|
+
ExperimentConfiguration,
|
|
57
|
+
ExperimentManifest,
|
|
58
|
+
ExperimentVariant,
|
|
59
|
+
)
|
|
60
|
+
from techtree.models.skill import SkillArtifact, SkillFile
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"MANIFEST_BUILD_FAILED",
|
|
64
|
+
"MANIFEST_ID_PREFIX",
|
|
65
|
+
"SKILL_MEDIA_TYPE",
|
|
66
|
+
"assert_manifest_matches_campaign",
|
|
67
|
+
"build_baseline_manifest",
|
|
68
|
+
"build_candidate_manifest",
|
|
69
|
+
"build_experiment_configuration",
|
|
70
|
+
"build_skill_reference",
|
|
71
|
+
"manifest_id_for",
|
|
72
|
+
"skill_content_digest",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
#: The media type of an instruction skill's canonical content tree. It is not
|
|
76
|
+
#: ``application/x-tar``: what the reference addresses is the tree, and the tar
|
|
77
|
+
#: is one possible encoding of it.
|
|
78
|
+
SKILL_MEDIA_TYPE: Final = "application/vnd.techtree.instruction-skill.v1"
|
|
79
|
+
|
|
80
|
+
#: Manifest identifiers are derived from the configuration digest rather than
|
|
81
|
+
#: drawn at random. Two derivations of the same experiment then name it the
|
|
82
|
+
#: same way, and an identifier can never disagree with the document it labels.
|
|
83
|
+
MANIFEST_ID_PREFIX: Final = "experiment"
|
|
84
|
+
|
|
85
|
+
#: The one error code every failure in this module reports. Spec PR6 §6.10.
|
|
86
|
+
MANIFEST_BUILD_FAILED: Final = "manifest_build_failed"
|
|
87
|
+
|
|
88
|
+
#: How much of the digest a derived identifier carries, matching the display-id
|
|
89
|
+
#: convention of decisions document 0003 A1.
|
|
90
|
+
_ID_HEX_LENGTH: Final = 24
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def build_experiment_configuration(
|
|
94
|
+
campaign: CampaignSpec,
|
|
95
|
+
) -> ExperimentConfiguration:
|
|
96
|
+
"""Copy the Campaign scientific fields into an ``ExperimentConfiguration``.
|
|
97
|
+
|
|
98
|
+
This is the baseline configuration, because a Campaign describes the subject
|
|
99
|
+
as the candidate is measured against it — with no skill for an insertion,
|
|
100
|
+
and with the skill being revised for a replacement.
|
|
101
|
+
:func:`build_candidate_manifest` produces the other variant by replacing
|
|
102
|
+
exactly one field of exactly one agent.
|
|
103
|
+
"""
|
|
104
|
+
return ExperimentConfiguration(
|
|
105
|
+
taskset=campaign.taskset.model_copy(deep=True),
|
|
106
|
+
environment=campaign.environment.model_copy(deep=True),
|
|
107
|
+
agents={
|
|
108
|
+
name: agent.model_copy(deep=True) for name, agent in campaign.agents.items()
|
|
109
|
+
},
|
|
110
|
+
mutation_contract=campaign.mutation_contract.model_copy(deep=True),
|
|
111
|
+
evaluation_backend=campaign.evaluation_backend.model_copy(deep=True),
|
|
112
|
+
execution=campaign.execution.model_copy(deep=True),
|
|
113
|
+
scoring=campaign.scoring.model_copy(deep=True),
|
|
114
|
+
evidence=campaign.evidence.model_copy(deep=True),
|
|
115
|
+
budgets=campaign.budgets.model_copy(deep=True),
|
|
116
|
+
data_policy_digest=campaign.data_policy_digest,
|
|
117
|
+
outcome_contract_digest=campaign.context.outcome_contract_digest,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def skill_content_digest(files: Sequence[SkillFile]) -> Digest:
|
|
122
|
+
"""Return the digest of a skill's canonical content tree.
|
|
123
|
+
|
|
124
|
+
The tree is exactly its sorted file list: each path, its media type, its
|
|
125
|
+
size, and the digest of its bytes. Digesting that list commits to every
|
|
126
|
+
file's content and to the shape of the directory, and to nothing about how
|
|
127
|
+
the tree was packed or where it was read from.
|
|
128
|
+
"""
|
|
129
|
+
return digest_object(list(files))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_skill_reference(skill: SkillArtifact) -> ArtifactRef:
|
|
133
|
+
"""Create the harness-facing reference to canonical skill content."""
|
|
134
|
+
total = sum(file.size for file in skill.files)
|
|
135
|
+
if total <= 0:
|
|
136
|
+
raise ValidationError(
|
|
137
|
+
"the candidate skill has no content to insert, so there is nothing "
|
|
138
|
+
"for the experiment to measure",
|
|
139
|
+
code=MANIFEST_BUILD_FAILED,
|
|
140
|
+
details={"file_count": len(skill.files), "total_bytes": total},
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
recomputed = skill_content_digest(skill.files)
|
|
144
|
+
if recomputed != skill.root_digest:
|
|
145
|
+
raise ValidationError(
|
|
146
|
+
"the candidate skill's root digest does not describe the files it lists",
|
|
147
|
+
code=MANIFEST_BUILD_FAILED,
|
|
148
|
+
details={
|
|
149
|
+
"stated_root_digest": skill.root_digest,
|
|
150
|
+
"computed_root_digest": recomputed,
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return ArtifactRef(
|
|
155
|
+
digest=skill.root_digest,
|
|
156
|
+
media_type=SKILL_MEDIA_TYPE,
|
|
157
|
+
size=total,
|
|
158
|
+
relative_path=None,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def build_baseline_manifest(
|
|
163
|
+
*,
|
|
164
|
+
campaign: CampaignSpec,
|
|
165
|
+
campaign_digest: Digest,
|
|
166
|
+
public_context: PublicContext | None,
|
|
167
|
+
created_at: datetime | None = None,
|
|
168
|
+
manifest_id: str | None = None,
|
|
169
|
+
) -> ExperimentManifest:
|
|
170
|
+
"""Build the baseline variant."""
|
|
171
|
+
configuration = build_experiment_configuration(campaign)
|
|
172
|
+
_require_campaign_baseline(configuration, campaign)
|
|
173
|
+
return finalize_manifest(
|
|
174
|
+
campaign=campaign,
|
|
175
|
+
campaign_digest=campaign_digest,
|
|
176
|
+
public_context=public_context,
|
|
177
|
+
variant=ExperimentVariant.BASELINE,
|
|
178
|
+
configuration=configuration,
|
|
179
|
+
created_at=created_at,
|
|
180
|
+
manifest_id=manifest_id,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def build_candidate_manifest(
|
|
185
|
+
*,
|
|
186
|
+
campaign: CampaignSpec,
|
|
187
|
+
campaign_digest: Digest,
|
|
188
|
+
skill: SkillArtifact,
|
|
189
|
+
public_context: PublicContext | None,
|
|
190
|
+
created_at: datetime | None = None,
|
|
191
|
+
manifest_id: str | None = None,
|
|
192
|
+
) -> ExperimentManifest:
|
|
193
|
+
"""Build the candidate variant with exactly one skill reference.
|
|
194
|
+
|
|
195
|
+
The candidate configuration is the baseline configuration with the subject
|
|
196
|
+
harness's skill list replaced. Building it by replacement rather than by a
|
|
197
|
+
second derivation from the Campaign is what guarantees no other field can
|
|
198
|
+
drift: a field this function does not name cannot differ from the baseline.
|
|
199
|
+
"""
|
|
200
|
+
configuration = build_experiment_configuration(campaign)
|
|
201
|
+
baseline_subject = _require_campaign_baseline(configuration, campaign)
|
|
202
|
+
reference = build_skill_reference(skill)
|
|
203
|
+
|
|
204
|
+
mutation = campaign.mutation_contract
|
|
205
|
+
if mutation.kind is MutationKind.SKILL_REPLACEMENT:
|
|
206
|
+
replaced = baseline_subject.harness.skills[0]
|
|
207
|
+
if replaced.digest == reference.digest:
|
|
208
|
+
raise ValidationError(
|
|
209
|
+
"the replacement skill has the same content tree as the skill it "
|
|
210
|
+
"replaces, so the pair would measure nothing",
|
|
211
|
+
code=MANIFEST_BUILD_FAILED,
|
|
212
|
+
details={
|
|
213
|
+
"baseline_root_digest": replaced.digest,
|
|
214
|
+
"candidate_root_digest": reference.digest,
|
|
215
|
+
},
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if not mutation.minimum_skills <= 1 <= mutation.maximum_skills:
|
|
219
|
+
raise ValidationError(
|
|
220
|
+
"this Campaign's mutation contract does not permit a single "
|
|
221
|
+
f"candidate skill; it allows {mutation.minimum_skills} to "
|
|
222
|
+
f"{mutation.maximum_skills}",
|
|
223
|
+
code=MANIFEST_BUILD_FAILED,
|
|
224
|
+
details={
|
|
225
|
+
"minimum_skills": mutation.minimum_skills,
|
|
226
|
+
"maximum_skills": mutation.maximum_skills,
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
target = mutation.target_agent
|
|
231
|
+
subject = configuration.agents[target]
|
|
232
|
+
with_skill = AgentSpec(
|
|
233
|
+
model=subject.model,
|
|
234
|
+
sampling=subject.sampling,
|
|
235
|
+
harness=HarnessSpec(
|
|
236
|
+
id=subject.harness.id,
|
|
237
|
+
version=subject.harness.version,
|
|
238
|
+
use_bundled_skill=subject.harness.use_bundled_skill,
|
|
239
|
+
skills=[reference],
|
|
240
|
+
),
|
|
241
|
+
runtime=subject.runtime,
|
|
242
|
+
trainable=subject.trainable,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
return finalize_manifest(
|
|
246
|
+
campaign=campaign,
|
|
247
|
+
campaign_digest=campaign_digest,
|
|
248
|
+
public_context=public_context,
|
|
249
|
+
variant=ExperimentVariant.CANDIDATE,
|
|
250
|
+
configuration=configuration.model_copy(
|
|
251
|
+
update={"agents": {**configuration.agents, target: with_skill}}
|
|
252
|
+
),
|
|
253
|
+
created_at=created_at,
|
|
254
|
+
manifest_id=manifest_id,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def finalize_manifest(
|
|
259
|
+
*,
|
|
260
|
+
campaign: CampaignSpec,
|
|
261
|
+
campaign_digest: Digest,
|
|
262
|
+
public_context: PublicContext | None,
|
|
263
|
+
variant: ExperimentVariant,
|
|
264
|
+
configuration: ExperimentConfiguration,
|
|
265
|
+
created_at: datetime | None,
|
|
266
|
+
manifest_id: str | None,
|
|
267
|
+
) -> ExperimentManifest:
|
|
268
|
+
"""Validate lineage, calculate ``configuration_digest``, and construct."""
|
|
269
|
+
if digest_object(campaign) != campaign_digest:
|
|
270
|
+
raise ValidationError(
|
|
271
|
+
"the Campaign digest a manifest was asked to carry is not the "
|
|
272
|
+
"digest of the Campaign it was built from",
|
|
273
|
+
code=MANIFEST_BUILD_FAILED,
|
|
274
|
+
details={
|
|
275
|
+
"stated_campaign_digest": campaign_digest,
|
|
276
|
+
"computed_campaign_digest": digest_object(campaign),
|
|
277
|
+
},
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
configuration_digest = digest_object(configuration)
|
|
281
|
+
manifest = ExperimentManifest(
|
|
282
|
+
schema_version=EXPERIMENT_SCHEMA_VERSION,
|
|
283
|
+
id=manifest_id or manifest_id_for(configuration_digest),
|
|
284
|
+
campaign_spec_digest=campaign_digest,
|
|
285
|
+
program_ref=(
|
|
286
|
+
None
|
|
287
|
+
if campaign.context.program_ref is None
|
|
288
|
+
else campaign.context.program_ref.model_copy(deep=True)
|
|
289
|
+
),
|
|
290
|
+
public_context=(
|
|
291
|
+
None if public_context is None else public_context.model_copy(deep=True)
|
|
292
|
+
),
|
|
293
|
+
variant=variant,
|
|
294
|
+
configuration=configuration,
|
|
295
|
+
configuration_digest=configuration_digest,
|
|
296
|
+
created_at=created_at or datetime.now(UTC),
|
|
297
|
+
)
|
|
298
|
+
assert_manifest_matches_campaign(manifest, campaign, campaign_digest)
|
|
299
|
+
return manifest
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def assert_manifest_matches_campaign(
|
|
303
|
+
manifest: ExperimentManifest,
|
|
304
|
+
campaign: CampaignSpec,
|
|
305
|
+
campaign_digest: Digest,
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Check, after the fact, that a manifest is the Campaign it claims to be.
|
|
308
|
+
|
|
309
|
+
Everything here was already true by construction when the manifest came
|
|
310
|
+
from this module. It is checked again because a manifest can also arrive
|
|
311
|
+
from disk, and because a postcondition that only holds when the code is
|
|
312
|
+
correct is not a postcondition.
|
|
313
|
+
"""
|
|
314
|
+
configuration = manifest.configuration
|
|
315
|
+
for label, expected, found in (
|
|
316
|
+
("Campaign digest", campaign_digest, manifest.campaign_spec_digest),
|
|
317
|
+
("improvement program", campaign.context.program_ref, manifest.program_ref),
|
|
318
|
+
(
|
|
319
|
+
"OutcomeContract digest",
|
|
320
|
+
campaign.context.outcome_contract_digest,
|
|
321
|
+
configuration.outcome_contract_digest,
|
|
322
|
+
),
|
|
323
|
+
(
|
|
324
|
+
"DataPolicy digest",
|
|
325
|
+
campaign.data_policy_digest,
|
|
326
|
+
configuration.data_policy_digest,
|
|
327
|
+
),
|
|
328
|
+
(
|
|
329
|
+
"evaluation backend",
|
|
330
|
+
campaign.evaluation_backend,
|
|
331
|
+
configuration.evaluation_backend,
|
|
332
|
+
),
|
|
333
|
+
("taskset", campaign.taskset, configuration.taskset),
|
|
334
|
+
("environment", campaign.environment, configuration.environment),
|
|
335
|
+
(
|
|
336
|
+
"mutation contract",
|
|
337
|
+
campaign.mutation_contract,
|
|
338
|
+
configuration.mutation_contract,
|
|
339
|
+
),
|
|
340
|
+
("execution plan", campaign.execution, configuration.execution),
|
|
341
|
+
("scoring rule", campaign.scoring, configuration.scoring),
|
|
342
|
+
("evidence requirement", campaign.evidence, configuration.evidence),
|
|
343
|
+
("budget", campaign.budgets, configuration.budgets),
|
|
344
|
+
):
|
|
345
|
+
if expected != found:
|
|
346
|
+
raise ValidationError(
|
|
347
|
+
f"the manifest's {label} is not the Campaign's",
|
|
348
|
+
code=MANIFEST_BUILD_FAILED,
|
|
349
|
+
details={"field": label},
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
if set(configuration.agents) != set(campaign.agents):
|
|
353
|
+
agents: dict[str, JsonValue] = {
|
|
354
|
+
"campaign_agents": list(sorted(campaign.agents)),
|
|
355
|
+
"manifest_agents": list(sorted(configuration.agents)),
|
|
356
|
+
}
|
|
357
|
+
raise ValidationError(
|
|
358
|
+
"the manifest names different agents than the Campaign",
|
|
359
|
+
code=MANIFEST_BUILD_FAILED,
|
|
360
|
+
details=agents,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
target = campaign.mutation_contract.target_agent
|
|
364
|
+
for name, agent in configuration.agents.items():
|
|
365
|
+
expected_agent = campaign.agents[name]
|
|
366
|
+
if name == target:
|
|
367
|
+
# The skill list is the one field the mutation contract allows to
|
|
368
|
+
# differ, so the subject is compared with it set aside.
|
|
369
|
+
agent = agent.model_copy(
|
|
370
|
+
update={
|
|
371
|
+
"harness": agent.harness.model_copy(
|
|
372
|
+
update={"skills": list(expected_agent.harness.skills)}
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
)
|
|
376
|
+
if agent != expected_agent:
|
|
377
|
+
raise ValidationError(
|
|
378
|
+
f"the manifest's {name} agent differs from the Campaign's "
|
|
379
|
+
"outside the skill list",
|
|
380
|
+
code=MANIFEST_BUILD_FAILED,
|
|
381
|
+
details={"agent": name},
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def manifest_id_for(configuration_digest: Digest) -> str:
|
|
386
|
+
"""Return the display identifier one configuration digest implies."""
|
|
387
|
+
hexadecimal = configuration_digest.split(":", 1)[1]
|
|
388
|
+
return f"{MANIFEST_ID_PREFIX}_{hexadecimal[:_ID_HEX_LENGTH]}"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _require_campaign_baseline(
|
|
392
|
+
configuration: ExperimentConfiguration, campaign: CampaignSpec
|
|
393
|
+
) -> AgentSpec:
|
|
394
|
+
"""Fail closed when the Campaign's subject is not the baseline its kind needs.
|
|
395
|
+
|
|
396
|
+
An insertion measures a skill against no skill, so its Campaign carries
|
|
397
|
+
none. A replacement measures a revision against the skill it revises, so its
|
|
398
|
+
Campaign carries exactly that one. Either way the Campaign *is* the baseline,
|
|
399
|
+
and a Campaign that is not the baseline its own contract describes has no
|
|
400
|
+
comparison to offer.
|
|
401
|
+
"""
|
|
402
|
+
target = campaign.mutation_contract.target_agent
|
|
403
|
+
subject = configuration.agents.get(target)
|
|
404
|
+
if subject is None:
|
|
405
|
+
missing: dict[str, JsonValue] = {
|
|
406
|
+
"target_agent": target,
|
|
407
|
+
"agents": list(sorted(configuration.agents)),
|
|
408
|
+
}
|
|
409
|
+
raise ValidationError(
|
|
410
|
+
f"the Campaign defines no {target} agent for the mutation contract "
|
|
411
|
+
"to target",
|
|
412
|
+
code=MANIFEST_BUILD_FAILED,
|
|
413
|
+
details=missing,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
skills = subject.harness.skills
|
|
417
|
+
if campaign.mutation_contract.kind is MutationKind.SKILL_INSERTION:
|
|
418
|
+
if skills:
|
|
419
|
+
raise ValidationError(
|
|
420
|
+
"the Campaign's own subject already carries a skill, so there is "
|
|
421
|
+
"no baseline to compare a candidate against",
|
|
422
|
+
code=MANIFEST_BUILD_FAILED,
|
|
423
|
+
details={"skill_count": len(skills)},
|
|
424
|
+
)
|
|
425
|
+
elif len(skills) != 1:
|
|
426
|
+
raise ValidationError(
|
|
427
|
+
"a skill_replacement Campaign's subject carries exactly the one "
|
|
428
|
+
"skill the candidate replaces, so there is no baseline to compare a "
|
|
429
|
+
"candidate against",
|
|
430
|
+
code=MANIFEST_BUILD_FAILED,
|
|
431
|
+
details={"skill_count": len(skills)},
|
|
432
|
+
)
|
|
433
|
+
return subject
|