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,263 @@
|
|
|
1
|
+
"""Where the local signing key lives. Spec section 7.5.
|
|
2
|
+
|
|
3
|
+
```text
|
|
4
|
+
<techtree home>/identities/
|
|
5
|
+
├── executor-private-key.bin mode 0600, raw Ed25519 private bytes
|
|
6
|
+
└── executor-public.json mode 0600, canonical ExecutorIdentity
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The directory is :attr:`~techtree.paths.TechtreePaths.identities_dir`, which
|
|
10
|
+
WP0 resolved and deliberately never created: decisions document 0001 forbade
|
|
11
|
+
device keys through WP5, so the location was settled and the directory did not
|
|
12
|
+
exist. This module is the first thing entitled to create it.
|
|
13
|
+
|
|
14
|
+
Four properties are the reason this is a module rather than two file writes.
|
|
15
|
+
|
|
16
|
+
*Creation is exclusive.* The private key is created with ``O_EXCL``, so a
|
|
17
|
+
second creation attempt is reported as a conflict instead of quietly replacing
|
|
18
|
+
the key every existing receipt was signed under. A replaced key does not
|
|
19
|
+
invalidate old signatures loudly; it invalidates them silently, which is worse.
|
|
20
|
+
|
|
21
|
+
*The private half is never returned as bytes.* :meth:`IdentityStore.load_private`
|
|
22
|
+
hands back a key object that can sign. Nothing here exposes a serialization of
|
|
23
|
+
it, no error detail carries it, and the only bytes written are the ones written
|
|
24
|
+
once at creation.
|
|
25
|
+
|
|
26
|
+
*The pair is checkable without a document.* :meth:`IdentityStore.verify_pair`
|
|
27
|
+
signs a fixed, domain-separated challenge in memory and verifies it against the
|
|
28
|
+
stored public half. That is how a caller learns the two files still belong to
|
|
29
|
+
each other before it signs anything real with them.
|
|
30
|
+
|
|
31
|
+
*Both halves are digested together.* The key identifier is the digest of the
|
|
32
|
+
public key bytes, so it is derived rather than assigned: two files that
|
|
33
|
+
disagree about which key they describe cannot both be self-consistent.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import os
|
|
39
|
+
from collections.abc import Callable
|
|
40
|
+
from datetime import UTC, datetime
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Final
|
|
43
|
+
|
|
44
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
45
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
46
|
+
|
|
47
|
+
from techtree.canonical import canonical_json_bytes, sha256_digest_bytes
|
|
48
|
+
from techtree.crypto import (
|
|
49
|
+
ED25519_PRIVATE_KEY_BYTES,
|
|
50
|
+
generate_private_key,
|
|
51
|
+
load_private_key,
|
|
52
|
+
load_public_key,
|
|
53
|
+
private_key_bytes,
|
|
54
|
+
public_key_bytes,
|
|
55
|
+
public_key_to_base64,
|
|
56
|
+
sign_digest,
|
|
57
|
+
verify_signature,
|
|
58
|
+
)
|
|
59
|
+
from techtree.errors import ConflictError, NotFoundError, ValidationError
|
|
60
|
+
from techtree.fs import (
|
|
61
|
+
atomic_write_bytes,
|
|
62
|
+
ensure_private_directory,
|
|
63
|
+
fsync_directory,
|
|
64
|
+
open_exclusive,
|
|
65
|
+
)
|
|
66
|
+
from techtree.identity.models import LOCAL_IDENTITY_INVALID, ExecutorIdentity
|
|
67
|
+
from techtree.paths import TechtreePaths
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"IDENTITY_SELF_CHECK_CHALLENGE",
|
|
71
|
+
"PRIVATE_KEY_FILENAME",
|
|
72
|
+
"PUBLIC_IDENTITY_FILENAME",
|
|
73
|
+
"IdentityStore",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
#: Raw Ed25519 private bytes. The specification permits raw or a clearly
|
|
77
|
+
#: versioned encoding; raw is what the primitives already read and write, and
|
|
78
|
+
#: an encoding this file did not need would be one more thing to get wrong.
|
|
79
|
+
PRIVATE_KEY_FILENAME: Final = "executor-private-key.bin"
|
|
80
|
+
|
|
81
|
+
PUBLIC_IDENTITY_FILENAME: Final = "executor-public.json"
|
|
82
|
+
|
|
83
|
+
#: What :meth:`IdentityStore.verify_pair` signs. Domain-separated so that a
|
|
84
|
+
#: signature made during a self-check can never be replayed as a signature over
|
|
85
|
+
#: a protocol object: no canonical document digests to this value.
|
|
86
|
+
IDENTITY_SELF_CHECK_CHALLENGE: Final = b"techtree/identity/self-check/v1"
|
|
87
|
+
|
|
88
|
+
#: Owner read and write only, for both halves. The public half is not secret,
|
|
89
|
+
#: but it is local state in a single-user directory and there is no reason for
|
|
90
|
+
#: it to be more readable than everything beside it.
|
|
91
|
+
_FILE_MODE: Final = 0o600
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class IdentityStore:
|
|
95
|
+
"""Creates, loads, and self-checks the local executor identity."""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
paths: TechtreePaths,
|
|
100
|
+
*,
|
|
101
|
+
clock: Callable[[], datetime] | None = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
self._paths = paths
|
|
104
|
+
self._clock = clock or _utc_now
|
|
105
|
+
|
|
106
|
+
# -- placement ----------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def directory(self) -> Path:
|
|
110
|
+
"""Return the directory both halves of the identity live in."""
|
|
111
|
+
return self._paths.identities_dir
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def private_key_path(self) -> Path:
|
|
115
|
+
"""Return where the private half lives. Never copied anywhere else."""
|
|
116
|
+
return self.directory / PRIVATE_KEY_FILENAME
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def public_identity_path(self) -> Path:
|
|
120
|
+
"""Return where the public half lives."""
|
|
121
|
+
return self.directory / PUBLIC_IDENTITY_FILENAME
|
|
122
|
+
|
|
123
|
+
# -- lifecycle ----------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def exists(self) -> bool:
|
|
126
|
+
"""Return whether both halves of the identity are present."""
|
|
127
|
+
return self.private_key_path.is_file() and self.public_identity_path.is_file()
|
|
128
|
+
|
|
129
|
+
def create(self) -> ExecutorIdentity:
|
|
130
|
+
"""Generate a key, write both halves exclusively, and return the public one.
|
|
131
|
+
|
|
132
|
+
The private half is written first and with ``O_EXCL``: it is the file
|
|
133
|
+
whose existence means "this machine has an identity", so it is the file
|
|
134
|
+
that has to win the race. The public half is derived from it, which is
|
|
135
|
+
why rewriting it over a stale one is correct rather than destructive.
|
|
136
|
+
"""
|
|
137
|
+
ensure_private_directory(self.directory)
|
|
138
|
+
private_key = generate_private_key()
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
with open_exclusive(self.private_key_path, _FILE_MODE) as handle:
|
|
142
|
+
handle.write(private_key_bytes(private_key))
|
|
143
|
+
handle.flush()
|
|
144
|
+
os.fsync(handle.fileno())
|
|
145
|
+
except ConflictError as error:
|
|
146
|
+
raise ConflictError(
|
|
147
|
+
"this machine already has a local signing key; replacing it "
|
|
148
|
+
"would silently orphan every receipt signed under the old one",
|
|
149
|
+
code=LOCAL_IDENTITY_INVALID,
|
|
150
|
+
details={"path": str(self.private_key_path)},
|
|
151
|
+
) from error
|
|
152
|
+
|
|
153
|
+
identity = _identity_for(private_key, created_at=self._clock())
|
|
154
|
+
atomic_write_bytes(
|
|
155
|
+
self.public_identity_path,
|
|
156
|
+
canonical_json_bytes(identity),
|
|
157
|
+
mode=_FILE_MODE,
|
|
158
|
+
)
|
|
159
|
+
fsync_directory(self.directory)
|
|
160
|
+
return identity
|
|
161
|
+
|
|
162
|
+
# -- loading ------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def load_public(self) -> ExecutorIdentity:
|
|
165
|
+
"""Load and validate the public identity from its stored bytes."""
|
|
166
|
+
path = self.public_identity_path
|
|
167
|
+
try:
|
|
168
|
+
raw = path.read_bytes()
|
|
169
|
+
except FileNotFoundError as error:
|
|
170
|
+
raise NotFoundError(
|
|
171
|
+
"this machine has no local signing identity yet; `techtree "
|
|
172
|
+
"setup` creates one",
|
|
173
|
+
code=LOCAL_IDENTITY_INVALID,
|
|
174
|
+
details={"path": str(path)},
|
|
175
|
+
) from error
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
return ExecutorIdentity.model_validate_json(raw)
|
|
179
|
+
except PydanticValidationError as error:
|
|
180
|
+
raise ValidationError(
|
|
181
|
+
"the stored local identity is not a valid identity document: "
|
|
182
|
+
f"{error.errors()[0]['msg']}",
|
|
183
|
+
code=LOCAL_IDENTITY_INVALID,
|
|
184
|
+
details={"path": str(path)},
|
|
185
|
+
) from error
|
|
186
|
+
|
|
187
|
+
def load_private(self) -> Ed25519PrivateKey:
|
|
188
|
+
"""Load the private half into memory.
|
|
189
|
+
|
|
190
|
+
Nothing about the material reaches the caller except a key object that
|
|
191
|
+
can sign: no bytes, no encoding, and no detail in the errors raised
|
|
192
|
+
when the file is unusable.
|
|
193
|
+
"""
|
|
194
|
+
path = self.private_key_path
|
|
195
|
+
try:
|
|
196
|
+
raw = path.read_bytes()
|
|
197
|
+
except FileNotFoundError as error:
|
|
198
|
+
raise NotFoundError(
|
|
199
|
+
"this machine has no local signing key yet; `techtree setup` "
|
|
200
|
+
"creates one",
|
|
201
|
+
code=LOCAL_IDENTITY_INVALID,
|
|
202
|
+
details={"path": str(path)},
|
|
203
|
+
) from error
|
|
204
|
+
|
|
205
|
+
if len(raw) != ED25519_PRIVATE_KEY_BYTES:
|
|
206
|
+
raise ValidationError(
|
|
207
|
+
"the stored local signing key is not an Ed25519 private key",
|
|
208
|
+
code=LOCAL_IDENTITY_INVALID,
|
|
209
|
+
details={"path": str(path), "length": len(raw)},
|
|
210
|
+
)
|
|
211
|
+
return load_private_key(raw)
|
|
212
|
+
|
|
213
|
+
def verify_pair(self) -> bool:
|
|
214
|
+
"""Sign a fixed challenge in memory and check it against the public half.
|
|
215
|
+
|
|
216
|
+
Returns ``False`` for two well-formed files that describe different
|
|
217
|
+
keys. A file that cannot be read or parsed at all is a different
|
|
218
|
+
problem and raises, because "your identity is missing" and "your
|
|
219
|
+
identity does not match itself" call for different repairs.
|
|
220
|
+
"""
|
|
221
|
+
private_key = self.load_private()
|
|
222
|
+
identity = self.load_public()
|
|
223
|
+
public_key = load_public_key(public_key_bytes(private_key))
|
|
224
|
+
if public_key_to_base64(public_key) != identity.public_key:
|
|
225
|
+
return False
|
|
226
|
+
if _key_id_for(private_key) != identity.key_id:
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
challenge = sha256_digest_bytes(IDENTITY_SELF_CHECK_CHALLENGE)
|
|
230
|
+
signature = sign_digest(private_key, challenge, key_id=identity.key_id)
|
|
231
|
+
return verify_signature(public_key, challenge, signature)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
# Derivation
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _identity_for(
|
|
240
|
+
private_key: Ed25519PrivateKey, *, created_at: datetime
|
|
241
|
+
) -> ExecutorIdentity:
|
|
242
|
+
"""Return the public identity describing one private key."""
|
|
243
|
+
return ExecutorIdentity(
|
|
244
|
+
kind="local_ed25519",
|
|
245
|
+
key_id=_key_id_for(private_key),
|
|
246
|
+
algorithm="ed25519",
|
|
247
|
+
public_key=public_key_to_base64(private_key.public_key()),
|
|
248
|
+
created_at=created_at,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _key_id_for(private_key: Ed25519PrivateKey) -> str:
|
|
253
|
+
"""Return the content-addressed identifier of a key's public half.
|
|
254
|
+
|
|
255
|
+
Derived rather than assigned: a key identifier that was chosen could be
|
|
256
|
+
reused for a different key, and every signature Techtree checks names the
|
|
257
|
+
key by this string.
|
|
258
|
+
"""
|
|
259
|
+
return sha256_digest_bytes(public_key_bytes(private_key))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _utc_now() -> datetime:
|
|
263
|
+
return datetime.now(UTC)
|
techtree/ids.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Prefixed local identifiers. Spec section 10.6.
|
|
2
|
+
|
|
3
|
+
An identifier looks like ``run_5b1c...`` — a namespace prefix, an underscore,
|
|
4
|
+
and 32 lowercase hexadecimal characters from a UUID4.
|
|
5
|
+
|
|
6
|
+
Identifiers are labels, not integrity values. Two objects with the same
|
|
7
|
+
identifier are not thereby the same object; only a digest says that. Nothing in
|
|
8
|
+
the protocol should ever compare identifiers where it means to compare content.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
import uuid
|
|
15
|
+
from typing import Final
|
|
16
|
+
|
|
17
|
+
from techtree.errors import ValidationError
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"ID_PREFIXES",
|
|
21
|
+
"id_prefix",
|
|
22
|
+
"new_id",
|
|
23
|
+
"validate_id",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
#: Every namespace WP0–WP5 issues identifiers for. The set is closed so that a
|
|
27
|
+
#: typo in a prefix fails at the point it is written rather than becoming a
|
|
28
|
+
#: silent new namespace.
|
|
29
|
+
ID_PREFIXES: Final[frozenset[str]] = frozenset(
|
|
30
|
+
{
|
|
31
|
+
"campaign",
|
|
32
|
+
"climb",
|
|
33
|
+
"draft",
|
|
34
|
+
"run",
|
|
35
|
+
"receipt",
|
|
36
|
+
"uplift",
|
|
37
|
+
"policy",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
_ID_PATTERN = re.compile(r"^(?P<prefix>[a-z][a-z0-9]*)_(?P<body>[0-9a-f]{32})$")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _require_known_prefix(prefix: str) -> str:
|
|
45
|
+
if prefix not in ID_PREFIXES:
|
|
46
|
+
raise ValidationError(
|
|
47
|
+
f"unknown identifier prefix {prefix!r}",
|
|
48
|
+
details={"prefix": prefix, "known": ", ".join(sorted(ID_PREFIXES))},
|
|
49
|
+
)
|
|
50
|
+
return prefix
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def new_id(prefix: str) -> str:
|
|
54
|
+
"""Create a new prefixed UUID4 hexadecimal identifier."""
|
|
55
|
+
_require_known_prefix(prefix)
|
|
56
|
+
return f"{prefix}_{uuid.uuid4().hex}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validate_id(value: str, expected_prefix: str | None = None) -> str:
|
|
60
|
+
"""Validate syntax and optionally require one prefix."""
|
|
61
|
+
match = _ID_PATTERN.fullmatch(value)
|
|
62
|
+
if match is None:
|
|
63
|
+
raise ValidationError(
|
|
64
|
+
"identifier must be <prefix>_<32 lowercase hexadecimal characters>",
|
|
65
|
+
details={"value": value},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
prefix = match.group("prefix")
|
|
69
|
+
_require_known_prefix(prefix)
|
|
70
|
+
|
|
71
|
+
if expected_prefix is not None:
|
|
72
|
+
_require_known_prefix(expected_prefix)
|
|
73
|
+
if prefix != expected_prefix:
|
|
74
|
+
raise ValidationError(
|
|
75
|
+
f"expected a {expected_prefix} identifier, got a {prefix} identifier",
|
|
76
|
+
details={"value": value, "expected_prefix": expected_prefix},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def id_prefix(value: str) -> str:
|
|
83
|
+
"""Return the prefix from a valid ID."""
|
|
84
|
+
validated = validate_id(value)
|
|
85
|
+
return validated.split("_", 1)[0]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Campaign-derived experiment variants and the comparison between them.
|
|
2
|
+
|
|
3
|
+
Spec PR6 §6.3.
|
|
4
|
+
|
|
5
|
+
A Campaign states what is to be measured. It does not state which side of a
|
|
6
|
+
comparison a particular execution is. Two modules turn one into the other:
|
|
7
|
+
|
|
8
|
+
:mod:`techtree.manifests.builder`
|
|
9
|
+
Derivation. Copying the Campaign's scientific configuration unchanged into
|
|
10
|
+
a baseline variant carrying no skill and a candidate variant carrying
|
|
11
|
+
exactly one, and sealing each with the digest of its configuration.
|
|
12
|
+
|
|
13
|
+
:mod:`techtree.manifests.compare`
|
|
14
|
+
Control. Requiring the structural invariants outright, then diffing the two
|
|
15
|
+
configurations down to their leaves as RFC 6901 JSON Pointers, and
|
|
16
|
+
reporting whether the only place they disagree is the one place the
|
|
17
|
+
Campaign's mutation contract permits.
|
|
18
|
+
|
|
19
|
+
Together they are the reason an uplift number means something: the candidate
|
|
20
|
+
run and the baseline run are provably the same experiment apart from the skill
|
|
21
|
+
that was inserted, and the proof is a list of pointers anyone can re-derive
|
|
22
|
+
from the two documents.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from techtree.manifests.builder import (
|
|
28
|
+
build_baseline_manifest,
|
|
29
|
+
build_candidate_manifest,
|
|
30
|
+
build_experiment_configuration,
|
|
31
|
+
)
|
|
32
|
+
from techtree.manifests.compare import compare_manifests
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"build_baseline_manifest",
|
|
36
|
+
"build_candidate_manifest",
|
|
37
|
+
"build_experiment_configuration",
|
|
38
|
+
"compare_manifests",
|
|
39
|
+
]
|