puremethod 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 (171) hide show
  1. app/__init__.py +2 -0
  2. app/__main__.py +31 -0
  3. app/_web/404/index.html +1 -0
  4. app/_web/404.html +1 -0
  5. app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
  6. app/_web/__next._full.txt +36 -0
  7. app/_web/__next._tree.txt +4 -0
  8. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
  9. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
  10. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
  11. app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
  12. app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
  13. app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  14. app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
  15. app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
  16. app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
  17. app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
  18. app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
  19. app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
  20. app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
  21. app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
  22. app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
  23. app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
  24. app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
  25. app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
  26. app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
  27. app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
  28. app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
  29. app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
  30. app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
  31. app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
  32. app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
  33. app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
  34. app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
  35. app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
  36. app/_web/_not-found/__next._full.txt +24 -0
  37. app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
  38. app/_web/_not-found/__next._tree.txt +4 -0
  39. app/_web/_not-found/index.html +1 -0
  40. app/_web/_not-found/index.txt +24 -0
  41. app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
  42. app/_web/credentials/__next._full.txt +39 -0
  43. app/_web/credentials/__next._tree.txt +5 -0
  44. app/_web/credentials/index.html +1 -0
  45. app/_web/credentials/index.txt +39 -0
  46. app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
  47. app/_web/executors/__next._full.txt +39 -0
  48. app/_web/executors/__next._tree.txt +5 -0
  49. app/_web/executors/index.html +1 -0
  50. app/_web/executors/index.txt +39 -0
  51. app/_web/index.html +1 -0
  52. app/_web/index.txt +36 -0
  53. app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
  54. app/_web/new/__next._full.txt +39 -0
  55. app/_web/new/__next._tree.txt +5 -0
  56. app/_web/new/index.html +1 -0
  57. app/_web/new/index.txt +39 -0
  58. app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
  59. app/_web/profiles/__next._full.txt +39 -0
  60. app/_web/profiles/__next._tree.txt +5 -0
  61. app/_web/profiles/index.html +1 -0
  62. app/_web/profiles/index.txt +39 -0
  63. app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
  64. app/_web/run/__next._full.txt +40 -0
  65. app/_web/run/__next._tree.txt +5 -0
  66. app/_web/run/index.html +1 -0
  67. app/_web/run/index.txt +40 -0
  68. app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
  69. app/_web/services/__next._full.txt +39 -0
  70. app/_web/services/__next._tree.txt +5 -0
  71. app/_web/services/index.html +1 -0
  72. app/_web/services/index.txt +39 -0
  73. app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
  74. app/_web/setup/__next._full.txt +39 -0
  75. app/_web/setup/__next._tree.txt +5 -0
  76. app/_web/setup/index.html +1 -0
  77. app/_web/setup/index.txt +39 -0
  78. app/_web/signin/__next._full.txt +30 -0
  79. app/_web/signin/__next._tree.txt +4 -0
  80. app/_web/signin/__next.signin.__PAGE__.txt +36 -0
  81. app/_web/signin/index.html +1 -0
  82. app/_web/signin/index.txt +30 -0
  83. app/accounts.py +137 -0
  84. app/addresses.py +30 -0
  85. app/api/__init__.py +61 -0
  86. app/api/auth_routes.py +54 -0
  87. app/api/common.py +102 -0
  88. app/api/config_routes.py +562 -0
  89. app/api/links.py +22 -0
  90. app/api/run_routes.py +311 -0
  91. app/api/setup_routes.py +267 -0
  92. app/bundled.py +521 -0
  93. app/compose.py +229 -0
  94. app/db.py +186 -0
  95. app/executor_config.py +95 -0
  96. app/keys.py +29 -0
  97. app/launcher.py +389 -0
  98. app/leases.py +40 -0
  99. app/literature_sources.py +131 -0
  100. app/model_endpoints.py +104 -0
  101. app/model_search.py +252 -0
  102. app/outputs.py +77 -0
  103. app/paths.py +93 -0
  104. app/readonly.py +70 -0
  105. app/repo.py +746 -0
  106. app/scan.py +65 -0
  107. app/server.py +24 -0
  108. app/settings.py +125 -0
  109. app/setup.py +117 -0
  110. app/ssh_chain.py +102 -0
  111. app/ssh_hosts.py +244 -0
  112. app/ssh_machines.py +101 -0
  113. app/statuses.py +4 -0
  114. app/supervisor.py +87 -0
  115. app/tunnels.py +222 -0
  116. app/unseal.py +27 -0
  117. app/worker.py +657 -0
  118. executor/__init__.py +47 -0
  119. executor/base.py +161 -0
  120. executor/common.py +139 -0
  121. executor/local_docker.py +239 -0
  122. executor/local_process.py +247 -0
  123. executor/ssh.py +481 -0
  124. executor/types.py +128 -0
  125. executor/watchdog.py +108 -0
  126. ledger/__init__.py +46 -0
  127. ledger/artifacts.py +62 -0
  128. ledger/canonical.py +48 -0
  129. ledger/core.py +337 -0
  130. ledger/errors.py +36 -0
  131. ledger/model.py +107 -0
  132. ledger/payloads.py +228 -0
  133. ledger/rules.py +204 -0
  134. ledger/storage.py +226 -0
  135. llm/__init__.py +45 -0
  136. llm/backends/__init__.py +0 -0
  137. llm/backends/anthropic_backend.py +145 -0
  138. llm/backends/base.py +87 -0
  139. llm/backends/openai_backend.py +211 -0
  140. llm/core.py +416 -0
  141. llm/prices.py +98 -0
  142. llm/tokens.py +18 -0
  143. llm/types.py +106 -0
  144. loop/__init__.py +19 -0
  145. loop/config.py +108 -0
  146. loop/core.py +1160 -0
  147. loop/decide.py +61 -0
  148. loop/harness.py +132 -0
  149. loop/prompts.py +88 -0
  150. loop/view.py +202 -0
  151. puremethod-0.1.0.dist-info/METADATA +79 -0
  152. puremethod-0.1.0.dist-info/RECORD +171 -0
  153. puremethod-0.1.0.dist-info/WHEEL +4 -0
  154. puremethod-0.1.0.dist-info/entry_points.txt +2 -0
  155. puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
  156. puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
  157. purescholar/__init__.py +43 -0
  158. purescholar/client.py +380 -0
  159. purescholar/types.py +164 -0
  160. puretrace/__init__.py +49 -0
  161. puretrace/client.py +320 -0
  162. puretrace/types.py +166 -0
  163. writer/__init__.py +8 -0
  164. writer/core.py +430 -0
  165. writer/facts.py +162 -0
  166. writer/latex.py +62 -0
  167. writer/lint.py +85 -0
  168. writer/macros.py +114 -0
  169. writer/render.py +120 -0
  170. writer/tectonic.py +79 -0
  171. writer/types.py +61 -0
