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
executor/watchdog.py ADDED
@@ -0,0 +1,108 @@
1
+ """Deadline watchdog for `local-process` jobs (05-executor.md §5 step 3, INV-2, INV-3).
2
+
3
+ Run as its own process (`python -m executor.watchdog ...`), detached from the engine, so
4
+ the deadline holds even if the engine dies. It starts the job in a new process group, and
5
+ at the deadline or on a kill request it sends a termination signal to the whole group,
6
+ waits the grace period, then force-kills the tree. It writes the outcome to a result file.
7
+
8
+ Standard library only.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import signal
17
+ import subprocess
18
+ import sys
19
+ import time
20
+ from datetime import UTC, datetime
21
+ from pathlib import Path
22
+
23
+ POLL_S = 0.1
24
+
25
+
26
+ def _now() -> str:
27
+ return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
28
+
29
+
30
+ def _terminate(proc: subprocess.Popen[bytes]) -> None:
31
+ if sys.platform == "win32":
32
+ try:
33
+ proc.send_signal(signal.CTRL_BREAK_EVENT)
34
+ except (OSError, ValueError):
35
+ pass
36
+ else:
37
+ try:
38
+ os.killpg(proc.pid, signal.SIGTERM)
39
+ except ProcessLookupError:
40
+ pass
41
+
42
+
43
+ def _force_kill(proc: subprocess.Popen[bytes]) -> None:
44
+ if sys.platform == "win32":
45
+ subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], # noqa: S603, S607
46
+ capture_output=True, check=False)
47
+ else:
48
+ try:
49
+ os.killpg(proc.pid, signal.SIGKILL)
50
+ except ProcessLookupError:
51
+ pass
52
+
53
+
54
+ def main(argv: list[str] | None = None) -> int:
55
+ p = argparse.ArgumentParser()
56
+ p.add_argument("--deadline", type=float, required=True)
57
+ p.add_argument("--grace", type=float, required=True)
58
+ p.add_argument("--cwd", required=True)
59
+ p.add_argument("--stdout", required=True)
60
+ p.add_argument("--stderr", required=True)
61
+ p.add_argument("--result", required=True)
62
+ p.add_argument("--killfile", required=True)
63
+ p.add_argument("command")
64
+ a = p.parse_args(argv)
65
+
66
+ started_at, t0 = _now(), time.monotonic()
67
+ kwargs: dict[str, object] = {}
68
+ if sys.platform == "win32":
69
+ kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
70
+ args: list[str] | str = a.command
71
+ else:
72
+ kwargs["start_new_session"] = True
73
+ args = ["sh", "-c", a.command]
74
+ env = dict(os.environ)
75
+ env.pop("PYTHONPATH", None) # the watchdog's own import path is not the job's
76
+ with open(a.stdout, "wb") as out, open(a.stderr, "wb") as err:
77
+ proc = subprocess.Popen(args, cwd=a.cwd, stdout=out, stderr=err, # noqa: S603
78
+ stdin=subprocess.DEVNULL, shell=os.name == "nt", env=env,
79
+ **kwargs) # type: ignore[call-overload]
80
+ # For `reap` after an engine restart, if this watchdog dies (KB-020).
81
+ Path(a.result).with_name("job.pid").write_text(str(proc.pid), encoding="utf-8")
82
+ reason = "exited"
83
+ while proc.poll() is None:
84
+ if Path(a.killfile).exists():
85
+ reason = "killed"
86
+ elif time.monotonic() - t0 >= a.deadline:
87
+ reason = "timeout"
88
+ if reason != "exited":
89
+ _terminate(proc)
90
+ end = time.monotonic() + a.grace
91
+ while proc.poll() is None and time.monotonic() < end:
92
+ time.sleep(POLL_S)
93
+ _force_kill(proc)
94
+ proc.wait()
95
+ break
96
+ time.sleep(POLL_S)
97
+ if reason == "exited":
98
+ _force_kill(proc) # leftovers of the job's group, if any
99
+ result = {"exit_code": proc.returncode, "reason": reason, "started_at": started_at,
100
+ "ended_at": _now(), "duration_s": time.monotonic() - t0, "pid": proc.pid}
101
+ tmp = Path(a.result + ".tmp")
102
+ tmp.write_text(json.dumps(result), encoding="utf-8")
103
+ os.replace(tmp, a.result)
104
+ return 0
105
+
106
+
107
+ if __name__ == "__main__":
108
+ sys.exit(main())
ledger/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """ledger: the append-only record of a run (docs/knowledge_base/04-ledger.md)."""
2
+
3
+ from .canonical import canonical_json, entry_hash
4
+ from .core import Ledger
5
+ from .errors import (
6
+ ArtifactCorrupt,
7
+ ArtifactMissing,
8
+ ConcurrentWrite,
9
+ ExportError,
10
+ LedgerError,
11
+ LedgerRejection,
12
+ UnknownRun,
13
+ )
14
+ from .model import (
15
+ ARTIFACT_KINDS,
16
+ CONFIRM_STAGE,
17
+ ENTRY_TYPES,
18
+ SMOKE_STAGE,
19
+ ChainReport,
20
+ Entry,
21
+ Rejection,
22
+ RunState,
23
+ SpendSummary,
24
+ )
25
+
26
+ __all__ = [
27
+ "ARTIFACT_KINDS",
28
+ "CONFIRM_STAGE",
29
+ "ENTRY_TYPES",
30
+ "SMOKE_STAGE",
31
+ "ArtifactCorrupt",
32
+ "ArtifactMissing",
33
+ "ChainReport",
34
+ "ConcurrentWrite",
35
+ "Entry",
36
+ "ExportError",
37
+ "Ledger",
38
+ "LedgerError",
39
+ "LedgerRejection",
40
+ "Rejection",
41
+ "RunState",
42
+ "SpendSummary",
43
+ "UnknownRun",
44
+ "canonical_json",
45
+ "entry_hash",
46
+ ]
ledger/artifacts.py ADDED
@@ -0,0 +1,62 @@
1
+ """Content-addressed artifact files (04-ledger.md §4, "Artifact").
2
+
3
+ v0.1 stores artifacts on the local filesystem under `<root>/<run_id>/<hex>`. Files are
4
+ written once and never replaced; reads re-check the digest.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import tempfile
12
+ from pathlib import Path
13
+
14
+ from .canonical import sha256_hex
15
+ from .errors import ArtifactCorrupt, ArtifactMissing
16
+
17
+ DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
18
+ _RUN_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
19
+
20
+
21
+ def digest_of(data: bytes) -> str:
22
+ return "sha256:" + sha256_hex(data)
23
+
24
+
25
+ class FileArtifactStore:
26
+ def __init__(self, root: str | Path) -> None:
27
+ self._root = Path(root)
28
+ self._root.mkdir(parents=True, exist_ok=True)
29
+
30
+ def _path(self, run_id: str, digest: str) -> Path:
31
+ if not _RUN_ID_RE.match(run_id) or not DIGEST_RE.match(digest):
32
+ raise ArtifactMissing("malformed run id or digest")
33
+ return self._root / run_id / digest.removeprefix("sha256:")
34
+
35
+ def put(self, run_id: str, digest: str, data: bytes) -> None:
36
+ path = self._path(run_id, digest)
37
+ if path.exists():
38
+ return # idempotent: same digest, same content
39
+ path.parent.mkdir(parents=True, exist_ok=True)
40
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".tmp-")
41
+ try:
42
+ with os.fdopen(fd, "wb") as f:
43
+ f.write(data)
44
+ try:
45
+ os.link(tmp, path) # fails if another writer won the race; never overwrites
46
+ except FileExistsError:
47
+ pass
48
+ finally:
49
+ os.unlink(tmp)
50
+
51
+ def get(self, run_id: str, digest: str) -> bytes:
52
+ path = self._path(run_id, digest)
53
+ try:
54
+ data = path.read_bytes()
55
+ except FileNotFoundError:
56
+ raise ArtifactMissing(digest) from None
57
+ if digest_of(data) != digest:
58
+ raise ArtifactCorrupt(digest)
59
+ return data
60
+
61
+ def exists(self, run_id: str, digest: str) -> bool:
62
+ return self._path(run_id, digest).exists()
ledger/canonical.py ADDED
@@ -0,0 +1,48 @@
1
+ """Canonical JSON and hashing for ledger entries (04-ledger.md §4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ from typing import Any
9
+
10
+ GENESIS_HASH = "0" * 64
11
+
12
+
13
+ class NonCanonicalValue(ValueError):
14
+ """A value that has no stable JSON form (NaN, infinity, or a non-JSON type)."""
15
+
16
+
17
+ def _check(value: Any, path: str) -> None:
18
+ if isinstance(value, float):
19
+ if math.isnan(value) or math.isinf(value):
20
+ raise NonCanonicalValue(f"{path}: NaN and infinity are not allowed")
21
+ elif isinstance(value, dict):
22
+ for key, item in value.items():
23
+ if not isinstance(key, str):
24
+ raise NonCanonicalValue(f"{path}: object keys must be strings")
25
+ _check(item, f"{path}.{key}")
26
+ elif isinstance(value, list | tuple):
27
+ for i, item in enumerate(value):
28
+ _check(item, f"{path}[{i}]")
29
+ elif value is not None and not isinstance(value, str | int | bool):
30
+ raise NonCanonicalValue(f"{path}: unsupported type {type(value).__name__}")
31
+
32
+
33
+ def canonical_json(value: Any) -> str:
34
+ """Sorted keys, no whitespace, UTF-8 text. Same output on every platform."""
35
+ _check(value, "$")
36
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
37
+ allow_nan=False)
38
+
39
+
40
+ def sha256_hex(data: bytes) -> str:
41
+ return hashlib.sha256(data).hexdigest()
42
+
43
+
44
+ def entry_hash(fields: dict[str, Any]) -> str:
45
+ """SHA-256 over the canonical JSON of every entry field except `hash`."""
46
+ if "hash" in fields:
47
+ raise ValueError("entry_hash takes the fields without `hash`")
48
+ return sha256_hex(canonical_json(fields).encode("utf-8"))
ledger/core.py ADDED
@@ -0,0 +1,337 @@
1
+ """The Ledger (04-ledger.md §3): the append-only record of a run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import tempfile
8
+ import uuid
9
+ from collections.abc import Callable, Iterable, Iterator
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from . import payloads
15
+ from .artifacts import DIGEST_RE, FileArtifactStore, digest_of
16
+ from .canonical import GENESIS_HASH, NonCanonicalValue, canonical_json, entry_hash
17
+ from .errors import ConcurrentWrite, ExportError, LedgerRejection, UnknownRun
18
+ from .model import (
19
+ ARTIFACT_KINDS,
20
+ Actor,
21
+ ChainReport,
22
+ Entry,
23
+ Rejection,
24
+ RunState,
25
+ SpendSummary,
26
+ )
27
+ from .rules import RunIndex, check
28
+ from .storage import Storage, open_storage
29
+
30
+
31
+ def _utc_now() -> str:
32
+ return datetime.now(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z")
33
+
34
+
35
+ def _strings(value: Any) -> Iterator[str]:
36
+ if isinstance(value, str):
37
+ yield value
38
+ elif isinstance(value, dict):
39
+ for k, v in value.items():
40
+ yield k
41
+ yield from _strings(v)
42
+ elif isinstance(value, list | tuple):
43
+ for item in value:
44
+ yield from _strings(item)
45
+
46
+
47
+ def artifact_refs(e: Entry) -> list[str]:
48
+ """Digests of stored artifacts an entry points to (04-ledger.md §4).
49
+
50
+ Only these fields name artifacts. Other digests, such as the content digest of an
51
+ evaluation data set, describe data that lives outside the ledger.
52
+ """
53
+ pl = e.payload
54
+ found: list[Any] = []
55
+ if e.type == "call.recorded":
56
+ found = [pl.get("request_artifact"), pl.get("response_artifact")]
57
+ elif e.type == "prereg.registered":
58
+ found = [pl.get("code_digest")]
59
+ elif e.type in ("experiment.finished", "paper.built"):
60
+ found = list(pl.get("artifacts", {}).values())
61
+ elif e.type == "selfcheck.result":
62
+ found = [pl.get("paper")]
63
+ return [d for d in found if isinstance(d, str) and DIGEST_RE.match(d)]
64
+
65
+
66
+ class Ledger:
67
+ """One writer per run (`loop`); any number of readers.
68
+
69
+ Secret values live only in this object's memory (INV-7, KB-013). A new `Ledger` is a
70
+ restart: writes to a run are refused until `register_secrets` is called for it.
71
+ """
72
+
73
+ def __init__(self, storage_url: str, artifact_root: str | Path,
74
+ clock: Callable[[], str] = _utc_now) -> None:
75
+ self._storage: Storage = open_storage(storage_url)
76
+ self._artifacts = FileArtifactStore(artifact_root)
77
+ self._clock = clock
78
+ self._secrets: dict[str, tuple[str, ...]] = {}
79
+ self._index: dict[str, RunIndex] = {}
80
+
81
+ def close(self) -> None:
82
+ self._storage.close()
83
+
84
+ # ---- secrets -------------------------------------------------------------------------
85
+
86
+ def register_secrets(self, run_id: str, secrets: list[str]) -> None:
87
+ if not self._storage.run_exists(run_id):
88
+ raise UnknownRun(run_id)
89
+ self._secrets[run_id] = self._clean_secrets(secrets)
90
+
91
+ @staticmethod
92
+ def _clean_secrets(secrets: Iterable[str]) -> tuple[str, ...]:
93
+ values = tuple(secrets)
94
+ if any(not isinstance(s, str) or s == "" for s in values):
95
+ raise ValueError("secret values must be non-empty strings")
96
+ return values
97
+
98
+ def _secret_hit(self, run_id: str, texts: Iterable[str]) -> int | None:
99
+ secrets = self._secrets.get(run_id, ())
100
+ for text in texts:
101
+ for i, secret in enumerate(secrets):
102
+ if secret in text:
103
+ return i
104
+ return None
105
+
106
+ # ---- rejections ----------------------------------------------------------------------
107
+
108
+ def _reject(self, run_id: str, target: str, rule: str, message: str) -> LedgerRejection:
109
+ # The message is built by the ledger and never contains payload content (INV-7).
110
+ self._storage.add_rejection(Rejection(run_id, self._clock(), target, rule, message))
111
+ return LedgerRejection(rule, message)
112
+
113
+ def rejections(self, run_id: str) -> list[Rejection]:
114
+ return self._storage.rejections(run_id)
115
+
116
+ # ---- writing -------------------------------------------------------------------------
117
+
118
+ def open_run(self, config: dict[str, Any], secrets: list[str]) -> str:
119
+ run_id = "run_" + uuid.uuid4().hex
120
+ values = self._clean_secrets(secrets)
121
+ payload = {"idea": config.get("idea"), "config": config}
122
+ self._secrets[run_id] = values
123
+ try:
124
+ self._append(run_id, "run.created", payload, [], "loop", new_run=True)
125
+ except LedgerRejection:
126
+ del self._secrets[run_id]
127
+ raise
128
+ return run_id
129
+
130
+ def append(self, run_id: str, type: str, payload: dict[str, Any],
131
+ refs: list[str] | None = None, *, actor: Actor = "loop") -> Entry:
132
+ return self._append(run_id, type, payload, list(refs or []), actor, new_run=False)
133
+
134
+ def _append(self, run_id: str, entry_type: str, payload: dict[str, Any], refs: list[str],
135
+ actor: Actor, *, new_run: bool) -> Entry:
136
+ if actor not in ("loop", "user", "system"):
137
+ raise ValueError("actor must be loop, user or system")
138
+ if not new_run and not self._storage.run_exists(run_id):
139
+ raise UnknownRun(run_id)
140
+ if run_id not in self._secrets:
141
+ raise self._reject(run_id, entry_type, "INV-7",
142
+ "secrets not registered for this run since the ledger started")
143
+ # 1. schema
144
+ try:
145
+ canonical_json({"payload": payload, "refs": refs})
146
+ model = payloads.validate_payload(entry_type, payload)
147
+ except NonCanonicalValue as exc:
148
+ raise self._reject(run_id, entry_type, "schema", str(exc)) from None
149
+ except LedgerRejection as exc:
150
+ raise self._reject(run_id, entry_type, exc.rule, exc.message) from None
151
+ # 2-3. refs and the type's rules
152
+ idx = self._refresh(run_id)
153
+ try:
154
+ check(idx, entry_type, model, refs)
155
+ except LedgerRejection as exc:
156
+ raise self._reject(run_id, entry_type, exc.rule, exc.message) from None
157
+ # 4. secret scan
158
+ hit = self._secret_hit(run_id, _strings([payload, refs]))
159
+ if hit is not None:
160
+ raise self._reject(run_id, entry_type, "INV-7",
161
+ f"content contains the value of registered secret #{hit}")
162
+
163
+ at = self._clock()
164
+ expected_seq = idx.last_seq + 1
165
+
166
+ def build(prev_hash: str | None, seq: int) -> Entry:
167
+ if seq != expected_seq:
168
+ raise ConcurrentWrite(f"expected seq {expected_seq}, storage is at {seq - 1}")
169
+ fields: dict[str, Any] = {
170
+ "run_id": run_id, "seq": seq, "entry_id": f"{run_id}:{seq}",
171
+ "type": entry_type, "at": at, "actor": actor, "payload": payload,
172
+ "refs": refs, "prev_hash": prev_hash or GENESIS_HASH,
173
+ }
174
+ return Entry(**fields, hash=entry_hash(fields))
175
+
176
+ try:
177
+ entry = self._storage.append(run_id, build)
178
+ except ConcurrentWrite:
179
+ self._index.pop(run_id, None)
180
+ raise
181
+ idx.apply(entry)
182
+ return entry
183
+
184
+ def close_dangling(self, run_id: str) -> list[Entry]:
185
+ """INV-6: on resume, close every unmatched experiment.started as `lost`."""
186
+ idx = self._refresh(run_id)
187
+ closed = []
188
+ for started in idx.open_experiments():
189
+ closed.append(self.append(
190
+ run_id, "experiment.finished",
191
+ {"experiment": started, "status": "lost", "metrics": [], "artifacts": {},
192
+ "duration_s": None, "cost_usd": None, "metered": False},
193
+ [started], actor="system"))
194
+ return closed
195
+
196
+ def put_artifact(self, run_id: str, data: bytes | Path, kind: str) -> str:
197
+ if kind not in ARTIFACT_KINDS:
198
+ raise ValueError(f"unknown artifact kind {kind!r}")
199
+ if not self._storage.run_exists(run_id):
200
+ raise UnknownRun(run_id)
201
+ if run_id not in self._secrets:
202
+ raise self._reject(run_id, "artifact", "INV-7",
203
+ "secrets not registered for this run since the ledger started")
204
+ content = data.read_bytes() if isinstance(data, Path) else bytes(data)
205
+ for i, secret in enumerate(self._secrets[run_id]):
206
+ if secret.encode("utf-8") in content:
207
+ raise self._reject(run_id, "artifact", "INV-7",
208
+ f"content contains the value of registered secret #{i}")
209
+ digest = digest_of(content)
210
+ self._artifacts.put(run_id, digest, content)
211
+ self._storage.add_artifact(run_id, digest, kind, len(content), self._clock())
212
+ return digest
213
+
214
+ # ---- reading -------------------------------------------------------------------------
215
+
216
+ def _refresh(self, run_id: str) -> RunIndex:
217
+ idx = self._index.get(run_id)
218
+ if idx is None:
219
+ idx = self._index[run_id] = RunIndex(run_id)
220
+ for e in self._storage.entries(run_id, since_seq=idx.last_seq):
221
+ idx.apply(e)
222
+ return idx
223
+
224
+ def get_artifact(self, run_id: str, digest: str) -> bytes:
225
+ return self._artifacts.get(run_id, digest)
226
+
227
+ def entries(self, run_id: str, types: Iterable[str] | None = None,
228
+ refs: Iterable[str] | None = None,
229
+ since_seq: int | None = None) -> Iterator[Entry]:
230
+ type_set = set(types) if types is not None else None
231
+ ref_set = set(refs) if refs is not None else None
232
+ for e in self._storage.entries(run_id, since_seq=since_seq or 0):
233
+ if type_set is not None and e.type not in type_set:
234
+ continue
235
+ if ref_set is not None and not ref_set.intersection(e.refs):
236
+ continue
237
+ yield e
238
+
239
+ def state(self, run_id: str) -> RunState:
240
+ if not self._storage.run_exists(run_id):
241
+ raise UnknownRun(run_id)
242
+ idx = RunIndex(run_id)
243
+ for e in self._storage.entries(run_id):
244
+ idx.apply(e)
245
+ return idx.to_state()
246
+
247
+ def spend(self, run_id: str) -> SpendSummary:
248
+ if not self._storage.run_exists(run_id):
249
+ raise UnknownRun(run_id)
250
+ total = 0.0
251
+ by_source: dict[str, float] = {}
252
+ unmetered = 0
253
+ limit: float | None = None
254
+ for e in self._storage.entries(run_id):
255
+ pl = e.payload
256
+ if e.type == "run.created":
257
+ money = pl.get("config", {}).get("limits", {}).get("money")
258
+ limit = float(money) if isinstance(money, int | float) else None
259
+ continue
260
+ if e.type == "call.recorded":
261
+ source = pl["source"]
262
+ elif e.type == "experiment.finished":
263
+ source = "executor"
264
+ else:
265
+ continue
266
+ if not pl.get("metered") or pl.get("cost_usd") is None:
267
+ unmetered += 1
268
+ continue
269
+ cost = float(pl["cost_usd"])
270
+ total += cost
271
+ by_source[source] = by_source.get(source, 0.0) + cost
272
+ return SpendSummary(total_usd=total, by_source=by_source, unmetered_items=unmetered,
273
+ money_limit_usd=limit)
274
+
275
+ def verify(self, run_id: str) -> ChainReport:
276
+ entries = self._storage.entries(run_id)
277
+ prev = GENESIS_HASH
278
+ for i, e in enumerate(entries, start=1):
279
+ reason = None
280
+ if e.seq != i:
281
+ reason = f"expected seq {i}, found {e.seq}"
282
+ elif e.run_id != run_id or e.entry_id != f"{run_id}:{e.seq}":
283
+ reason = "entry does not belong to this run"
284
+ elif e.prev_hash != prev:
285
+ reason = "prev_hash does not match the previous entry"
286
+ elif entry_hash(e.hashed_fields()) != e.hash:
287
+ reason = "hash does not match the entry content"
288
+ if reason:
289
+ return ChainReport(run_id, len(entries), ok=False, first_break_seq=i,
290
+ reason=reason)
291
+ prev = e.hash
292
+ return ChainReport(run_id, len(entries), ok=True,
293
+ head_hash=entries[-1].hash if entries else None)
294
+
295
+ def export(self, run_id: str, dest: Path) -> Path:
296
+ """INV-8: every entry and every referenced artifact, verified. All or nothing."""
297
+ dest = Path(dest)
298
+ if dest.exists() and any(dest.iterdir()):
299
+ raise ExportError("destination exists and is not empty")
300
+ if not self._storage.run_exists(run_id):
301
+ raise UnknownRun(run_id)
302
+ report = self.verify(run_id)
303
+ if not report.ok:
304
+ raise ExportError(f"chain broken at seq {report.first_break_seq}: {report.reason}")
305
+ entries = self._storage.entries(run_id)
306
+ digests = sorted({d for e in entries for d in artifact_refs(e)})
307
+
308
+ dest.parent.mkdir(parents=True, exist_ok=True)
309
+ tmp = Path(tempfile.mkdtemp(dir=dest.parent, prefix=".export-"))
310
+ try:
311
+ (tmp / "artifacts").mkdir()
312
+ kinds: dict[str, str | None] = {}
313
+ for d in digests:
314
+ try:
315
+ data = self._artifacts.get(run_id, d)
316
+ except Exception as exc:
317
+ raise ExportError(f"referenced artifact {d} unavailable: "
318
+ f"{type(exc).__name__}") from None
319
+ (tmp / "artifacts" / d.removeprefix("sha256:")).write_bytes(data)
320
+ kinds[d] = self._storage.artifact_kind(run_id, d)
321
+ with open(tmp / "ledger.jsonl", "w", encoding="utf-8", newline="\n") as f:
322
+ for e in entries:
323
+ f.write(canonical_json(e.to_dict()) + "\n")
324
+ with open(tmp / "rejections.jsonl", "w", encoding="utf-8", newline="\n") as f:
325
+ for r in self._storage.rejections(run_id):
326
+ f.write(canonical_json(r.__dict__) + "\n")
327
+ (tmp / "artifacts" / "index.json").write_text(
328
+ json.dumps(kinds, indent=2, sort_keys=True), encoding="utf-8")
329
+ (tmp / "chain_report.json").write_text(
330
+ json.dumps(report.__dict__, indent=2, sort_keys=True), encoding="utf-8")
331
+ if dest.exists():
332
+ dest.rmdir()
333
+ tmp.rename(dest)
334
+ except BaseException:
335
+ shutil.rmtree(tmp, ignore_errors=True)
336
+ raise
337
+ return dest
ledger/errors.py ADDED
@@ -0,0 +1,36 @@
1
+ """Ledger errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class LedgerError(Exception):
7
+ """Base class for ledger errors."""
8
+
9
+
10
+ class LedgerRejection(LedgerError):
11
+ """An append or artifact write refused by a rule. Nothing was stored (04-ledger.md §5)."""
12
+
13
+ def __init__(self, rule: str, message: str) -> None:
14
+ super().__init__(f"{rule}: {message}")
15
+ self.rule = rule
16
+ self.message = message
17
+
18
+
19
+ class UnknownRun(LedgerError):
20
+ """The run id does not exist."""
21
+
22
+
23
+ class ConcurrentWrite(LedgerError):
24
+ """Another writer appended to the run between validation and storage."""
25
+
26
+
27
+ class ArtifactMissing(LedgerError):
28
+ """No artifact with this digest exists for the run."""
29
+
30
+
31
+ class ArtifactCorrupt(LedgerError):
32
+ """A stored artifact no longer matches its digest."""
33
+
34
+
35
+ class ExportError(LedgerError):
36
+ """An export could not be completed (INV-8)."""