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/models/base.py
ADDED
|
@@ -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
|