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,311 @@
1
+ """Turning a taskset reference into a lock. Spec section 21.3.
2
+
3
+ A ``TasksetLock`` is the answer to one question: given this engine and this
4
+ reference, which tasks *are* the Climb? The answer has to be the same for the
5
+ publisher and for every participant, so this module is built around three
6
+ refusals.
7
+
8
+ *Ask twice, in two fresh processes.* One inspection tells you what a taskset
9
+ loaded as; two tell you whether loading it is deterministic. A taskset that
10
+ draws from a random source, iterates a set, or depends on dictionary order will
11
+ usually agree with itself inside one process and disagree across two, which is
12
+ exactly why the second run is a new interpreter rather than a second call.
13
+
14
+ *Count and uniqueness are required, not observed.* A selection asking for 36
15
+ tasks and receiving 35 is not a smaller Climb, it is a different one; a
16
+ membership naming the same task twice would score it twice under one
17
+ commitment.
18
+
19
+ *The package the engine actually holds is what gets locked.* Decisions document
20
+ 0003 A8 requires three values to be equal — the Campaign's
21
+ ``taskset.ref.package.digest``, the engine descriptor's ``source_digest``, and
22
+ the lock's ``resolved_package_digest``. This module recomputes the digest from
23
+ the installed source tree rather than copying either declaration, so the
24
+ equality is a fact about the files on disk instead of a fact about two
25
+ documents agreeing with each other.
26
+
27
+ Nothing in the lock is a timestamp, a path, or a process detail. Resolving the
28
+ same reference against the same engine twice produces byte-identical bytes, and
29
+ that is what lets a participant's lock and the publisher's lock be compared by
30
+ digest instead of by narrative.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import tempfile
36
+ from pathlib import Path
37
+ from typing import Final
38
+
39
+ from techtree.canonical import validate_digest
40
+ from techtree.constants import TASKSET_LOCK_SCHEMA_VERSION
41
+ from techtree.engines.bundle import (
42
+ PACKAGES_DIRECTORY,
43
+ package_source_digest,
44
+ read_engine_descriptor,
45
+ )
46
+ from techtree.engines.registry import EngineRegistry
47
+ from techtree.engines.runner import EngineRunner
48
+ from techtree.errors import EngineError, ValidationError, VerificationError
49
+ from techtree.models.base import Digest
50
+ from techtree.models.campaign import TaskSelection, TasksetRef
51
+ from techtree.models.validation import TasksetLock
52
+ from techtree.tasksets.membership import (
53
+ MEMBERSHIP_REPEATABILITY_CHECK,
54
+ TasksetInspection,
55
+ assert_unique_task_hashes,
56
+ compare_membership,
57
+ load_inspection_output,
58
+ membership_digest,
59
+ )
60
+
61
+ __all__ = [
62
+ "INSPECTION_TIMEOUT_SECONDS",
63
+ "INSPECT_TASKSET_TOOL",
64
+ "SUPPORTED_CONFIG_KEYS",
65
+ "TasksetResolver",
66
+ ]
67
+
68
+ #: The engine helper that reports task identity. It lives inside the digested
69
+ #: bundle (decisions document 0003 A3) and is addressed through the registry,
70
+ #: never as a path a caller supplies.
71
+ INSPECT_TASKSET_TOOL: Final = "inspect_taskset.py"
72
+
73
+ #: Loading a taskset imports Verifiers and the reference package, which is slow
74
+ #: on a cold filesystem cache and instant afterwards. The limit exists to end a
75
+ #: hung process, not to police performance.
76
+ INSPECTION_TIMEOUT_SECONDS: Final = 300.0
77
+
78
+ #: The only taskset configuration WP5 can honor. The helper takes the task count
79
+ #: as an argument, so a configuration may restate it and may say nothing else:
80
+ #: a key nothing applies would be a setting that silently does not happen.
81
+ SUPPORTED_CONFIG_KEYS: Final[tuple[str, ...]] = ("num_tasks",)
82
+
83
+ _OUTPUT_FILENAME: Final = "inspection.json"
84
+ _TEMPORARY_PREFIX: Final = "techtree-inspect-"
85
+
86
+
87
+ class TasksetResolver:
88
+ """Resolves one taskset reference against one installed engine."""
89
+
90
+ def __init__(
91
+ self,
92
+ registry: EngineRegistry,
93
+ engine_digest: Digest,
94
+ *,
95
+ timeout_seconds: float = INSPECTION_TIMEOUT_SECONDS,
96
+ ) -> None:
97
+ self._registry = registry
98
+ self._engine_digest = validate_digest(engine_digest)
99
+ self._runner = EngineRunner(registry, self._engine_digest)
100
+ self._timeout_seconds = timeout_seconds
101
+
102
+ def resolve(
103
+ self,
104
+ *,
105
+ taskset_ref: TasksetRef,
106
+ selection: TaskSelection,
107
+ ) -> TasksetLock:
108
+ """Load the taskset twice, enforce determinism, and build the lock."""
109
+ _require_supported_reference(taskset_ref, selection)
110
+ resolved_package_digest = self._package_digest(taskset_ref)
111
+
112
+ first = self._inspect(taskset_ref, selection)
113
+ second = self._inspect(taskset_ref, selection)
114
+
115
+ repeatability = compare_membership(
116
+ second.ordered_task_hashes,
117
+ first.ordered_task_hashes,
118
+ check_id=MEMBERSHIP_REPEATABILITY_CHECK,
119
+ )
120
+ if repeatability.status != "passed":
121
+ raise VerificationError(
122
+ f"taskset {taskset_ref.id} produced a different membership on a "
123
+ f"second load, so it cannot be locked: {repeatability.detail}",
124
+ code="taskset_membership_unstable",
125
+ details={
126
+ "taskset_id": taskset_ref.id,
127
+ "check": repeatability.id,
128
+ "detail": repeatability.detail,
129
+ },
130
+ )
131
+
132
+ ordered_task_hashes = first.ordered_task_hashes
133
+ if len(ordered_task_hashes) != selection.num_tasks:
134
+ raise VerificationError(
135
+ f"taskset {taskset_ref.id} resolved to "
136
+ f"{len(ordered_task_hashes)} tasks but the selection asks for "
137
+ f"{selection.num_tasks}",
138
+ code="taskset_task_count_unexpected",
139
+ details={
140
+ "taskset_id": taskset_ref.id,
141
+ "resolved": len(ordered_task_hashes),
142
+ "expected": selection.num_tasks,
143
+ },
144
+ )
145
+ assert_unique_task_hashes(ordered_task_hashes)
146
+
147
+ return TasksetLock(
148
+ schema_version=TASKSET_LOCK_SCHEMA_VERSION,
149
+ taskset_ref=taskset_ref,
150
+ engine_digest=self._engine_digest,
151
+ resolved_package_digest=resolved_package_digest,
152
+ ordered_task_hashes=ordered_task_hashes,
153
+ membership_digest=membership_digest(ordered_task_hashes),
154
+ task_count=len(ordered_task_hashes),
155
+ )
156
+
157
+ # -- one inspection ----------------------------------------------------
158
+
159
+ def _inspect(
160
+ self, taskset_ref: TasksetRef, selection: TaskSelection
161
+ ) -> TasksetInspection:
162
+ """Run the engine helper once, in its own process, and read its report."""
163
+ script = self._registry.tool_path(self._engine_digest, INSPECT_TASKSET_TOOL)
164
+
165
+ with tempfile.TemporaryDirectory(prefix=_TEMPORARY_PREFIX) as directory:
166
+ output = Path(directory) / _OUTPUT_FILENAME
167
+ result = self._runner.run_python_script(
168
+ script,
169
+ [
170
+ "--taskset-id",
171
+ taskset_ref.id,
172
+ "--num-tasks",
173
+ str(selection.num_tasks),
174
+ "--output",
175
+ str(output),
176
+ ],
177
+ timeout=self._timeout_seconds,
178
+ )
179
+ if result.exit_code != 0:
180
+ raise EngineError(
181
+ f"the engine could not inspect taskset {taskset_ref.id}: "
182
+ f"{_last_line(result.stderr)}",
183
+ code="taskset_inspection_failed",
184
+ details={
185
+ "taskset_id": taskset_ref.id,
186
+ "engine_digest": self._engine_digest,
187
+ "exit_code": result.exit_code,
188
+ },
189
+ )
190
+ inspection = load_inspection_output(output)
191
+
192
+ if inspection.taskset_id != taskset_ref.id:
193
+ raise VerificationError(
194
+ f"the engine inspected {inspection.taskset_id} but "
195
+ f"{taskset_ref.id} was asked for",
196
+ code="taskset_identity_mismatch",
197
+ details={
198
+ "requested": taskset_ref.id,
199
+ "inspected": inspection.taskset_id,
200
+ },
201
+ )
202
+ return inspection
203
+
204
+ # -- the package the engine holds --------------------------------------
205
+
206
+ def _package_digest(self, taskset_ref: TasksetRef) -> Digest:
207
+ """Return the source digest of the installed package, having checked it.
208
+
209
+ Decisions document 0003 A8. Two of the three required equalities are
210
+ settled here, against the recomputed tree: the engine descriptor's
211
+ declaration and the Campaign's commitment. The third is the lock's own
212
+ field, which is this return value.
213
+ """
214
+ engine_root = self._registry.path(self._engine_digest)
215
+ package_name = taskset_ref.package.name
216
+ package_root = engine_root / PACKAGES_DIRECTORY / package_name
217
+
218
+ if not package_root.is_dir():
219
+ raise EngineError(
220
+ f"engine {self._engine_digest} ships no package named {package_name}",
221
+ code="engine_package_missing",
222
+ details={
223
+ "engine_digest": self._engine_digest,
224
+ "package": package_name,
225
+ },
226
+ )
227
+
228
+ resolved = package_source_digest(package_root)
229
+ declared = _declared_source_digest(engine_root, package_name)
230
+
231
+ if declared != resolved:
232
+ raise VerificationError(
233
+ f"the {package_name} source tree inside engine "
234
+ f"{self._engine_digest} hashes to {resolved}, but the engine "
235
+ f"descriptor declares {declared}",
236
+ code="engine_package_digest_mismatch",
237
+ details={
238
+ "package": package_name,
239
+ "resolved": resolved,
240
+ "declared": declared,
241
+ },
242
+ )
243
+ if taskset_ref.package.digest != resolved:
244
+ raise VerificationError(
245
+ f"the Campaign commits to {package_name} at "
246
+ f"{taskset_ref.package.digest}, but this engine holds {resolved}",
247
+ code="taskset_package_digest_mismatch",
248
+ details={
249
+ "package": package_name,
250
+ "committed": taskset_ref.package.digest,
251
+ "resolved": resolved,
252
+ },
253
+ )
254
+ return resolved
255
+
256
+
257
+ def _declared_source_digest(engine_root: Path, package_name: str) -> Digest:
258
+ """Return what the installed engine's descriptor says one package hashes to."""
259
+ descriptor = read_engine_descriptor(engine_root)
260
+ for package in descriptor.packages:
261
+ if package.name == package_name:
262
+ return package.source_digest
263
+ raise EngineError(
264
+ f"the engine descriptor lists no package named {package_name}",
265
+ code="engine_package_missing",
266
+ details={
267
+ "package": package_name,
268
+ "available": [entry.name for entry in descriptor.packages],
269
+ },
270
+ )
271
+
272
+
273
+ def _require_supported_reference(
274
+ taskset_ref: TasksetRef, selection: TaskSelection
275
+ ) -> None:
276
+ """Refuse a reference this work package cannot resolve honestly."""
277
+ if taskset_ref.package.kind != "embedded":
278
+ raise ValidationError(
279
+ f"WP5 resolves the embedded reference package only, not a "
280
+ f"{taskset_ref.package.kind} package",
281
+ code="taskset_package_kind_unsupported",
282
+ details={"kind": taskset_ref.package.kind, "package": taskset_ref.id},
283
+ )
284
+
285
+ unsupported = sorted(set(taskset_ref.config) - set(SUPPORTED_CONFIG_KEYS))
286
+ if unsupported:
287
+ raise ValidationError(
288
+ "the taskset configuration sets "
289
+ + ", ".join(unsupported)
290
+ + ", and nothing in WP5 would apply it",
291
+ code="taskset_config_unsupported",
292
+ details={
293
+ "unsupported_keys": list(unsupported),
294
+ "supported_keys": list(SUPPORTED_CONFIG_KEYS),
295
+ },
296
+ )
297
+
298
+ configured = taskset_ref.config.get("num_tasks")
299
+ if configured is not None and configured != selection.num_tasks:
300
+ raise ValidationError(
301
+ f"the taskset configuration asks for {configured} tasks but the "
302
+ f"selection asks for {selection.num_tasks}",
303
+ code="taskset_config_conflict",
304
+ details={"config": configured, "selection": selection.num_tasks},
305
+ )
306
+
307
+
308
+ def _last_line(text: str) -> str:
309
+ """Return the last meaningful line of a child process's diagnostics."""
310
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
311
+ return lines[-1] if lines else "the engine reported no diagnostics"