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,1242 @@
1
+ """Turning one signed report into one neutral payload. Spec section 7.14.
2
+
3
+ Everything a reader is shown comes from here, and everything here comes from
4
+ the signed report and the signed receipts it commits to. That is the whole
5
+ design: a renderer receives numbers it cannot recompute, statuses it cannot
6
+ soften, and caveats it cannot drop, so two channels showing the same run cannot
7
+ say different things about it.
8
+
9
+ Four rules shape what is built.
10
+
11
+ *The report decides, the builder describes.* No score is recomputed, no verdict
12
+ is re-derived, and no status is interpreted. ``decision``, ``proof_grade`` and
13
+ the comparison status are copied; the caveats are the plain-English reading of
14
+ those same fields rather than a second opinion about them.
15
+
16
+ *Warnings are carried, never smoothed.* A real comparison in this build is
17
+ usually ``controlled_with_warnings`` — a provider that publishes no model
18
+ revision, a daemon that reports no resolved image digest — and that is a fact
19
+ about how strongly the run is attested. It arrives as a caveat with warning
20
+ severity, in front of the reader, in every channel.
21
+
22
+ *A rejected candidate is a measurement.* The decision is stated as what it is.
23
+ A Skill that did not clear the Campaign's declared threshold produced evidence
24
+ about that Skill, and framing it as a failure of the run would be both
25
+ discouraging and wrong.
26
+
27
+ *Nothing is offered that does not exist.* ``next_actions`` names commands this
28
+ build actually has. Section 7.14's four steps are all present for a graded
29
+ result: look at the tasks, export the context a host agent proposes a revision
30
+ from, prepare the v1-against-v2 comparison, and verify the local proof. The
31
+ reasoning turn itself is WP9+'s, so what is offered is the deterministic
32
+ command that produces its input rather than a promise to think.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from collections.abc import Sequence
38
+ from dataclasses import dataclass
39
+ from typing import Final, Literal
40
+
41
+ from techtree.errors import PrerequisiteError
42
+ from techtree.identity.models import VerificationResult
43
+ from techtree.models.campaign import CampaignSpec
44
+ from techtree.models.cli import NextAction
45
+ from techtree.models.episode_receipt import EpisodeReceipt
46
+ from techtree.models.skill import SkillArtifact
47
+ from techtree.models.uplift_report import (
48
+ ComparisonStatus,
49
+ TaskDelta,
50
+ UpliftDecision,
51
+ UpliftReport,
52
+ )
53
+ from techtree.presentation.evidence import RecordedEvidence
54
+ from techtree.presentation.models import (
55
+ PRESENTATION_SCHEMA_VERSION,
56
+ DerivedCost,
57
+ EconomicsSource,
58
+ PresentationCaveat,
59
+ ScoreBar,
60
+ SkillSummary,
61
+ TaskOutcome,
62
+ TaskResultRow,
63
+ UpliftPresentationPayload,
64
+ )
65
+ from techtree.presentation.sanitize import (
66
+ ensure_no_hidden_task_material,
67
+ sanitize_label,
68
+ )
69
+ from techtree.publication.offer import publish_action
70
+ from techtree.receipts.compare import (
71
+ MODEL_REVISION_UNDISCOVERABLE,
72
+ weaker_claim_warnings,
73
+ )
74
+ from techtree.receipts.execution import (
75
+ ComparisonExecutionRecord,
76
+ CostProvenance,
77
+ TotalCost,
78
+ VariantUsage,
79
+ )
80
+ from techtree.verifiers.budget import price_profile_for
81
+
82
+ __all__ = [
83
+ "BASELINE_SKILL_LABEL",
84
+ "FIRST_CHANGE_LABEL",
85
+ "FIRST_RESULT_LABEL",
86
+ "HELD_FIXED_LINE",
87
+ "LATER_RESULT_LABEL",
88
+ "NOT_BROAD_CAPABILITY_LINE",
89
+ "P1_MEANING",
90
+ "SCORE_BAR_WIDTH",
91
+ "SECOND_CHANGE_LABEL",
92
+ "SECOND_RESULT_LABEL",
93
+ "VERIFICATION_FAILED",
94
+ "VERIFICATION_NOT_VERIFIED",
95
+ "VERIFICATION_VERIFIED",
96
+ "build_uplift_presentation",
97
+ "cost_explanation",
98
+ "cost_summary",
99
+ "decision_headline",
100
+ "efficiency_sentence",
101
+ "score_bars",
102
+ "solved_line",
103
+ "task_count_line",
104
+ "task_counts",
105
+ ]
106
+
107
+ #: What a Skill-insertion comparison measures against. Not an absent value,
108
+ #: and not what a replacement measures against: a replacement's baseline is a
109
+ #: Skill, and it is called by its own name.
110
+ BASELINE_SKILL_LABEL: Final = "No tested Skill"
111
+
112
+ #: What each result is called. Decisions document 0009 fixes the spellings: the
113
+ #: first result is the receipt for adding a Skill, and a later one is the same
114
+ #: comparison run again with a revised Skill. Which of them a reader is holding
115
+ #: is worked out from the Skills the run itself carries, never assumed — a
116
+ #: third comparison labelled as the second would be a receipt for work nobody
117
+ #: did.
118
+ FIRST_RESULT_LABEL: Final = "Hello World Uplift Receipt"
119
+ SECOND_RESULT_LABEL: Final = "Hello World — Iteration 2"
120
+ LATER_RESULT_LABEL: Final = "Hello World — A Later Iteration"
121
+
122
+ #: The other half of decisions document 0019 section 3's second statement, and
123
+ #: the whole of its first: one changed Skill, and everything else the same. It
124
+ #: is a constant rather than a sentence each renderer writes, because a channel
125
+ #: that made a weaker version of this claim than another channel would be the
126
+ #: one somebody quoted.
127
+ HELD_FIXED_LINE: Final = (
128
+ "Everything else was the same on both sides: the same model sampled the "
129
+ "same way, the same harness and tools, the same runtime image, the same "
130
+ "tasks in the same order, the same reward, and the same declared limits."
131
+ )
132
+
133
+ #: What changed, in the words decisions document 0019 section 1 fixes. The two
134
+ #: sides are named as roles rather than by digest, because the digests are
135
+ #: printed underneath and a reader needs the shape of the comparison first.
136
+ FIRST_CHANGE_LABEL: Final = "No tested Skill → Skill v1"
137
+ SECOND_CHANGE_LABEL: Final = "Skill v1 → Skill v2"
138
+
139
+ #: Decisions document 0005 section 3.4, verbatim. The only words this build is
140
+ #: permitted to explain ``P1`` with.
141
+ P1_MEANING: Final = "integrity-bound, participant-attested local execution"
142
+
143
+ #: What each decision actually established, said as what was measured rather
144
+ #: than as a verdict that was won.
145
+ #:
146
+ #: The bar on this Climb is to beat a baseline of zero by any margin at all, on
147
+ #: a synthetic task family, at proof grade P1, with no publication eligibility.
148
+ #: "Accepted" and "met the threshold" are both literally true of that and both
149
+ #: read as a benchmark somebody passed, which is the one thing this result is
150
+ #: not. So the accepted case says what it can defend — that the Skill improved
151
+ #: on this particular task family — and the standing line underneath says what
152
+ #: it cannot.
153
+ #:
154
+ #: An accepted decision always means the candidate out-scored the baseline:
155
+ #: :func:`~techtree.receipts.uplift.decide_uplift` reaches it only through a
156
+ #: positive delta. A rejected one may still have moved the score a little, so
157
+ #: it is described by the bar it fell under rather than as no improvement.
158
+ DECISION_HEADLINE: Final[dict[str, str]] = {
159
+ "accepted": "Improved on this development task family",
160
+ "rejected": "Did not clear the bar this Climb declared",
161
+ "inconclusive": "Not decided: this Climb declared no rule that could decide it",
162
+ "invalid": "Not valid: this comparison cannot carry a result",
163
+ "development_only": "Development-only: this report states no verdict",
164
+ }
165
+
166
+ #: The sentence that stops any of the headlines above being read as a claim
167
+ #: about what the Skill can do in general. It is not a footer: decisions
168
+ #: document 0013's toy/synthetic boundary belongs in the first lines, beside
169
+ #: the number a reader would otherwise quote on its own.
170
+ NOT_BROAD_CAPABILITY_LINE: Final = "Not broad-capability evidence"
171
+
172
+ #: How wide a score bar is drawn, in cells. Fixed so two bars are comparable
173
+ #: and so a rendering is the same width on every terminal.
174
+ SCORE_BAR_WIDTH: Final = 24
175
+
176
+ VERIFICATION_VERIFIED: Final = "verified_offline"
177
+ VERIFICATION_FAILED: Final = "verification_failed"
178
+ VERIFICATION_NOT_VERIFIED: Final = "not_verified"
179
+
180
+ _FILLED: Final = "█"
181
+ _EMPTY: Final = "·"
182
+
183
+ #: What one task is worth when it is got right. The rewards this build measures
184
+ #: are all-or-nothing, and a headline count is only offered for a reward that
185
+ #: is: see :func:`_tasks_scored_full`.
186
+ _FULL_SCORE: Final = 1.0
187
+
188
+ #: The unit the recorded prices are quoted in.
189
+ _TOKENS_PER_MILLION: Final = 1_000_000.0
190
+
191
+
192
+ def build_uplift_presentation(
193
+ *,
194
+ report: UpliftReport,
195
+ campaign: CampaignSpec,
196
+ baseline_receipts: Sequence[EpisodeReceipt],
197
+ candidate_receipts: Sequence[EpisodeReceipt],
198
+ campaign_title: str,
199
+ baseline_skill: SkillArtifact | None,
200
+ candidate_skill: SkillArtifact,
201
+ verification: VerificationResult | None,
202
+ execution_record: ComparisonExecutionRecord | None = None,
203
+ recorded_evidence: RecordedEvidence | None = None,
204
+ ) -> UpliftPresentationPayload:
205
+ """Build a channel-neutral presentation payload from a signed report.
206
+
207
+ ``verification`` is the result of checking the run's proof bundle offline,
208
+ or ``None`` when the reader asked not to check it. The three states are
209
+ kept apart in ``verification_status`` because "not checked" and "checked
210
+ and failed" are different things to tell somebody, and a development-only
211
+ report has no proof to check at all.
212
+
213
+ ``execution_record`` is decisions document 0007 R6's signed operational
214
+ record, when the run carries one. It is the only source of a *reported*
215
+ cost figure and the preferred source of tokens and timing; a payload built
216
+ without one says so in ``economics_source`` and carries the
217
+ operational-evidence caveat rather than a number nobody signed.
218
+
219
+ ``campaign`` is the run's own signed Campaign. It says which model was
220
+ measured, which is what lets a cost be worked out from the prices this
221
+ release recorded and what lets the weaker-claim warning name its
222
+ coordinate instead of gesturing at one.
223
+
224
+ ``recorded_evidence`` is what
225
+ :func:`~techtree.presentation.evidence.read_recorded_evidence` read back
226
+ out of the run's own digest-checked files, when they are still there. It
227
+ adds nothing to any artifact; it is counted while the result is drawn.
228
+ """
229
+ economics = _economics(execution_record, baseline_receipts, candidate_receipts)
230
+ task_rows = _task_rows(report.task_deltas)
231
+ scored_full = _tasks_scored_full(task_rows)
232
+ seen = recorded_evidence
233
+ baseline_seen = None if seen is None else seen.baseline
234
+ candidate_seen = None if seen is None else seen.candidate
235
+ derived = _derived_cost(campaign, execution_record, economics)
236
+ unavailable = (
237
+ None
238
+ if derived is not None or economics.cost.cost_usd is not None
239
+ else _cost_unavailable_reason(campaign, execution_record, economics)
240
+ )
241
+ generation = _generation(baseline_skill)
242
+ primary = report.primary_result
243
+ payload = UpliftPresentationPayload(
244
+ schema_version=PRESENTATION_SCHEMA_VERSION,
245
+ run_id=report.run_id,
246
+ campaign_title=sanitize_label(campaign_title),
247
+ comparison_label=_comparison_label(generation),
248
+ change_label=_change_label(generation, baseline_skill, candidate_skill),
249
+ baseline_skill=_skill_summary(baseline_skill, _baseline_label(baseline_skill)),
250
+ candidate_skill=_skill_summary(candidate_skill, candidate_skill.name),
251
+ baseline_score=primary.baseline_mean,
252
+ candidate_score=primary.candidate_mean,
253
+ absolute_delta=primary.absolute_delta,
254
+ relative_delta=primary.relative_delta,
255
+ wins=primary.wins,
256
+ losses=primary.losses,
257
+ ties=primary.ties,
258
+ task_rows=task_rows,
259
+ baseline_tasks_scored_full=scored_full[0],
260
+ candidate_tasks_scored_full=scored_full[1],
261
+ baseline_tokens=economics.baseline_tokens,
262
+ candidate_tokens=economics.candidate_tokens,
263
+ baseline_seconds=economics.baseline_seconds,
264
+ candidate_seconds=economics.candidate_seconds,
265
+ baseline_model_turns=(
266
+ None if baseline_seen is None else baseline_seen.model_turns
267
+ ),
268
+ candidate_model_turns=(
269
+ None if candidate_seen is None else candidate_seen.model_turns
270
+ ),
271
+ baseline_rate_limited_calls=(
272
+ None if baseline_seen is None else baseline_seen.rate_limited_calls
273
+ ),
274
+ candidate_rate_limited_calls=(
275
+ None if candidate_seen is None else candidate_seen.rate_limited_calls
276
+ ),
277
+ every_rollout_completed=(
278
+ None if seen is None else seen.every_rollout_completed
279
+ ),
280
+ economics_source=economics.source,
281
+ cost_usd=economics.cost.cost_usd,
282
+ cost_provenance=economics.cost.provenance,
283
+ derived_cost=derived,
284
+ cost_unavailable_reason=unavailable,
285
+ decision=report.decision.value,
286
+ proof_grade=report.proof_grade,
287
+ verification_status=_verification_status(verification),
288
+ caveats=_caveats(
289
+ report=report,
290
+ campaign=campaign,
291
+ verification=verification,
292
+ economics=economics,
293
+ recorded_evidence=recorded_evidence,
294
+ derived=derived,
295
+ ),
296
+ next_actions=_next_actions(
297
+ report.run_id,
298
+ report.proof_grade,
299
+ # Publishing is offered for a run whose proof was checked here and
300
+ # held together, and for no other run. A result nobody verified is
301
+ # not a result anybody should be invited to send anywhere.
302
+ verified=verification is not None and verification.verified,
303
+ publication_eligible=report.publication_eligible,
304
+ ),
305
+ )
306
+ ensure_no_hidden_task_material(payload)
307
+ return payload
308
+
309
+
310
+ def task_counts(payload: UpliftPresentationPayload) -> tuple[int, int, int] | None:
311
+ """Return each side's count and the total, in the unit a person counts in.
312
+
313
+ A mean is what the report holds and a count is what a reader thinks in, and
314
+ the two are the same fact for an all-or-nothing reward. A reward that is not
315
+ all-or-nothing has no such count, and this returns nothing rather than
316
+ rounding one into existence.
317
+
318
+ The three numbers are returned rather than one sentence because the two
319
+ channels have room for different words around them and must never be free
320
+ to disagree about the numbers themselves.
321
+ """
322
+ baseline = payload.baseline_tasks_scored_full
323
+ candidate = payload.candidate_tasks_scored_full
324
+ if baseline is None or candidate is None:
325
+ return None
326
+ return baseline, candidate, len(payload.task_rows)
327
+
328
+
329
+ def task_count_line(payload: UpliftPresentationPayload) -> str | None:
330
+ """Return the count, both sides and the movement, as one line."""
331
+ counted = task_counts(payload)
332
+ if counted is None:
333
+ return None
334
+ baseline, candidate, total = counted
335
+ return f"{baseline} of {total} → {candidate} of {total} ({candidate - baseline:+d})"
336
+
337
+
338
+ def decision_headline(payload: UpliftPresentationPayload) -> str:
339
+ """Return what this comparison established, in one line."""
340
+ return DECISION_HEADLINE[payload.decision]
341
+
342
+
343
+ def solved_line(payload: UpliftPresentationPayload) -> str:
344
+ """Return the count a reader can act on: solved, still failing, regressed.
345
+
346
+ In a Skill-insertion comparison the baseline scores nothing on every task,
347
+ so a tie means both sides failed the task rather than that nothing moved.
348
+ Reporting wins and ties would then read as a clean sweep while hiding the
349
+ most actionable fact in the run, which is how many tasks are still failing.
350
+ Wins, losses and ties stay in the per-task table and in the machine
351
+ envelope, where they are read by something that knows what they mean.
352
+
353
+ A reward that is not all-or-nothing carries no count of solved tasks (see
354
+ :func:`_tasks_scored_full`), and nothing is inferred in its absence: the
355
+ regressions are reported on their own rather than beside a number this
356
+ build would have had to invent.
357
+ """
358
+ regressions = f"{payload.losses} regression{'' if payload.losses == 1 else 's'}"
359
+ counted = task_counts(payload)
360
+ if counted is None:
361
+ return regressions
362
+ _, candidate, total = counted
363
+ return (
364
+ f"Solved {candidate} of {total} · {total - candidate} still failing · "
365
+ f"{regressions}"
366
+ )
367
+
368
+
369
+ def efficiency_sentence(payload: UpliftPresentationPayload) -> str | None:
370
+ """Return what the candidate saved against the baseline, on this run.
371
+
372
+ The two sides' turn counts, token totals and clocks are the most striking
373
+ measurement most runs produce, and a pair of numbers on adjacent lines is
374
+ not a finding. The difference is, so it is stated — and stated as a fact
375
+ about this one controlled comparison, because without that anchor the
376
+ percentages read as a general claim about what Skills do, which nothing
377
+ here measures.
378
+
379
+ Every figure needs both of its sides. A measurement one side did not record
380
+ is left out entirely rather than compared against a zero, and a side that
381
+ spent exactly what the other did contributes no difference to report. A
382
+ comparison with no differences left to report gets no sentence at all.
383
+ """
384
+ turns = _saving(
385
+ payload.baseline_model_turns,
386
+ payload.candidate_model_turns,
387
+ fewer="took {count} fewer model turn{s}",
388
+ more="took {count} more model turn{s}",
389
+ )
390
+ tokens = _saving(
391
+ payload.baseline_tokens,
392
+ payload.candidate_tokens,
393
+ fewer="used {count} fewer token{s}",
394
+ more="used {count} more token{s}",
395
+ )
396
+ clock = _saving(
397
+ payload.baseline_seconds,
398
+ payload.candidate_seconds,
399
+ fewer="finished {count} seconds sooner",
400
+ more="finished {count} seconds later",
401
+ )
402
+ savings = [phrase for phrase in (turns, tokens, clock) if phrase is not None]
403
+ if not savings:
404
+ return None
405
+ counted = turns is not None or tokens is not None
406
+ return (
407
+ f"On this controlled run the Skill {_listed(savings)}. "
408
+ f"{_WORK_OR_WEATHER[counted, clock is not None]}"
409
+ )
410
+
411
+
412
+ #: What the numbers above are properties of. Turn and token counts are
413
+ #: properties of the work and reproduce; a clock also reads this machine and
414
+ #: how busy the provider was that afternoon, and a reader deciding what to
415
+ #: repeat needs to know which of the two they are holding.
416
+ _WORK_OR_WEATHER: Final[dict[tuple[bool, bool], str]] = {
417
+ (True, True): (
418
+ "Those counts are properties of the work; how long each side took also "
419
+ "depends on this machine and on how busy the provider was."
420
+ ),
421
+ (True, False): "Those counts are properties of the work.",
422
+ (False, True): (
423
+ "How long each side took depends on this machine and on how busy the "
424
+ "provider was, as well as on the work."
425
+ ),
426
+ }
427
+
428
+
429
+ def _saving(
430
+ baseline: float | int | None,
431
+ candidate: float | int | None,
432
+ *,
433
+ fewer: str,
434
+ more: str,
435
+ ) -> str | None:
436
+ """Return one measured difference, or nothing where there is not one."""
437
+ if baseline is None or candidate is None or baseline == candidate:
438
+ return None
439
+ difference = abs(baseline - candidate)
440
+ count = f"{difference:,.1f}" if isinstance(difference, float) else f"{difference:,}"
441
+ phrase = (fewer if candidate < baseline else more).format(
442
+ count=count, s="" if difference == 1 else "s"
443
+ )
444
+ if not baseline:
445
+ return phrase
446
+ return f"{phrase} ({difference / baseline:.0%})"
447
+
448
+
449
+ def _listed(phrases: list[str]) -> str:
450
+ """Join what was measured into one readable list."""
451
+ if len(phrases) == 1:
452
+ return phrases[0]
453
+ if len(phrases) == 2:
454
+ return f"{phrases[0]} and {phrases[1]}"
455
+ return f"{', '.join(phrases[:-1])}, and {phrases[-1]}"
456
+
457
+
458
+ def _calls(count: int) -> str:
459
+ """Return a count of model calls, in the number the count actually is."""
460
+ return f"{count:,} model call" if count == 1 else f"{count:,} model calls"
461
+
462
+
463
+ def cost_summary(payload: UpliftPresentationPayload) -> str:
464
+ """Return the figure and, in the same breath, what kind of figure it is.
465
+
466
+ Decisions document 0007 R6: a number that was worked out and a number that
467
+ was billed are different claims, and the one word that tells them apart
468
+ travels with the number rather than somewhere below it.
469
+ """
470
+ if payload.cost_usd is not None:
471
+ return f"${payload.cost_usd:.2f}, {_COST_PROVENANCE[payload.cost_provenance]}"
472
+ if payload.derived_cost is not None:
473
+ return f"about ${payload.derived_cost.usd:.2f}, worked out here, not billed"
474
+ return "unavailable"
475
+
476
+
477
+ def cost_explanation(payload: UpliftPresentationPayload) -> list[str]:
478
+ """Return what a reader needs in order to judge the figure above it."""
479
+ if payload.cost_usd is not None:
480
+ return []
481
+ derived = payload.derived_cost
482
+ if derived is None:
483
+ assert payload.cost_unavailable_reason is not None
484
+ return [payload.cost_unavailable_reason]
485
+ lines = [
486
+ f"Computed from {derived.input_tokens:,} input and "
487
+ f"{derived.output_tokens:,} output tokens at the prices this release "
488
+ "recorded. Your provider's bill is what you actually pay."
489
+ ]
490
+ cached = derived.cached_input_tokens
491
+ if cached and not derived.prices_name_a_cached_rate:
492
+ lines.append(
493
+ f"{cached:,} of those input tokens came back from the provider's "
494
+ "cache. The recorded prices name no separate rate for those, so "
495
+ "every token is priced at the full rate and the figure above is "
496
+ "on the high side."
497
+ )
498
+ return lines
499
+
500
+
501
+ def score_bars(payload: UpliftPresentationPayload) -> list[ScoreBar]:
502
+ """Return the two bars a renderer draws the headline with.
503
+
504
+ The scale is the larger of the two scores or one, so a reward that is
505
+ bounded at 1.0 draws against a full bar and one that is not stays
506
+ comparable between the two sides.
507
+ """
508
+ maximum = max(1.0, payload.baseline_score, payload.candidate_score)
509
+ return [
510
+ ScoreBar(
511
+ label=label,
512
+ value=value,
513
+ maximum=maximum,
514
+ display=_bar(value, maximum),
515
+ )
516
+ for label, value in (
517
+ ("Baseline", payload.baseline_score),
518
+ ("Candidate", payload.candidate_score),
519
+ )
520
+ ]
521
+
522
+
523
+ # ---------------------------------------------------------------------------
524
+ # The pieces
525
+ # ---------------------------------------------------------------------------
526
+
527
+
528
+ def _bar(value: float, maximum: float) -> str:
529
+ """Return one fixed-width bar, plus the number it draws."""
530
+ filled = (
531
+ 0
532
+ if value <= 0
533
+ else min(SCORE_BAR_WIDTH, round(SCORE_BAR_WIDTH * value / maximum))
534
+ )
535
+ return f"{_FILLED * filled}{_EMPTY * (SCORE_BAR_WIDTH - filled)} {value:.3f}"
536
+
537
+
538
+ def _generation(baseline_skill: SkillArtifact | None) -> int | None:
539
+ """Return which comparison in the chain this is, or ``None`` when unknown.
540
+
541
+ A :class:`~techtree.models.skill.SkillArtifact` names the Skill it revises
542
+ and nothing further back, so the chain a run can see is the chain its own
543
+ two Skills record. That is enough to tell the first two apart, which is the
544
+ distinction that matters: a baseline carrying no Skill is the first
545
+ comparison, and a baseline carrying a Skill that revised nothing is the
546
+ second. A baseline that is itself a revision means the chain runs deeper
547
+ than this run can count, and the honest answer there is that the number is
548
+ not known rather than a number that happens to be two.
549
+ """
550
+ if baseline_skill is None:
551
+ return 1
552
+ if baseline_skill.parent_skill_digest is None:
553
+ return 2
554
+ return None
555
+
556
+
557
+ def _comparison_label(generation: int | None) -> str:
558
+ """Return which result in the chain a reader is looking at.
559
+
560
+ Decisions document 0009 names the first two. Anything further along is
561
+ named as what it is — a later iteration — because a receipt that claimed
562
+ an ordinal the run cannot prove would be worse than one that does not
563
+ claim an ordinal at all. Which Skill sat on each side is spelled out under
564
+ "what changed" rather than compressed into this line.
565
+ """
566
+ if generation == 1:
567
+ return FIRST_RESULT_LABEL
568
+ if generation == 2:
569
+ return SECOND_RESULT_LABEL
570
+ return LATER_RESULT_LABEL
571
+
572
+
573
+ def _change_label(
574
+ generation: int | None,
575
+ baseline_skill: SkillArtifact | None,
576
+ candidate_skill: SkillArtifact,
577
+ ) -> str:
578
+ """Return the one change this comparison measured, as an arrow.
579
+
580
+ Decisions document 0019 section 1 fixes the wording of the first two. Once
581
+ the chain runs deeper than the run can number, the two Skills are named by
582
+ their own names instead, which is always true of them.
583
+ """
584
+ if generation == 1:
585
+ return FIRST_CHANGE_LABEL
586
+ if generation == 2:
587
+ return SECOND_CHANGE_LABEL
588
+ assert baseline_skill is not None
589
+ return (
590
+ f"{sanitize_label(baseline_skill.name)} → "
591
+ f"{sanitize_label(candidate_skill.name)}"
592
+ )
593
+
594
+
595
+ def _baseline_label(baseline_skill: SkillArtifact | None) -> str:
596
+ """Return what the baseline side is called.
597
+
598
+ Decisions document 0019 section 1: a baseline is a role, not the absence of
599
+ a Skill. A replacement's baseline carries the Skill being revised, and
600
+ naming it "No tested Skill" beside that Skill's own digest would print a
601
+ contradiction.
602
+ """
603
+ if baseline_skill is None:
604
+ return BASELINE_SKILL_LABEL
605
+ return baseline_skill.name
606
+
607
+
608
+ def _skill_summary(skill: SkillArtifact | None, label: str) -> SkillSummary:
609
+ """Summarize one side's Skill by size and content address."""
610
+ if skill is None:
611
+ return SkillSummary(
612
+ label=sanitize_label(label), root_digest=None, file_count=0, total_bytes=0
613
+ )
614
+ return SkillSummary(
615
+ label=sanitize_label(label),
616
+ root_digest=skill.root_digest,
617
+ file_count=len(skill.files),
618
+ total_bytes=sum(file.size for file in skill.files),
619
+ )
620
+
621
+
622
+ def _task_rows(deltas: Sequence[TaskDelta]) -> list[TaskResultRow]:
623
+ """Return one row per committed task, in the report's own order."""
624
+ return [
625
+ TaskResultRow(
626
+ position=position,
627
+ task_label=_task_label(position, delta.task_hash),
628
+ baseline_score=delta.baseline_reward,
629
+ candidate_score=delta.candidate_reward,
630
+ delta=delta.delta,
631
+ outcome=_outcome(delta),
632
+ )
633
+ for position, delta in enumerate(deltas)
634
+ ]
635
+
636
+
637
+ def _task_label(position: int, task_hash: str) -> str:
638
+ """Name one task by its place and the first cell of its hash.
639
+
640
+ The taskset's public names are not carried in a receipt, and a hash is not
641
+ hidden material: it is the same identifier the TasksetLock commits to. The
642
+ prefix is enough to tell two rows apart and to find the task in the lock.
643
+ """
644
+ _, _, hexadecimal = task_hash.partition(":")
645
+ return f"task {position + 1:02d} · {hexadecimal[:8]}"
646
+
647
+
648
+ def _outcome(delta: TaskDelta) -> TaskOutcome:
649
+ """Return which way one task moved."""
650
+ if delta.candidate_reward > delta.baseline_reward:
651
+ return "win"
652
+ if delta.candidate_reward < delta.baseline_reward:
653
+ return "loss"
654
+ return "tie"
655
+
656
+
657
+ def _tasks_scored_full(rows: Sequence[TaskResultRow]) -> tuple[int | None, int | None]:
658
+ """Return how many tasks each side got right, when that is a countable thing.
659
+
660
+ A person reads "24 of 36", not "0.667". The translation is only honest for
661
+ a reward that is all-or-nothing: a task scored 0.4 was not got right and
662
+ was not got wrong, and counting it either way would invent a number the
663
+ report does not contain. So the count is offered only when every score on
664
+ both sides is exactly zero or exactly the full reward, and the mean stands
665
+ alone otherwise.
666
+ """
667
+ if not rows:
668
+ return None, None
669
+ scores = [(row.baseline_score, row.candidate_score) for row in rows]
670
+ if any(value not in (0.0, _FULL_SCORE) for pair in scores for value in pair):
671
+ return None, None
672
+ return (
673
+ sum(1 for baseline, _ in scores if baseline == _FULL_SCORE),
674
+ sum(1 for _, candidate in scores if candidate == _FULL_SCORE),
675
+ )
676
+
677
+
678
+ @dataclass(frozen=True)
679
+ class _Economics:
680
+ """What a payload can honestly say about cost and time, and from where."""
681
+
682
+ source: EconomicsSource
683
+ baseline_tokens: int | None
684
+ candidate_tokens: int | None
685
+ baseline_seconds: float | None
686
+ candidate_seconds: float | None
687
+ cost: TotalCost
688
+
689
+
690
+ def _economics(
691
+ record: ComparisonExecutionRecord | None,
692
+ baseline_receipts: Sequence[EpisodeReceipt],
693
+ candidate_receipts: Sequence[EpisodeReceipt],
694
+ ) -> _Economics:
695
+ """Decide what this payload may say about what the comparison consumed.
696
+
697
+ Decisions document 0007 R6 makes the signed execution record the source of
698
+ this, so it is preferred whenever there is one. Without it, the receipts
699
+ are asked for a token total — they carry one only if the evaluation
700
+ recorded tokens as a metric — and time and cost are simply not known. In
701
+ no branch is a number produced that this payload could not name a source
702
+ for.
703
+ """
704
+ if record is not None:
705
+ return _Economics(
706
+ source="comparison_execution_record",
707
+ baseline_tokens=record.baseline.usage.total_tokens,
708
+ candidate_tokens=record.candidate.usage.total_tokens,
709
+ baseline_seconds=record.baseline.elapsed_seconds,
710
+ candidate_seconds=record.candidate.elapsed_seconds,
711
+ cost=record.total_cost,
712
+ )
713
+
714
+ baseline_tokens = _tokens(baseline_receipts)
715
+ candidate_tokens = _tokens(candidate_receipts)
716
+ recorded = baseline_tokens is not None or candidate_tokens is not None
717
+ return _Economics(
718
+ source="episode_receipts" if recorded else "unavailable",
719
+ baseline_tokens=baseline_tokens,
720
+ candidate_tokens=candidate_tokens,
721
+ baseline_seconds=None,
722
+ candidate_seconds=None,
723
+ cost=TotalCost(cost_usd=None, provenance=CostProvenance.UNAVAILABLE),
724
+ )
725
+
726
+
727
+ def _derived_cost(
728
+ campaign: CampaignSpec,
729
+ record: ComparisonExecutionRecord | None,
730
+ economics: _Economics,
731
+ ) -> DerivedCost | None:
732
+ """Work one comparison's cost out from the tokens it recorded, or return none.
733
+
734
+ Only ever reached when the provider reported no cost of its own: a figure
735
+ it billed is the better answer wherever there is one. Nothing computed here
736
+ is written anywhere. It is multiplication, done while the result is drawn,
737
+ over token counts the signed execution record already holds and prices this
738
+ release recorded on a named day.
739
+
740
+ A model this release recorded no prices for produces nothing rather than a
741
+ guess, and so does a run whose sides did not both report their usage. Both
742
+ are said out loud by the caveat rather than shown as a blank.
743
+ """
744
+ if record is None or economics.cost.provenance is not CostProvenance.UNAVAILABLE:
745
+ return None
746
+ usage = (record.baseline.usage, record.candidate.usage)
747
+ input_tokens = _summed(usage, "input_tokens")
748
+ output_tokens = _summed(usage, "output_tokens")
749
+ if input_tokens is None or output_tokens is None:
750
+ return None
751
+ try:
752
+ prices = price_profile_for(campaign.subject.model.model_id)
753
+ except PrerequisiteError:
754
+ return None
755
+
756
+ cached = _summed(usage, "cached_input_tokens")
757
+ return DerivedCost(
758
+ usd=(
759
+ input_tokens * prices.input_usd_per_mtok
760
+ + output_tokens * prices.output_usd_per_mtok
761
+ )
762
+ / _TOKENS_PER_MILLION,
763
+ input_tokens=input_tokens,
764
+ output_tokens=output_tokens,
765
+ # A split that claims more cached input than there was input describes
766
+ # no cache this can price around, so it is carried as unknown rather
767
+ # than repaired into something plausible.
768
+ cached_input_tokens=(
769
+ None if cached is None or cached > input_tokens else cached
770
+ ),
771
+ # The recorded profile quotes one rate per direction and says outright
772
+ # that they are the highest uncached ones. Until a profile distinguishes
773
+ # a cached rate, every input token is priced as though the provider
774
+ # cached none of it, which can only make the figure too high.
775
+ prices_name_a_cached_rate=False,
776
+ model_id=sanitize_label(prices.model_id),
777
+ input_usd_per_mtok=prices.input_usd_per_mtok,
778
+ output_usd_per_mtok=prices.output_usd_per_mtok,
779
+ prices_recorded_on=sanitize_label(prices.recorded_on),
780
+ )
781
+
782
+
783
+ def _summed(usage: tuple[VariantUsage, VariantUsage], field: str) -> int | None:
784
+ """Return both sides' count of one thing, or ``None`` when a side has none."""
785
+ total = 0
786
+ for side in usage:
787
+ recorded = getattr(side, field)
788
+ if recorded is None:
789
+ return None
790
+ total += int(recorded)
791
+ return total
792
+
793
+
794
+ def _tokens(receipts: Sequence[EpisodeReceipt]) -> int | None:
795
+ """Return one side's token total when the receipts carry one.
796
+
797
+ A receipt records whatever metrics the evaluation recorded. The pinned
798
+ build records token usage on the trace rather than in that table, so this
799
+ is ``None`` for every run this release produces, and the caveat that says
800
+ so is emitted rather than a zero that would read as "used no tokens".
801
+ """
802
+ totals: list[float] = []
803
+ for receipt in receipts:
804
+ for traces in receipt.named_traces.values():
805
+ for trace in traces:
806
+ recorded = trace.metrics.get("total_tokens")
807
+ if recorded is None:
808
+ return None
809
+ totals.append(recorded)
810
+ return int(sum(totals)) if totals else None
811
+
812
+
813
+ def _verification_status(verification: VerificationResult | None) -> str:
814
+ """Return the three-state answer to "was this proof checked?"."""
815
+ if verification is None:
816
+ return VERIFICATION_NOT_VERIFIED
817
+ return VERIFICATION_VERIFIED if verification.verified else VERIFICATION_FAILED
818
+
819
+
820
+ #: How each cost provenance is spelled for a person, in one phrase that fits
821
+ #: beside the number. Owned here rather than by either renderer, so that the
822
+ #: terminal and a phone cannot describe one figure two ways.
823
+ _COST_PROVENANCE: Final[dict[CostProvenance, str]] = {
824
+ CostProvenance.PROVIDER_REPORTED: "reported by the provider",
825
+ CostProvenance.COMPUTED_FROM_PINNED_PRICE: "computed from the pinned price",
826
+ CostProvenance.ESTIMATED: "estimated, not billed",
827
+ CostProvenance.UNAVAILABLE: "unavailable",
828
+ }
829
+
830
+
831
+ def _caveats(
832
+ *,
833
+ report: UpliftReport,
834
+ campaign: CampaignSpec,
835
+ verification: VerificationResult | None,
836
+ economics: _Economics,
837
+ recorded_evidence: RecordedEvidence | None,
838
+ derived: DerivedCost | None,
839
+ ) -> list[PresentationCaveat]:
840
+ """Return everything a reader has to know to read the numbers correctly.
841
+
842
+ The order is the order they matter in: what would make the result invalid
843
+ first, then what bounds how much it proves, then the standing facts about
844
+ this release.
845
+ """
846
+ caveats: list[PresentationCaveat] = []
847
+
848
+ if report.proof_grade == "development_only":
849
+ caveats.append(
850
+ PresentationCaveat(
851
+ code="development_only_result",
852
+ severity="error",
853
+ text=(
854
+ "This report is development-only. Its numbers are not "
855
+ "evidence and it withholds a verdict."
856
+ ),
857
+ )
858
+ )
859
+ if verification is not None and not verification.verified:
860
+ failures = verification.failures
861
+ remainder = (
862
+ "" if len(failures) == 1 else f" ({len(failures) - 1} more checks failed)"
863
+ )
864
+ caveats.append(
865
+ PresentationCaveat(
866
+ code="proof_verification_failed",
867
+ severity="error",
868
+ text=(
869
+ "This run's local proof did not verify, so nothing below "
870
+ f"can be relied on: {failures[0].detail}{remainder}."
871
+ ),
872
+ )
873
+ )
874
+ if verification is None and report.proof_grade == "P1":
875
+ caveats.append(
876
+ PresentationCaveat(
877
+ code="proof_not_verified",
878
+ severity="warning",
879
+ text=(
880
+ "The local proof was not checked for this rendering. Run "
881
+ "`techtree proof verify` to check it."
882
+ ),
883
+ )
884
+ )
885
+ if report.statuses.comparison is ComparisonStatus.CONTROLLED_WITH_WARNINGS:
886
+ caveats.append(
887
+ PresentationCaveat(
888
+ code="comparison_controlled_with_warnings",
889
+ severity="warning",
890
+ text=_weak_attestation_text(campaign),
891
+ )
892
+ )
893
+ if report.proof_grade == "P1":
894
+ caveats.append(
895
+ PresentationCaveat(
896
+ code="local_participant_attestation",
897
+ severity="warning",
898
+ text=(
899
+ f"Proof grade P1 means {P1_MEANING}. Your own local key "
900
+ "vouches for bytes that verify against each other — not "
901
+ "for who ran them."
902
+ ),
903
+ )
904
+ )
905
+ caveats.append(
906
+ PresentationCaveat(
907
+ code="no_independent_reproduction",
908
+ severity="warning",
909
+ text=(
910
+ "Nobody has independently reproduced this comparison, and no "
911
+ "platform witnessed it."
912
+ ),
913
+ )
914
+ )
915
+ caveats.append(
916
+ PresentationCaveat(
917
+ code="introductory_task_family",
918
+ severity="warning",
919
+ text=(
920
+ "This is a toy introductory Climb. Its task family is "
921
+ "synthetic and demonstrates the mechanism; it measures no "
922
+ "broad capability."
923
+ ),
924
+ )
925
+ )
926
+ caveats.append(
927
+ PresentationCaveat(
928
+ code="no_server_upload",
929
+ severity="info",
930
+ text=(
931
+ "The raw episodes stay on this machine. They are not in the "
932
+ "proof directory and nothing sends them: publishing a run "
933
+ "sends its proof and never its episodes, and nothing is "
934
+ "published unless you publish this run yourself. Model "
935
+ "inference was still sent to the model provider this run "
936
+ "used, under that provider's policies."
937
+ ),
938
+ )
939
+ )
940
+ caveats.append(
941
+ PresentationCaveat(
942
+ # Spec section 7.15 calls this "no Relay requirement". The reader
943
+ # is a participant rather than an architect, so the caveat says
944
+ # what it means for them and names no internal component.
945
+ code="no_external_evidence_service",
946
+ severity="info",
947
+ text="No external evidence service is required, used, or contacted.",
948
+ )
949
+ )
950
+ throttling = _throttling_caveat(recorded_evidence)
951
+ if throttling is not None:
952
+ caveats.append(throttling)
953
+ caveats.append(_economics_caveat(economics, derived))
954
+ if report.decision is UpliftDecision.REJECTED:
955
+ caveats.append(
956
+ PresentationCaveat(
957
+ code="rejected_is_evidence",
958
+ severity="info",
959
+ text=(
960
+ "A rejected candidate is a measurement, not a failed run: "
961
+ "this Skill did not meet the threshold the Campaign "
962
+ "declared in advance."
963
+ ),
964
+ )
965
+ )
966
+ return caveats
967
+
968
+
969
+ def _weak_attestation_text(campaign: CampaignSpec) -> str:
970
+ """Say which declared coordinate the run could not confirm, when it can.
971
+
972
+ "At least one declared coordinate could not be confirmed" is true and
973
+ useless: a reader cannot weigh a warning whose subject is withheld. The
974
+ subject is not guessed here — the same function the comparison itself used
975
+ to decide whether to raise this warning is asked again, over the run's own
976
+ signed Campaign, and the coordinate is named only when that answer names
977
+ it. A cause this build does not have a plain sentence for keeps the honest
978
+ general wording rather than borrowing the model-revision one.
979
+ """
980
+ recorded = weaker_claim_warnings(campaign)
981
+ coordinates = {check.id for check in recorded}
982
+ if coordinates == {MODEL_REVISION_UNDISCOVERABLE}:
983
+ named = (
984
+ "Your provider publishes no immutable build identifier for "
985
+ f"{sanitize_label(campaign.subject.model.model_id)}, so both sides "
986
+ "provably used the same model name but not provably the same model "
987
+ "build."
988
+ )
989
+ else:
990
+ named = (
991
+ "At least one declared coordinate could not be confirmed from what "
992
+ "the run observed, and this build has no plainer name for it."
993
+ )
994
+ return (
995
+ "The comparison is controlled with warnings, which means one "
996
+ f"coordinate is attested more weakly than the rest. {named} No "
997
+ "mismatch was found; a mismatch would have made the comparison invalid."
998
+ )
999
+
1000
+
1001
+ def _throttling_caveat(
1002
+ recorded_evidence: RecordedEvidence | None,
1003
+ ) -> PresentationCaveat | None:
1004
+ """Say how often the provider refused each side, when the run still says.
1005
+
1006
+ Two sides that met different amounts of throttling did not quite meet the
1007
+ same conditions, so an asymmetry is part of how much the comparison proves
1008
+ and is raised as a warning. An even count is worth stating and is not a
1009
+ qualification of anything, so it is a note. A run whose recorded traces
1010
+ can no longer be read says nothing here at all.
1011
+ """
1012
+ if recorded_evidence is None:
1013
+ return None
1014
+ baseline = recorded_evidence.baseline.rate_limited_calls
1015
+ candidate = recorded_evidence.candidate.rate_limited_calls
1016
+ if baseline == candidate == 0:
1017
+ text = "The provider refused no model call on either side."
1018
+ else:
1019
+ text = (
1020
+ f"The provider refused {_calls(baseline)} with a rate limit on the "
1021
+ f"baseline side and {candidate:,} on the candidate side."
1022
+ )
1023
+ if recorded_evidence.every_rollout_completed:
1024
+ text = f"{text} Every rollout still ran to completion."
1025
+ return PresentationCaveat(
1026
+ code="provider_rate_limiting",
1027
+ severity="warning" if baseline != candidate else "info",
1028
+ text=text,
1029
+ )
1030
+
1031
+
1032
+ #: What each cost provenance means to a reader, in the words decisions
1033
+ #: document 0007 R6 requires: a computed or estimated figure is never allowed
1034
+ #: to read as one the provider billed.
1035
+ _COST_CAVEATS: Final[
1036
+ dict[CostProvenance, tuple[str, Literal["info", "warning", "error"], str]]
1037
+ ] = {
1038
+ CostProvenance.PROVIDER_REPORTED: (
1039
+ "cost_provider_reported",
1040
+ "info",
1041
+ "The cost shown is the figure the provider reported for this "
1042
+ "comparison, taken from its signed execution record.",
1043
+ ),
1044
+ CostProvenance.COMPUTED_FROM_PINNED_PRICE: (
1045
+ "cost_computed_from_pinned_price",
1046
+ "info",
1047
+ "The cost shown was computed from the recorded token usage and the "
1048
+ "price this release pins. The provider did not report it.",
1049
+ ),
1050
+ CostProvenance.ESTIMATED: (
1051
+ "cost_estimated",
1052
+ "warning",
1053
+ "The cost shown is an estimate. It is not a figure the provider "
1054
+ "reported and it is not what you were charged.",
1055
+ ),
1056
+ }
1057
+
1058
+
1059
+ def _cost_unavailable_reason(
1060
+ campaign: CampaignSpec,
1061
+ record: ComparisonExecutionRecord | None,
1062
+ economics: _Economics,
1063
+ ) -> str:
1064
+ """Say which of the two things a cost needs this run is missing.
1065
+
1066
+ "Unavailable" on its own leaves a reader who has just spent money unable
1067
+ to tell whether the run lost the tokens or the release lost the prices, so
1068
+ the sentence names whichever one is actually absent.
1069
+ """
1070
+ if record is None:
1071
+ return (
1072
+ "This run wrote no signed execution record, so there is no signed "
1073
+ "token total to work a cost out from."
1074
+ )
1075
+ model_id = campaign.subject.model.model_id
1076
+ try:
1077
+ price_profile_for(model_id)
1078
+ except PrerequisiteError:
1079
+ return (
1080
+ "This release recorded no provider prices for "
1081
+ f"{sanitize_label(model_id)}, so the tokens this run recorded "
1082
+ "cannot be turned into a cost."
1083
+ )
1084
+ if economics.cost.provenance is not CostProvenance.UNAVAILABLE:
1085
+ return _COST_PROVENANCE[economics.cost.provenance]
1086
+ return (
1087
+ "Neither side of this comparison reported how many tokens it used, "
1088
+ "and the provider reported no cost of its own."
1089
+ )
1090
+
1091
+
1092
+ def _economics_caveat(
1093
+ economics: _Economics, derived: DerivedCost | None
1094
+ ) -> PresentationCaveat:
1095
+ """Say where the cost and timing came from, or that they are unknown.
1096
+
1097
+ Decisions document 0007 R6: missing economics is an operational-evidence
1098
+ warning about what is unknown, and never a finding about the measurement.
1099
+ Both sentences below end by saying so, because the reader most likely to
1100
+ meet this caveat is the one who needs to be told that their result still
1101
+ stands.
1102
+ """
1103
+ if economics.source == "comparison_execution_record":
1104
+ named = _COST_CAVEATS.get(economics.cost.provenance)
1105
+ if named is not None:
1106
+ code, severity, text = named
1107
+ return PresentationCaveat(code=code, severity=severity, text=text)
1108
+ if derived is not None:
1109
+ return PresentationCaveat(
1110
+ code="cost_derived_while_rendering",
1111
+ severity="info",
1112
+ text=(
1113
+ "Timing and token counts come from this run's signed "
1114
+ "execution record. The provider reported no figure for what "
1115
+ "it charged, so the one shown was worked out from those "
1116
+ "token counts and the prices this release recorded. It was "
1117
+ "not written into anything this run signed, and what the "
1118
+ "comparison measured is unaffected."
1119
+ ),
1120
+ )
1121
+ return PresentationCaveat(
1122
+ code="cost_unavailable",
1123
+ severity="warning",
1124
+ text=(
1125
+ "Timing and token counts come from this run's signed execution "
1126
+ "record. No cost was reported for it and none could be worked "
1127
+ "out from what it recorded. What the comparison measured is "
1128
+ "unaffected."
1129
+ ),
1130
+ )
1131
+ if economics.source == "episode_receipts":
1132
+ return PresentationCaveat(
1133
+ code="operational_evidence_unavailable",
1134
+ severity="warning",
1135
+ text=(
1136
+ "This run has no signed execution record, so its timing and "
1137
+ "cost are unavailable; the token count shown comes from the "
1138
+ "receipts. What the comparison measured is unaffected."
1139
+ ),
1140
+ )
1141
+ return PresentationCaveat(
1142
+ code="operational_evidence_unavailable",
1143
+ severity="warning",
1144
+ text=(
1145
+ "This run has no signed execution record, so how long it took, "
1146
+ "how many tokens it used and what it cost are all unavailable. "
1147
+ "What the comparison measured is unaffected."
1148
+ ),
1149
+ )
1150
+
1151
+
1152
+ def _next_actions(
1153
+ run_id: str,
1154
+ proof_grade: str,
1155
+ *,
1156
+ verified: bool,
1157
+ publication_eligible: bool,
1158
+ ) -> list[NextAction]:
1159
+ """Return the steps this build can actually carry out for this report.
1160
+
1161
+ A development-only report is offered only the first of them. It has no
1162
+ proof bundle and never claimed one, and nothing may be derived from
1163
+ invented numbers, so the three commands that would refuse it are not
1164
+ suggested: an action a caller cannot carry out is worse than one fewer
1165
+ suggestion.
1166
+
1167
+ Publishing is offered second, and only to a run whose proof was checked in
1168
+ this very reading and held together. It goes above ``verify_proof`` for the
1169
+ same reason it is only offered at all: the check has just been run and
1170
+ passed, so re-running it is a step already taken, while publishing is the
1171
+ step that has not been. The envelope carries three actions, so a position
1172
+ here is a decision about what a reader is actually shown.
1173
+ """
1174
+ actions = [
1175
+ NextAction(
1176
+ id="inspect_tasks",
1177
+ label="Look at every task, including the ones that regressed",
1178
+ reason="The per-task table is where a Skill's effect is legible.",
1179
+ cli=["techtree", "run", "result", run_id, "--show-tasks", "all"],
1180
+ hermes_tool=None,
1181
+ hermes_args=None,
1182
+ requires_user_confirmation=False,
1183
+ )
1184
+ ]
1185
+ if proof_grade == "development_only":
1186
+ return actions
1187
+
1188
+ if verified and publication_eligible:
1189
+ actions.append(publish_action(run_id))
1190
+
1191
+ # Ordered by usefulness, because the CLI envelope carries at most three and
1192
+ # truncates from the end. Checking that the result is real comes before
1193
+ # acting on it, and the context has to exist before a revision can be
1194
+ # compared against anything.
1195
+ actions.append(
1196
+ NextAction(
1197
+ id="verify_proof",
1198
+ label="Verify this run's local proof",
1199
+ reason="It checks offline, from the bytes the run stored.",
1200
+ cli=["techtree", "proof", "verify", run_id],
1201
+ hermes_tool=None,
1202
+ hermes_args=None,
1203
+ requires_user_confirmation=False,
1204
+ )
1205
+ )
1206
+ actions.append(
1207
+ NextAction(
1208
+ id="improvement_context",
1209
+ label="Export what a host agent needs to propose one Skill revision",
1210
+ reason=(
1211
+ "It carries the regressions, the failures and the objective, "
1212
+ "and no hidden task material."
1213
+ ),
1214
+ cli=["techtree", "uplift", "context", run_id],
1215
+ hermes_tool=None,
1216
+ hermes_args=None,
1217
+ requires_user_confirmation=False,
1218
+ )
1219
+ )
1220
+ actions.append(
1221
+ NextAction(
1222
+ id="prepare_replacement",
1223
+ label="Prepare a comparison of this Skill against a revision of it",
1224
+ reason=(
1225
+ "The baseline is pinned to the Skill this run measured, so the "
1226
+ "second comparison starts where this one ended."
1227
+ ),
1228
+ cli=[
1229
+ "techtree",
1230
+ "uplift",
1231
+ "prepare",
1232
+ "--from-run",
1233
+ run_id,
1234
+ "--candidate-skill",
1235
+ "PATH",
1236
+ ],
1237
+ hermes_tool=None,
1238
+ hermes_args=None,
1239
+ requires_user_confirmation=True,
1240
+ )
1241
+ )
1242
+ return actions