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
@@ -0,0 +1,201 @@
1
+ """Shared protocol types, base classes, and envelopes. Spec section 11.2.
2
+
3
+ Two base classes exist and the distinction matters:
4
+
5
+ ``ProtocolModel``
6
+ Frozen, strict, extra-forbidden. Everything that is hashed, digested, or
7
+ signed derives from it. Immutability is what makes a digest meaningful:
8
+ once an object exists, its canonical bytes cannot drift underneath the
9
+ digest that was taken of them.
10
+
11
+ ``StateModel``
12
+ Strict, extra-forbidden, assignment-validating, and mutable. Local state
13
+ projections that the CLI edits in place derive from it.
14
+
15
+ Both are *strict*, which in Pydantic v2 is mode-dependent. Python-mode
16
+ validation performs no coercion at all, so a protocol object built in code must
17
+ be handed real ``datetime`` objects rather than strings. JSON-mode validation
18
+ still accepts the single JSON spelling of a type, so stored protocol documents
19
+ are loaded with ``model_validate_json`` on the raw bytes, never by decoding to
20
+ ``dict`` first and calling ``model_validate``. Loading from bytes is also what
21
+ lets a caller digest exactly what it parsed.
22
+
23
+ This module deliberately depends on nothing in the package except
24
+ :mod:`techtree.constants`, so it sits underneath canonicalization, crypto, and
25
+ every model file.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import base64
31
+ import binascii
32
+ from datetime import UTC, datetime
33
+ from typing import Annotated, Literal
34
+
35
+ from pydantic import (
36
+ AfterValidator,
37
+ BaseModel,
38
+ ConfigDict,
39
+ Field,
40
+ StringConstraints,
41
+ )
42
+
43
+ from techtree.constants import DIGEST_PREFIX
44
+
45
+ __all__ = [
46
+ "DIGEST_PATTERN",
47
+ "ArtifactRef",
48
+ "Base64String",
49
+ "Digest",
50
+ "JsonScalar",
51
+ "JsonValue",
52
+ "NonEmptyString",
53
+ "ObjectEnvelope",
54
+ "ProtocolModel",
55
+ "PublicKeyRef",
56
+ "SignatureEnvelope",
57
+ "StateModel",
58
+ "UtcDateTime",
59
+ ]
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # JSON value types
64
+ # ---------------------------------------------------------------------------
65
+
66
+ type JsonScalar = str | int | float | bool | None
67
+ """Any value JSON can hold without nesting."""
68
+
69
+ type JsonValue = JsonScalar | list[JsonValue] | dict[str, JsonValue]
70
+ """Any value JSON can hold, including arrays and objects."""
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Constrained scalars
75
+ # ---------------------------------------------------------------------------
76
+
77
+ #: A Techtree digest is the prefix plus 64 lowercase hexadecimal characters.
78
+ #: Uppercase hexadecimal is rejected so that one digest string has exactly one
79
+ #: spelling and byte comparison is meaningful.
80
+ DIGEST_PATTERN = rf"^{DIGEST_PREFIX}[0-9a-f]{{64}}$"
81
+
82
+ type Digest = Annotated[str, StringConstraints(pattern=DIGEST_PATTERN)]
83
+
84
+
85
+ def _require_utc(value: datetime) -> datetime:
86
+ """Reject naive datetimes and normalize aware ones to UTC."""
87
+ if value.tzinfo is None or value.tzinfo.utcoffset(value) is None:
88
+ raise ValueError("datetime must be timezone-aware")
89
+ return value.astimezone(UTC)
90
+
91
+
92
+ type UtcDateTime = Annotated[datetime, AfterValidator(_require_utc)]
93
+
94
+
95
+ def _require_visible_characters(value: str) -> str:
96
+ """Reject strings that are empty or made only of whitespace."""
97
+ if not value.strip():
98
+ raise ValueError("value must contain at least one non-whitespace character")
99
+ return value
100
+
101
+
102
+ type NonEmptyString = Annotated[
103
+ str,
104
+ StringConstraints(min_length=1),
105
+ AfterValidator(_require_visible_characters),
106
+ ]
107
+
108
+
109
+ def _require_base64(value: str) -> str:
110
+ """Reject anything that is not strict, canonical base64."""
111
+ try:
112
+ decoded = base64.b64decode(value, validate=True)
113
+ except (binascii.Error, ValueError) as error:
114
+ raise ValueError("value must be base64") from error
115
+ # ``b64decode`` tolerates several spellings of the same bytes. Re-encoding
116
+ # and comparing pins the single canonical spelling.
117
+ if base64.b64encode(decoded).decode("ascii") != value:
118
+ raise ValueError("value must be canonical base64")
119
+ return value
120
+
121
+
122
+ type Base64String = Annotated[
123
+ str,
124
+ StringConstraints(min_length=1),
125
+ AfterValidator(_require_base64),
126
+ ]
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Base classes
131
+ # ---------------------------------------------------------------------------
132
+
133
+
134
+ class ProtocolModel(BaseModel):
135
+ """Frozen, strict, extra-forbidden hashed or signed object."""
136
+
137
+ model_config = ConfigDict(
138
+ frozen=True,
139
+ strict=True,
140
+ extra="forbid",
141
+ validate_default=True,
142
+ populate_by_name=False,
143
+ )
144
+
145
+
146
+ class StateModel(BaseModel):
147
+ """Strict mutable local-state projection."""
148
+
149
+ model_config = ConfigDict(
150
+ frozen=False,
151
+ strict=True,
152
+ extra="forbid",
153
+ validate_default=True,
154
+ validate_assignment=True,
155
+ populate_by_name=False,
156
+ )
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Shared models
161
+ # ---------------------------------------------------------------------------
162
+
163
+
164
+ class ArtifactRef(ProtocolModel):
165
+ """A content-addressed pointer to a stored byte stream."""
166
+
167
+ digest: Digest
168
+ media_type: NonEmptyString
169
+ size: Annotated[int, Field(gt=0)]
170
+ relative_path: str | None = None
171
+
172
+
173
+ class PublicKeyRef(ProtocolModel):
174
+ """An Ed25519 public key in the form protocol documents carry it."""
175
+
176
+ algorithm: Literal["ed25519"]
177
+ key_id: NonEmptyString
178
+ public_key: Base64String
179
+
180
+
181
+ class SignatureEnvelope(ProtocolModel):
182
+ """A detached Ed25519 signature over a digest string."""
183
+
184
+ algorithm: Literal["ed25519"]
185
+ key_id: NonEmptyString
186
+ signature: Base64String
187
+
188
+
189
+ class ObjectEnvelope[T: BaseModel](ProtocolModel):
190
+ """A payload together with its digest and an optional signature.
191
+
192
+ ``payload_digest`` is carried, never recomputed during validation. A model
193
+ that silently recomputed it could not detect the one thing the field exists
194
+ to detect: a payload that no longer matches the digest it was stored under.
195
+ Services verify the pair explicitly with
196
+ :func:`techtree.canonical.verify_object_digest`.
197
+ """
198
+
199
+ payload: T
200
+ payload_digest: Digest
201
+ signature: SignatureEnvelope | None = None
@@ -0,0 +1,484 @@
1
+ """The scientific execution contract. Spec section 11.5, decisions 0001/0002.
2
+
3
+ ``CampaignSpec`` is the reusable half of the kernel: what is measured, on which
4
+ tasks, with which agent, under which comparison rules, and under whose data
5
+ policy. It is the object every execution artifact points at.
6
+
7
+ What it deliberately does not own is anything public — no slug, no schedule, no
8
+ leaderboard, no candidate visibility. Those live in ``ClimbManifest``. The
9
+ separation is not tidiness: a Campaign can be re-run privately, reproduced by a
10
+ third party, or referenced by a future program, and none of those uses should
11
+ drag a marketing surface along with them. ``extra="forbid"`` on every model
12
+ here is what makes "no public policy fields" enforceable rather than aspirational.
13
+
14
+ A Campaign is also a commitment. The task membership is fixed and hashed before
15
+ anything runs, ``shuffle`` cannot be spelled ``True``, and the only difference
16
+ a candidate is permitted to introduce is the subject's skill list. Everything in
17
+ this module exists to make an uncontrolled comparison unrepresentable rather
18
+ than merely discouraged.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from enum import StrEnum
25
+ from typing import Literal, Self
26
+
27
+ from pydantic import Field, PositiveFloat, PositiveInt, model_validator
28
+
29
+ from techtree.models.base import (
30
+ ArtifactRef,
31
+ Digest,
32
+ JsonValue,
33
+ NonEmptyString,
34
+ ProtocolModel,
35
+ )
36
+ from techtree.models.evaluation_backend import (
37
+ EvaluationBackendKind,
38
+ EvaluationBackendSpec,
39
+ )
40
+
41
+ __all__ = [
42
+ "CREDENTIAL_ENV_PATTERN",
43
+ "SKILL_MUTATION_POINTER",
44
+ "SUBJECT_AGENT",
45
+ "AgentSpec",
46
+ "BudgetSpec",
47
+ "CampaignContext",
48
+ "CampaignMetadata",
49
+ "CampaignSpec",
50
+ "CampaignTaskset",
51
+ "EnvironmentSpec",
52
+ "EvidenceRequirements",
53
+ "ExecutionSpec",
54
+ "HarnessSpec",
55
+ "ModelSpec",
56
+ "MutationContract",
57
+ "MutationKind",
58
+ "PackageRef",
59
+ "ProgramRef",
60
+ "PublicContext",
61
+ "RuntimeSpec",
62
+ "SamplingSpec",
63
+ "ScoringSpec",
64
+ "TaskMembershipCommitment",
65
+ "TaskSelection",
66
+ "TasksetRef",
67
+ "VariantSchedule",
68
+ ]
69
+
70
+ #: The only agent name v0.1 defines. A second named agent would be a second
71
+ #: thing being measured, which the comparison contract has no way to control.
72
+ SUBJECT_AGENT = "subject"
73
+
74
+ #: The single JSON Pointer a candidate is allowed to differ at.
75
+ SKILL_MUTATION_POINTER = "/agents/subject/harness/skills"
76
+
77
+ #: A credential is named, never carried. The name must look like an ordinary
78
+ #: environment variable so that nothing resembling a value can hide in the
79
+ #: field that is supposed to hold only a name.
80
+ CREDENTIAL_ENV_PATTERN = r"^[A-Z][A-Z0-9_]{2,63}$"
81
+
82
+ _CREDENTIAL_ENV_RE = re.compile(CREDENTIAL_ENV_PATTERN)
83
+
84
+ #: An OCI reference that names content rather than a moving tag. For a
85
+ #: multi-platform repository the digest names the image index.
86
+ _IMAGE_INDEX_DIGEST_RE = re.compile(r"@(sha256:[0-9a-f]{64})$")
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Shared low-level references
91
+ # ---------------------------------------------------------------------------
92
+
93
+
94
+ class ProgramRef(ProtocolModel):
95
+ """A reserved pointer at a future ``ImprovementProgram``.
96
+
97
+ The program model itself is deferred (spec section 4.4). Only the pointer
98
+ exists, so that artifacts written today can be attributed later without a
99
+ schema break.
100
+ """
101
+
102
+ id: NonEmptyString
103
+ version: int = Field(ge=1)
104
+
105
+
106
+ class PublicContext(ProtocolModel):
107
+ """The optional public Climb an execution artifact was produced under."""
108
+
109
+ kind: Literal["climb"]
110
+ climb_digest: Digest
111
+
112
+
113
+ class CampaignContext(ProtocolModel):
114
+ """Forward-compatible pointers a Campaign may carry."""
115
+
116
+ program_ref: ProgramRef | None = None
117
+ outcome_contract_digest: Digest | None = None
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Package and taskset
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ class PackageRef(ProtocolModel):
126
+ """The source package a taskset is defined in."""
127
+
128
+ kind: Literal["embedded", "git", "hub"]
129
+ name: NonEmptyString
130
+ revision: NonEmptyString
131
+ digest: Digest
132
+
133
+
134
+ class TasksetRef(ProtocolModel):
135
+ """Which taskset, from which package, with which configuration."""
136
+
137
+ kind: Literal["verifiers"]
138
+ id: NonEmptyString
139
+ package: PackageRef
140
+ config: dict[str, JsonValue]
141
+
142
+
143
+ class TaskSelection(ProtocolModel):
144
+ """How many tasks and rollouts, in which order.
145
+
146
+ ``shuffle`` is typed ``Literal[False]``. Decisions document 0001 removes
147
+ shuffling from WP0–WP5 entirely, and there is no seed anywhere in the
148
+ protocol to reproduce a shuffle with, so a document that claimed to be
149
+ shuffled could not be checked by anyone.
150
+ """
151
+
152
+ num_tasks: int = Field(ge=1)
153
+ num_rollouts: int = Field(ge=1)
154
+ shuffle: Literal[False]
155
+
156
+
157
+ class TaskMembershipCommitment(ProtocolModel):
158
+ """The exact tasks a Campaign is fixed to, committed in order."""
159
+
160
+ mode: Literal["committed"]
161
+ ordered_task_hashes: list[Digest]
162
+ membership_digest: Digest
163
+
164
+ @model_validator(mode="after")
165
+ def _check_membership_is_a_usable_commitment(self) -> Self:
166
+ """Reject an empty or repeating membership list."""
167
+ if not self.ordered_task_hashes:
168
+ raise ValueError("a committed membership must list at least one task")
169
+ if len(set(self.ordered_task_hashes)) != len(self.ordered_task_hashes):
170
+ raise ValueError(
171
+ "committed task hashes must be unique; a repeated task would be "
172
+ "scored twice under one commitment"
173
+ )
174
+ return self
175
+
176
+
177
+ class CampaignTaskset(ProtocolModel):
178
+ """The taskset, the slice of it that is used, and its validation receipt."""
179
+
180
+ ref: TasksetRef
181
+ selection: TaskSelection
182
+ membership: TaskMembershipCommitment
183
+ validation_receipt_digest: Digest
184
+
185
+ @model_validator(mode="after")
186
+ def _check_membership_matches_selection(self) -> Self:
187
+ """Reject a commitment that does not cover exactly the selected tasks."""
188
+ committed = len(self.membership.ordered_task_hashes)
189
+ if committed != self.selection.num_tasks:
190
+ raise ValueError(
191
+ f"membership commits {committed} tasks but the selection asks "
192
+ f"for {self.selection.num_tasks}"
193
+ )
194
+ return self
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # The subject agent
199
+ # ---------------------------------------------------------------------------
200
+
201
+
202
+ class ModelSpec(ProtocolModel):
203
+ """Which model answers, and the environment variable holding its key."""
204
+
205
+ provider: NonEmptyString
206
+ model_id: NonEmptyString
207
+ revision: NonEmptyString | None
208
+ credential_env: NonEmptyString
209
+
210
+ @model_validator(mode="after")
211
+ def _check_credential_env_is_a_name(self) -> Self:
212
+ """Reject anything that is not a plain environment-variable name."""
213
+ if _CREDENTIAL_ENV_RE.fullmatch(self.credential_env) is None:
214
+ raise ValueError(
215
+ "credential_env must be an uppercase environment-variable name "
216
+ "such as TECHTREE_MODEL_API_KEY, never a credential value"
217
+ )
218
+ return self
219
+
220
+
221
+ class SamplingSpec(ProtocolModel):
222
+ """How the subject model is sampled."""
223
+
224
+ temperature: float = Field(ge=0.0, le=2.0)
225
+ max_tokens: int = Field(ge=1)
226
+
227
+
228
+ class HarnessSpec(ProtocolModel):
229
+ """The agent harness and the skills inserted into it."""
230
+
231
+ id: NonEmptyString
232
+ version: NonEmptyString
233
+ use_bundled_skill: bool
234
+ skills: list[ArtifactRef]
235
+
236
+
237
+ class RuntimeSpec(ProtocolModel):
238
+ """Where the subject agent executes. Not the evaluation backend.
239
+
240
+ The image is pinned twice over, because one pin is not enough to say what
241
+ ran. ``image`` names content rather than a tag, and for a multi-platform
242
+ repository the content it names is an OCI image *index* — a list of
243
+ per-platform manifests, not a filesystem. Two hosts on different
244
+ architectures pulling the same index digest therefore run different bytes,
245
+ and a comparison that recorded only the index digest could not tell whether
246
+ the two variants ran the same subject container.
247
+
248
+ ``image_platform_digests`` closes that: one platform-specific manifest
249
+ digest per supported platform, resolved from the registry when the pin is
250
+ made and carried in the Campaign from then on. A run records the platform
251
+ the local daemon resolved, and the digest that actually ran is this table's
252
+ entry for that platform.
253
+ """
254
+
255
+ type: Literal["docker"]
256
+ image: NonEmptyString
257
+ supported_platforms: list[NonEmptyString]
258
+ image_platform_digests: dict[NonEmptyString, Digest]
259
+ cpu: PositiveFloat | None
260
+ memory_gb: PositiveFloat | None
261
+ network_policy: Literal["restricted", "open"]
262
+
263
+ @property
264
+ def image_index_digest(self) -> Digest:
265
+ """The content the pinned reference names."""
266
+ match = _IMAGE_INDEX_DIGEST_RE.search(self.image)
267
+ assert match is not None # the validator below refuses anything else
268
+ return match.group(1)
269
+
270
+ @model_validator(mode="after")
271
+ def _check_platforms_are_listed_once(self) -> Self:
272
+ """Reject an empty or repeating platform list."""
273
+ if not self.supported_platforms:
274
+ raise ValueError("a runtime must support at least one platform")
275
+ if len(set(self.supported_platforms)) != len(self.supported_platforms):
276
+ raise ValueError("supported_platforms must not repeat a platform")
277
+ return self
278
+
279
+ @model_validator(mode="after")
280
+ def _check_the_image_is_pinned_for_every_platform(self) -> Self:
281
+ """Reject a moving image, and a platform with no digest behind it."""
282
+ if _IMAGE_INDEX_DIGEST_RE.search(self.image) is None:
283
+ raise ValueError(
284
+ f"image must name content, as repository@sha256:..., not a "
285
+ f"tag; got {self.image!r}"
286
+ )
287
+ declared = sorted(self.image_platform_digests)
288
+ supported = sorted(self.supported_platforms)
289
+ if declared != supported:
290
+ raise ValueError(
291
+ "image_platform_digests must name exactly the supported "
292
+ f"platforms; it names {declared} for {supported}"
293
+ )
294
+ return self
295
+
296
+
297
+ class AgentSpec(ProtocolModel):
298
+ """One named agent, complete."""
299
+
300
+ model: ModelSpec
301
+ sampling: SamplingSpec
302
+ harness: HarnessSpec
303
+ runtime: RuntimeSpec
304
+ trainable: bool
305
+
306
+
307
+ class EnvironmentSpec(ProtocolModel):
308
+ """The interaction shape the Campaign runs in."""
309
+
310
+ id: Literal["single-agent"]
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # The scientific contract
315
+ # ---------------------------------------------------------------------------
316
+
317
+
318
+ class MutationKind(StrEnum):
319
+ """Which shape of skill change the candidate is allowed to make.
320
+
321
+ Spec section 3.1. ``skill_insertion`` measures a skill against no skill and
322
+ is what a public Climb requires. ``skill_replacement`` measures one revision
323
+ of a skill against the previous one, which is the shape a local improvement
324
+ loop needs and which no public Climb wraps.
325
+ """
326
+
327
+ SKILL_INSERTION = "skill_insertion"
328
+ SKILL_REPLACEMENT = "skill_replacement"
329
+
330
+
331
+ class MutationContract(ProtocolModel):
332
+ """What a candidate is allowed to change, and by how much."""
333
+
334
+ kind: MutationKind
335
+ target_agent: Literal["subject"]
336
+ allowed_differences: list[NonEmptyString]
337
+ minimum_skills: int = Field(ge=0)
338
+ maximum_skills: int = Field(ge=1)
339
+
340
+ @model_validator(mode="after")
341
+ def _check_bounds_and_pointer(self) -> Self:
342
+ """Reject impossible bounds and any difference beyond the skill list."""
343
+ if self.minimum_skills > self.maximum_skills:
344
+ raise ValueError("minimum_skills cannot exceed maximum_skills")
345
+ if self.allowed_differences != [SKILL_MUTATION_POINTER]:
346
+ raise ValueError(
347
+ "the only allowed difference in v0.1 is exactly "
348
+ f"[{SKILL_MUTATION_POINTER!r}]"
349
+ )
350
+ return self
351
+
352
+
353
+ class VariantSchedule(StrEnum):
354
+ """Whether the two variants run one after the other or side by side.
355
+
356
+ Spec section 3.2. ``max_concurrent`` is the Campaign-wide bound either way;
357
+ under ``parallel_variants`` the executor divides it between the two variants
358
+ rather than granting each of them the whole allowance.
359
+ """
360
+
361
+ SEQUENTIAL = "baseline_then_candidate"
362
+ PARALLEL = "parallel_variants"
363
+
364
+
365
+ class ExecutionSpec(ProtocolModel):
366
+ """How the two variants are executed against each other."""
367
+
368
+ order: VariantSchedule
369
+ max_concurrent: int = Field(ge=1)
370
+ timeout_seconds: int = Field(ge=1)
371
+ retry_limit: int = Field(ge=0)
372
+
373
+
374
+ class ScoringSpec(ProtocolModel):
375
+ """Which reward decides the comparison, and what counts as an improvement."""
376
+
377
+ primary_reward: NonEmptyString
378
+ aggregation: Literal["mean"]
379
+ require_candidate_above_baseline: bool
380
+ minimum_absolute_delta: float = Field(ge=0.0)
381
+
382
+
383
+ class EvidenceRequirements(ProtocolModel):
384
+ """Which evidence a valid episode must carry."""
385
+
386
+ verifiers_episode: Literal["required"]
387
+ runtime_evidence: Literal["not_required", "optional", "required"]
388
+
389
+
390
+ class BudgetSpec(ProtocolModel):
391
+ """Optional ceilings on what a run may consume."""
392
+
393
+ maximum_input_tokens: PositiveInt | None = None
394
+ maximum_output_tokens: PositiveInt | None = None
395
+ maximum_model_calls: PositiveInt | None = None
396
+ maximum_usd: PositiveFloat | None = None
397
+
398
+
399
+ class CampaignMetadata(ProtocolModel):
400
+ """Identity and intent. Nothing public, nothing presentational."""
401
+
402
+ id: NonEmptyString
403
+ version: int = Field(ge=1)
404
+ purpose: Literal[
405
+ "component_uplift",
406
+ "baseline",
407
+ "release_assurance",
408
+ "environment_validation",
409
+ "reproduction",
410
+ ]
411
+
412
+
413
+ class CampaignSpec(ProtocolModel):
414
+ """The complete scientific and execution contract."""
415
+
416
+ schema_version: Literal["techtree.campaign.v1alpha1"]
417
+ kind: Literal["Campaign"]
418
+ metadata: CampaignMetadata
419
+ context: CampaignContext
420
+ taskset: CampaignTaskset
421
+ environment: EnvironmentSpec
422
+ agents: dict[str, AgentSpec]
423
+ mutation_contract: MutationContract
424
+ evaluation_backend: EvaluationBackendSpec
425
+ execution: ExecutionSpec
426
+ scoring: ScoringSpec
427
+ evidence: EvidenceRequirements
428
+ budgets: BudgetSpec
429
+ data_policy_digest: Digest
430
+
431
+ @property
432
+ def subject(self) -> AgentSpec:
433
+ """Return the single subject agent."""
434
+ return self.agents[SUBJECT_AGENT]
435
+
436
+ @model_validator(mode="after")
437
+ def _check_campaign_contract(self) -> Self:
438
+ """Enforce every WP0–WP5 Campaign rule from spec section 11.5."""
439
+ if set(self.agents) != {SUBJECT_AGENT}:
440
+ raise ValueError(
441
+ f"v0.1 defines exactly one agent named {SUBJECT_AGENT!r}; "
442
+ f"got {sorted(self.agents)}"
443
+ )
444
+
445
+ subject = self.agents[SUBJECT_AGENT]
446
+ # The Campaign describes the baseline, so the subject harness carries
447
+ # the skill list the baseline side of the comparison starts from, and
448
+ # which list that is follows from the mutation kind (spec section 3.1).
449
+ baseline_skills = len(subject.harness.skills)
450
+ if self.mutation_contract.kind is MutationKind.SKILL_INSERTION:
451
+ if baseline_skills:
452
+ raise ValueError(
453
+ "a skill_insertion Campaign describes a baseline that "
454
+ "carries no skills; the candidate adds exactly one"
455
+ )
456
+ elif baseline_skills != 1:
457
+ raise ValueError(
458
+ "a skill_replacement Campaign describes a baseline that carries "
459
+ f"exactly one skill to replace; got {baseline_skills}"
460
+ )
461
+ if subject.harness.use_bundled_skill:
462
+ raise ValueError(
463
+ "use_bundled_skill is false for the whole of WP0-WP5; a bundled "
464
+ "skill would be an uncontrolled second difference"
465
+ )
466
+
467
+ if self.taskset.selection.num_rollouts != 1:
468
+ raise ValueError("v0.1 scores one rollout per task; num_rollouts must be 1")
469
+
470
+ if self.mutation_contract.target_agent != SUBJECT_AGENT:
471
+ raise ValueError("the mutation contract must target the subject agent")
472
+
473
+ if self.evaluation_backend.kind is not EvaluationBackendKind.LOCAL_TECHTREE:
474
+ raise ValueError(
475
+ "WP0-WP5 Campaigns are evaluated by local_techtree only; "
476
+ f"got {self.evaluation_backend.kind.value}"
477
+ )
478
+
479
+ if self.evidence.runtime_evidence != "not_required":
480
+ raise ValueError(
481
+ "runtime evidence is not collected before WP6, so a Campaign "
482
+ "that required it could never produce a valid episode"
483
+ )
484
+ return self