ledger/model.py ADDED
@@ -0,0 +1,107 @@
1
+ """Entries, derived views and reports (04-ledger.md §3, §4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Literal
7
+
8
+ Actor = Literal["loop", "user", "system"]
9
+
10
+ ENTRY_TYPES: tuple[str, ...] = (
11
+ "run.created",
12
+ "run.status",
13
+ "checkpoint.requested",
14
+ "checkpoint.decided",
15
+ "call.recorded",
16
+ "idea.proposed",
17
+ "hypothesis.proposed",
18
+ "hypothesis.novelty",
19
+ "screening.result",
20
+ "prereg.registered",
21
+ "prereg.amended",
22
+ "experiment.started",
23
+ "experiment.finished",
24
+ "decision.made",
25
+ "selfcheck.result",
26
+ "paper.built",
27
+ "correction",
28
+ )
29
+
30
+ ARTIFACT_KINDS: tuple[str, ...] = (
31
+ "code", "env", "stdout", "stderr", "result", "figure", "paper", "prompt", "response",
32
+ "other",
33
+ )
34
+
35
+ CONFIRM_STAGE = "confirm"
36
+ SMOKE_STAGE = "smoke" # harness checks; no decision uses them (INV-9, KB-018)
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Entry:
41
+ run_id: str
42
+ seq: int
43
+ entry_id: str
44
+ type: str
45
+ at: str
46
+ actor: Actor
47
+ payload: dict[str, Any]
48
+ refs: list[str]
49
+ prev_hash: str
50
+ hash: str
51
+
52
+ def hashed_fields(self) -> dict[str, Any]:
53
+ return {
54
+ "run_id": self.run_id,
55
+ "seq": self.seq,
56
+ "entry_id": self.entry_id,
57
+ "type": self.type,
58
+ "at": self.at,
59
+ "actor": self.actor,
60
+ "payload": self.payload,
61
+ "refs": self.refs,
62
+ "prev_hash": self.prev_hash,
63
+ }
64
+
65
+ def to_dict(self) -> dict[str, Any]:
66
+ return {**self.hashed_fields(), "hash": self.hash}
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Rejection:
71
+ run_id: str
72
+ at: str
73
+ target: str # entry type, or "artifact"
74
+ rule: str
75
+ message: str
76
+
77
+
78
+ @dataclass
79
+ class RunState:
80
+ """Derived view: a pure function of the entries (04-ledger.md §5 step 2)."""
81
+
82
+ run_id: str
83
+ status: str | None = None
84
+ hypotheses: dict[str, dict[str, Any]] = field(default_factory=dict)
85
+ prereg_in_force: dict[tuple[str, str], str] = field(default_factory=dict)
86
+ open_experiments: list[str] = field(default_factory=list)
87
+ finished: dict[str, str] = field(default_factory=dict)
88
+ decisions: list[str] = field(default_factory=list)
89
+ last_seq: int = 0
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class SpendSummary:
94
+ total_usd: float
95
+ by_source: dict[str, float]
96
+ unmetered_items: int
97
+ money_limit_usd: float | None
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class ChainReport:
102
+ run_id: str
103
+ entries: int
104
+ ok: bool
105
+ first_break_seq: int | None = None
106
+ reason: str | None = None
107
+ head_hash: str | None = None
ledger/payloads.py ADDED
@@ -0,0 +1,228 @@
1
+ """Payload schemas per entry type (04-ledger.md §4, "Entry types (v1)").
2
+
3
+ The KB lists the main fields of each type; extra fields are allowed. Fields that hold entry
4
+ ids are checked by the rules in `rules.py`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Literal
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
12
+
13
+ from .errors import LedgerRejection
14
+
15
+ NonEmpty = Field(min_length=1)
16
+
17
+ REASON_CODES = (
18
+ "refuted", "harness_error", "not_novel", "infeasible", "no_signal", "superseded",
19
+ "user_stopped", "limit_reached",
20
+ )
21
+ ReasonCode = Literal[
22
+ "refuted", "harness_error", "not_novel", "infeasible", "no_signal", "superseded",
23
+ "user_stopped", "limit_reached",
24
+ ]
25
+ DataRole = Literal["selection", "held_out"]
26
+
27
+
28
+ class _Payload(BaseModel):
29
+ model_config = ConfigDict(extra="allow", strict=True)
30
+
31
+
32
+ class RunCreated(_Payload):
33
+ idea: str = NonEmpty
34
+ config: dict[str, Any]
35
+
36
+
37
+ class RunStatus(_Payload):
38
+ status: Literal["preflight", "running", "paused", "stopping", "writing", "delivered",
39
+ "failed"]
40
+ reason: str = ""
41
+
42
+
43
+ class CheckpointRequested(_Payload):
44
+ checkpoint_id: str = NonEmpty
45
+ options: Any = None
46
+
47
+
48
+ class CheckpointDecided(_Payload):
49
+ checkpoint_id: str = NonEmpty
50
+ decision: Any
51
+
52
+
53
+ class CallRecorded(_Payload):
54
+ source: Literal["llm", "purescholar", "puretrace"]
55
+ record: dict[str, Any]
56
+ cost_usd: float | None
57
+ metered: bool
58
+ request_artifact: str | None = None
59
+ response_artifact: str | None = None
60
+
61
+
62
+ class IdeaProposed(_Payload):
63
+ direction: str = NonEmpty
64
+ parents: list[str] = []
65
+
66
+
67
+ class HypothesisProposed(_Payload):
68
+ statement: str = NonEmpty
69
+ idea: str = NonEmpty
70
+ kind: Literal["treatment", "baseline"]
71
+
72
+
73
+ class HypothesisNovelty(_Payload):
74
+ hypothesis: str = NonEmpty
75
+ evidence: list[str]
76
+ judgement: Literal["novel", "overlap", "unclear"]
77
+ reason: str = NonEmpty
78
+
79
+
80
+ class ScreeningResult(_Payload):
81
+ paper: Any
82
+ assessment: dict[str, Any]
83
+ decision: str = NonEmpty
84
+
85
+
86
+ class Metric(_Payload):
87
+ name: str = NonEmpty
88
+ unit: str
89
+ direction: Literal["maximize", "minimize"]
90
+
91
+
92
+ class Comparison(_Payload):
93
+ baseline: str = NonEmpty
94
+ rule: str = NonEmpty
95
+ threshold: float
96
+
97
+
98
+ class DataSet(_Payload):
99
+ source: str = NonEmpty
100
+ licence: str | None = None
101
+ role: DataRole
102
+ digest: str = NonEmpty
103
+
104
+
105
+ class Budget(_Payload):
106
+ wall_clock_s: float = Field(gt=0)
107
+ compute: Any = None
108
+ money_usd_estimate: float = Field(ge=0)
109
+
110
+
111
+ class Seeds(_Payload):
112
+ policy: str = NonEmpty
113
+ values: list[int] = Field(min_length=1)
114
+
115
+
116
+ class PreregRegistered(_Payload):
117
+ hypothesis: str = NonEmpty
118
+ stage: str = NonEmpty
119
+ metric: Metric
120
+ comparison: Comparison
121
+ evaluation_data: list[DataSet] = Field(min_length=1)
122
+ budget: Budget
123
+ seeds: Seeds
124
+ code_digest: str = NonEmpty
125
+
126
+
127
+ class PreregAmended(_Payload):
128
+ supersedes: str = NonEmpty
129
+ changes: dict[str, Any] = Field(min_length=1)
130
+ reason: str = NonEmpty
131
+
132
+
133
+ class ExperimentStarted(_Payload):
134
+ prereg: str = NonEmpty
135
+ executor_id: str = NonEmpty
136
+ isolation: Literal["container", "vm", "none"]
137
+ code_digest: str = NonEmpty
138
+ env_digest: str = NonEmpty
139
+ seed: int
140
+
141
+
142
+ class MetricValue(_Payload):
143
+ name: str = NonEmpty
144
+ value: float
145
+ unit: str
146
+ data_role: DataRole
147
+ set: str | None = None
148
+
149
+
150
+ class ExperimentFinished(_Payload):
151
+ experiment: str = NonEmpty
152
+ status: Literal["ok", "failed", "crashed", "timeout", "killed", "lost"]
153
+ metrics: list[MetricValue] = []
154
+ artifacts: dict[str, str] = {}
155
+ duration_s: float | None = None
156
+ cost_usd: float | None = None
157
+ metered: bool = False
158
+
159
+
160
+ class MetricUse(_Payload):
161
+ experiment: str = NonEmpty # an experiment.finished entry id
162
+ name: str = NonEmpty
163
+ set: str | None = None
164
+
165
+
166
+ class DecisionMade(_Payload):
167
+ hypothesis: str = NonEmpty
168
+ stage: str = NonEmpty
169
+ outcome: Literal["promote", "discard", "confirm", "inconclusive"]
170
+ reason_code: ReasonCode | None = None
171
+ reason_text: str = NonEmpty
172
+ prereg: str | None = None
173
+ experiments: list[str] = [] # experiment.started entry ids
174
+ metrics_used: list[MetricUse] = []
175
+ comparison: dict[str, Any] = {}
176
+
177
+
178
+ class SelfcheckResult(_Payload):
179
+ paper: str = NonEmpty
180
+ assessment: dict[str, Any]
181
+
182
+
183
+ class PaperBuilt(_Payload):
184
+ artifacts: dict[str, str]
185
+ trace_map: dict[str, Any]
186
+ citation_keys: list[str]
187
+
188
+
189
+ class Correction(_Payload):
190
+ corrects: str = NonEmpty
191
+ problem: str = NonEmpty
192
+ correct: Any
193
+
194
+
195
+ SCHEMAS: dict[str, type[_Payload]] = {
196
+ "run.created": RunCreated,
197
+ "run.status": RunStatus,
198
+ "checkpoint.requested": CheckpointRequested,
199
+ "checkpoint.decided": CheckpointDecided,
200
+ "call.recorded": CallRecorded,
201
+ "idea.proposed": IdeaProposed,
202
+ "hypothesis.proposed": HypothesisProposed,
203
+ "hypothesis.novelty": HypothesisNovelty,
204
+ "screening.result": ScreeningResult,
205
+ "prereg.registered": PreregRegistered,
206
+ "prereg.amended": PreregAmended,
207
+ "experiment.started": ExperimentStarted,
208
+ "experiment.finished": ExperimentFinished,
209
+ "decision.made": DecisionMade,
210
+ "selfcheck.result": SelfcheckResult,
211
+ "paper.built": PaperBuilt,
212
+ "correction": Correction,
213
+ }
214
+
215
+
216
+ def validate_payload(entry_type: str, payload: dict[str, Any]) -> _Payload:
217
+ schema = SCHEMAS.get(entry_type)
218
+ if schema is None:
219
+ raise LedgerRejection("schema", f"unknown entry type {entry_type!r}")
220
+ try:
221
+ return schema.model_validate(payload)
222
+ except ValidationError as exc:
223
+ # Field locations and error kinds only: never echo input values (INV-7).
224
+ problems = "; ".join(
225
+ f"{'.'.join(str(p) for p in err['loc']) or '$'}: {err['type']}"
226
+ for err in exc.errors(include_input=False, include_url=False)
227
+ )
228
+ raise LedgerRejection("schema", f"{entry_type}: {problems}") from None
ledger/rules.py ADDED
@@ -0,0 +1,204 @@
1
+ """Replay index and write-time rules (04-ledger.md §5, §7 INV-3 to INV-6).
2
+
3
+ `RunIndex` is built by replaying entries in order. The same replay serves `state()` and the
4
+ rules, so what the rules check is exactly what a resumed run sees.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ from . import payloads as p
13
+ from .errors import LedgerRejection
14
+ from .model import CONFIRM_STAGE, SMOKE_STAGE, Entry, RunState
15
+
16
+ # Outcomes that need experiments, and reason codes that can stand without them
17
+ # (for example a novelty discard before anything ran).
18
+ _NEEDS_EXPERIMENTS = {"promote", "confirm"}
19
+ _REASONS_WITHOUT_EXPERIMENTS = {"not_novel", "infeasible", "harness_error", "superseded",
20
+ "user_stopped", "limit_reached"}
21
+
22
+
23
+ @dataclass
24
+ class RunIndex:
25
+ run_id: str
26
+ by_id: dict[str, Entry] = field(default_factory=dict)
27
+ status: str | None = None
28
+ hypotheses: dict[str, dict[str, Any]] = field(default_factory=dict)
29
+ prereg_key: dict[str, tuple[str, str]] = field(default_factory=dict)
30
+ in_force: dict[tuple[str, str], str] = field(default_factory=dict)
31
+ started_prereg: dict[str, str] = field(default_factory=dict)
32
+ finished_for: dict[str, str] = field(default_factory=dict)
33
+ finished_of: dict[str, str] = field(default_factory=dict)
34
+ decisions: list[str] = field(default_factory=list)
35
+ last_seq: int = 0
36
+ last_hash: str | None = None
37
+
38
+ def apply(self, e: Entry) -> None:
39
+ self.by_id[e.entry_id] = e
40
+ self.last_seq = e.seq
41
+ self.last_hash = e.hash
42
+ pl = e.payload
43
+ if e.type == "run.status":
44
+ self.status = pl["status"]
45
+ elif e.type == "hypothesis.proposed":
46
+ self.hypotheses[e.entry_id] = {"statement": pl["statement"], "kind": pl["kind"],
47
+ "idea": pl["idea"]}
48
+ elif e.type == "prereg.registered":
49
+ key = (pl["hypothesis"], pl["stage"])
50
+ self.prereg_key[e.entry_id] = key
51
+ self.in_force[key] = e.entry_id
52
+ elif e.type == "prereg.amended":
53
+ key = self.prereg_key[pl["supersedes"]]
54
+ self.prereg_key[e.entry_id] = key
55
+ self.in_force[key] = e.entry_id
56
+ elif e.type == "experiment.started":
57
+ self.started_prereg[e.entry_id] = pl["prereg"]
58
+ elif e.type == "experiment.finished":
59
+ self.finished_for[pl["experiment"]] = e.entry_id
60
+ self.finished_of[e.entry_id] = pl["experiment"]
61
+ elif e.type == "decision.made":
62
+ self.decisions.append(e.entry_id)
63
+
64
+ def open_experiments(self) -> list[str]:
65
+ return [s for s in self.started_prereg if s not in self.finished_for]
66
+
67
+ def to_state(self) -> RunState:
68
+ return RunState(
69
+ run_id=self.run_id, status=self.status, hypotheses=dict(self.hypotheses),
70
+ prereg_in_force=dict(self.in_force), open_experiments=self.open_experiments(),
71
+ finished=dict(self.finished_for), decisions=list(self.decisions),
72
+ last_seq=self.last_seq,
73
+ )
74
+
75
+
76
+ def _require_ref(refs: list[str], entry_id: str, field_name: str) -> None:
77
+ if entry_id not in refs:
78
+ raise LedgerRejection("refs", f"{field_name} {entry_id!r} must also be listed in refs")
79
+
80
+
81
+ def _require_type(idx: RunIndex, entry_id: str, types: tuple[str, ...], what: str) -> Entry:
82
+ e = idx.by_id.get(entry_id)
83
+ if e is None or e.type not in types:
84
+ raise LedgerRejection("refs", f"{what} {entry_id!r} is not a {' or '.join(types)} "
85
+ "entry of this run")
86
+ return e
87
+
88
+
89
+ def check(idx: RunIndex, entry_type: str, model: p._Payload, refs: list[str]) -> None:
90
+ """Raise LedgerRejection if the new entry breaks a rule. Order: refs, then type rules."""
91
+ for ref in refs:
92
+ if ref not in idx.by_id:
93
+ raise LedgerRejection("refs", f"ref {ref!r} does not exist earlier in this run")
94
+
95
+ if entry_type == "run.created":
96
+ if idx.last_seq != 0:
97
+ raise LedgerRejection("run.created", "a run has exactly one run.created, first")
98
+ return
99
+ if idx.last_seq == 0:
100
+ raise LedgerRejection("run.created", "the first entry of a run must be run.created")
101
+
102
+ if isinstance(model, p.HypothesisProposed):
103
+ _require_ref(refs, model.idea, "idea")
104
+ _require_type(idx, model.idea, ("idea.proposed",), "idea")
105
+ elif isinstance(model, p.HypothesisNovelty):
106
+ _require_ref(refs, model.hypothesis, "hypothesis")
107
+ _require_type(idx, model.hypothesis, ("hypothesis.proposed",), "hypothesis")
108
+ elif isinstance(model, p.PreregRegistered):
109
+ _check_prereg(idx, model, refs)
110
+ elif isinstance(model, p.PreregAmended):
111
+ _require_ref(refs, model.supersedes, "supersedes")
112
+ _require_type(idx, model.supersedes, ("prereg.registered", "prereg.amended"),
113
+ "supersedes")
114
+ elif isinstance(model, p.ExperimentStarted):
115
+ _check_started(idx, model, refs) # INV-3
116
+ elif isinstance(model, p.ExperimentFinished):
117
+ _check_finished(idx, model, refs) # INV-6
118
+ elif isinstance(model, p.DecisionMade):
119
+ _check_decision(idx, model, refs) # INV-4, INV-5
120
+ elif isinstance(model, p.Correction):
121
+ _require_ref(refs, model.corrects, "corrects")
122
+
123
+
124
+ def _check_prereg(idx: RunIndex, m: p.PreregRegistered, refs: list[str]) -> None:
125
+ _require_ref(refs, m.hypothesis, "hypothesis")
126
+ _require_type(idx, m.hypothesis, ("hypothesis.proposed",), "hypothesis")
127
+ _require_ref(refs, m.comparison.baseline, "comparison.baseline")
128
+ _require_type(idx, m.comparison.baseline, ("hypothesis.proposed",), "comparison.baseline")
129
+
130
+
131
+ def _check_started(idx: RunIndex, m: p.ExperimentStarted, refs: list[str]) -> None:
132
+ _require_ref(refs, m.prereg, "prereg")
133
+ _require_type(idx, m.prereg, ("prereg.registered", "prereg.amended"), "prereg")
134
+ key = idx.prereg_key[m.prereg]
135
+ if idx.in_force.get(key) != m.prereg:
136
+ raise LedgerRejection("INV-3", f"prereg {m.prereg!r} is not the pre-registration in "
137
+ f"force for hypothesis {key[0]!r} at stage {key[1]!r}")
138
+
139
+
140
+ def _check_finished(idx: RunIndex, m: p.ExperimentFinished, refs: list[str]) -> None:
141
+ _require_ref(refs, m.experiment, "experiment")
142
+ _require_type(idx, m.experiment, ("experiment.started",), "experiment")
143
+ if m.experiment in idx.finished_for:
144
+ raise LedgerRejection("INV-6", f"experiment {m.experiment!r} already has an "
145
+ "experiment.finished entry")
146
+
147
+
148
+ def _check_decision(idx: RunIndex, m: p.DecisionMade, refs: list[str]) -> None:
149
+ if m.stage == SMOKE_STAGE:
150
+ raise LedgerRejection("INV-9", "no decision is made at stage `smoke` (harness checks)")
151
+ _require_ref(refs, m.hypothesis, "hypothesis")
152
+ _require_type(idx, m.hypothesis, ("hypothesis.proposed",), "hypothesis")
153
+
154
+ if m.outcome in {"discard", "inconclusive"} and m.reason_code is None:
155
+ raise LedgerRejection("reasons", f"a {m.outcome} decision needs a reason_code")
156
+
157
+ if not m.experiments:
158
+ if m.outcome in _NEEDS_EXPERIMENTS or m.reason_code not in _REASONS_WITHOUT_EXPERIMENTS:
159
+ raise LedgerRejection("INV-4", f"a {m.outcome} decision with reason "
160
+ f"{m.reason_code!r} must cite the experiments it used")
161
+ if m.metrics_used:
162
+ raise LedgerRejection("INV-4", "metrics_used without experiments")
163
+ return
164
+
165
+ # INV-4: cite the pre-registration in force at each start, and each finished entry.
166
+ if m.prereg is None:
167
+ raise LedgerRejection("INV-4", "a decision that uses experiments must name its prereg")
168
+ _require_ref(refs, m.prereg, "prereg")
169
+ key = idx.prereg_key.get(m.prereg)
170
+ if key is None:
171
+ raise LedgerRejection("INV-4", f"prereg {m.prereg!r} is not a pre-registration")
172
+ if key != (m.hypothesis, m.stage):
173
+ raise LedgerRejection("INV-4", "the prereg does not belong to this hypothesis and stage")
174
+ finished_ids: set[str] = set()
175
+ for started in m.experiments:
176
+ _require_type(idx, started, ("experiment.started",), "experiment")
177
+ if idx.started_prereg[started] != m.prereg:
178
+ raise LedgerRejection("INV-4", f"experiment {started!r} started under a different "
179
+ "pre-registration")
180
+ finished = idx.finished_for.get(started)
181
+ if finished is None:
182
+ raise LedgerRejection("INV-4", f"experiment {started!r} has not finished")
183
+ _require_ref(refs, finished, "finished experiment")
184
+ finished_ids.add(finished)
185
+
186
+ # A decision computed from metrics must say which, or INV-5 could not be checked.
187
+ if not m.metrics_used and (m.outcome in _NEEDS_EXPERIMENTS or m.reason_code == "refuted"):
188
+ raise LedgerRejection("INV-5", f"a {m.outcome} decision must list the metrics it used")
189
+
190
+ # INV-5 (KB-014): held-out metrics only in decisions at the confirmation stage.
191
+ for use in m.metrics_used:
192
+ if use.experiment not in finished_ids:
193
+ raise LedgerRejection("INV-4", f"metric source {use.experiment!r} is not a finished "
194
+ "experiment of this decision")
195
+ metrics = idx.by_id[use.experiment].payload.get("metrics", [])
196
+ match = [x for x in metrics if x["name"] == use.name
197
+ and (use.set is None or x.get("set") == use.set)]
198
+ if not match:
199
+ raise LedgerRejection("INV-4", f"metric {use.name!r} is not recorded in "
200
+ f"{use.experiment!r}")
201
+ held_out = any(x["data_role"] == "held_out" for x in match)
202
+ if held_out and m.stage != CONFIRM_STAGE:
203
+ raise LedgerRejection("INV-5", f"a {m.outcome} decision at stage {m.stage!r} may "
204
+ "not use held-out metrics")