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
loop/decide.py ADDED
@@ -0,0 +1,61 @@
1
+ """Decisions computed by code (03-loop.md §4, §5 step 4.7, INV-2). No LLM involved.
2
+
3
+ Two rules exist (KB-018): `difference_at_least` and `ratio_at_least`, both on
4
+ direction-adjusted means over the stage's seeds.
5
+
6
+ Arithmetic is exact on the values' decimal form (what the ledger stores as JSON), so a result
7
+ exactly at the threshold passes: in binary floating point 0.9 - 0.8 < 0.1.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from fractions import Fraction
14
+ from typing import Any, Literal
15
+
16
+ RULES = ("difference_at_least", "ratio_at_least")
17
+ Outcome = Literal["promote", "discard", "confirm", "inconclusive"]
18
+
19
+
20
+ def _mean(values: list[float]) -> Fraction:
21
+ return sum((Fraction(repr(float(v))) for v in values), Fraction(0)) / len(values)
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Verdict:
26
+ outcome: Outcome
27
+ reason_code: str | None
28
+ reason_text: str
29
+ comparison: dict[str, Any]
30
+
31
+
32
+ def evaluate(rule: str, direction: str, threshold: float, treatment: list[float],
33
+ baseline: list[float], *, confirm_stage: bool) -> Verdict:
34
+ """Pass: promote (or confirm at the confirmation stage). Fail: discard (refuted).
35
+ Missing values or an unknown rule: inconclusive (no_signal)."""
36
+ if rule not in RULES or direction not in ("maximize", "minimize"):
37
+ return Verdict("inconclusive", "no_signal", f"unknown rule {rule!r} or direction",
38
+ {"rule": rule})
39
+ if not treatment or not baseline:
40
+ return Verdict("inconclusive", "no_signal", "metric missing for an arm",
41
+ {"rule": rule, "n_treatment": len(treatment),
42
+ "n_baseline": len(baseline)})
43
+ t, b = _mean(treatment), _mean(baseline)
44
+ if rule == "difference_at_least":
45
+ exact = t - b if direction == "maximize" else b - t
46
+ else:
47
+ if b <= 0 or t <= 0:
48
+ return Verdict("inconclusive", "no_signal", "ratio undefined (a mean is not "
49
+ "positive)",
50
+ {"rule": rule, "treatment_mean": float(t), "baseline_mean": float(b)})
51
+ exact = t / b if direction == "maximize" else b / t
52
+ passed = exact >= Fraction(repr(float(threshold)))
53
+ value = float(exact)
54
+ comparison = {"rule": rule, "value": value, "threshold": threshold, "passed": passed,
55
+ "treatment_mean": float(t), "baseline_mean": float(b),
56
+ "n_treatment": len(treatment),
57
+ "n_baseline": len(baseline), "direction": direction}
58
+ if passed:
59
+ outcome: Outcome = "confirm" if confirm_stage else "promote"
60
+ return Verdict(outcome, None, f"{rule} {value:.6g} >= {threshold:.6g}", comparison)
61
+ return Verdict("discard", "refuted", f"{rule} {value:.6g} < {threshold:.6g}", comparison)
loop/harness.py ADDED
@@ -0,0 +1,132 @@
1
+ """Experiment harness conventions (03-loop.md §5 step 4.4, 05-executor.md §4).
2
+
3
+ The coder LLM writes the files; this module packs them, builds the command, and checks what
4
+ the job reported. Every check here is code: no LLM sees a check it could argue with.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import json
11
+ import math
12
+ import re
13
+ import tarfile
14
+ from pathlib import PurePosixPath
15
+ from typing import Any
16
+
17
+ from executor import METRICS_FILE
18
+
19
+ MANIFEST_FILE = "outputs/pm_data_manifest.json"
20
+ ROLES = ("selection", "held_out")
21
+ MAX_FILES = 50
22
+ MAX_BUNDLE_BYTES = 1_000_000
23
+ _HEX64 = re.compile(r"^(sha256:)?[0-9a-f]{64}$")
24
+
25
+
26
+ class HarnessInvalid(ValueError):
27
+ """The coder's files cannot be packed (bad paths, too large)."""
28
+
29
+
30
+ def bundle(files: list[dict[str, str]]) -> bytes:
31
+ """A deterministic tar archive of the coder's files (same files, same digest)."""
32
+ if not files or len(files) > MAX_FILES:
33
+ raise HarnessInvalid(f"a harness needs 1 to {MAX_FILES} files")
34
+ seen: set[str] = set()
35
+ out = io.BytesIO()
36
+ with tarfile.open(fileobj=out, mode="w", format=tarfile.PAX_FORMAT) as tar:
37
+ for f in sorted(files, key=lambda f: f["path"]):
38
+ path = PurePosixPath(f["path"])
39
+ if (path.is_absolute() or ".." in path.parts or not path.parts
40
+ or str(path) in seen or "\\" in f["path"]):
41
+ raise HarnessInvalid(f"invalid file path {f['path']!r}")
42
+ seen.add(str(path))
43
+ data = f["content"].encode("utf-8")
44
+ info = tarfile.TarInfo(str(path))
45
+ info.size, info.mtime, info.mode = len(data), 0, 0o644
46
+ tar.addfile(info, io.BytesIO(data))
47
+ raw = out.getvalue()
48
+ if len(raw) > MAX_BUNDLE_BYTES:
49
+ raise HarnessInvalid("the harness is larger than 1 MB")
50
+ return raw
51
+
52
+
53
+ def unbundle(code: bytes) -> list[dict[str, str]]:
54
+ files = []
55
+ with tarfile.open(fileobj=io.BytesIO(code), mode="r:*") as tar:
56
+ for m in tar.getmembers():
57
+ fh = tar.extractfile(m) if m.isfile() else None
58
+ if fh is not None:
59
+ files.append({"path": m.name, "content": fh.read().decode("utf-8", "replace")})
60
+ return files
61
+
62
+
63
+ def command(entrypoint: str, arm: str, stage: str, budget_s: float) -> str:
64
+ return f"{entrypoint} --arm {arm} --stage {stage} --budget-s {int(budget_s)}"
65
+
66
+
67
+ def _json_list(raw: bytes | None, what: str) -> tuple[list[Any], str | None]:
68
+ if raw is None:
69
+ return [], f"{what} missing"
70
+ try:
71
+ data = json.loads(raw.decode("utf-8"))
72
+ except (UnicodeDecodeError, json.JSONDecodeError):
73
+ return [], f"{what} is not valid JSON"
74
+ if not isinstance(data, list):
75
+ return [], f"{what} is not a list"
76
+ return data, None
77
+
78
+
79
+ def parse_metrics(raw: bytes | None) -> tuple[list[dict[str, Any]], str | None]:
80
+ """Valid metric records, and a problem if the file was missing or malformed. A missing
81
+ `set` defaults to the data role, so selection and held-out values never share one."""
82
+ data, problem = _json_list(raw, "metrics file")
83
+ out: list[dict[str, Any]] = []
84
+ for m in data:
85
+ if not isinstance(m, dict):
86
+ return [], "metrics file has a non-object item"
87
+ name, value, role = m.get("name"), m.get("value"), m.get("data_role")
88
+ if (not isinstance(name, str) or not name or isinstance(value, bool)
89
+ or not isinstance(value, int | float) or not math.isfinite(value)
90
+ or role not in ROLES):
91
+ return [], "metrics file has an invalid item (name, finite value, data_role)"
92
+ unit = m.get("unit")
93
+ out.append({"name": name, "value": float(value),
94
+ "unit": unit if isinstance(unit, str) else "", "data_role": role,
95
+ "set": str(m["set"]) if isinstance(m.get("set"), str) else role})
96
+ return out, problem
97
+
98
+
99
+ def parse_manifest(raw: bytes | None) -> tuple[list[dict[str, Any]], str | None]:
100
+ data, problem = _json_list(raw, "data manifest")
101
+ if problem:
102
+ return [], problem
103
+ out = []
104
+ for d in data:
105
+ if not isinstance(d, dict) or not isinstance(d.get("source"), str) or not d["source"]:
106
+ return [], "data manifest item without a source"
107
+ if d.get("role") not in ROLES:
108
+ return [], "data manifest item without a valid role"
109
+ sha = str(d.get("sha256", "")).lower()
110
+ if not _HEX64.match(sha):
111
+ return [], "data manifest item without a SHA-256"
112
+ licence = d.get("licence")
113
+ out.append({"source": d["source"], "role": d["role"],
114
+ "licence": licence if isinstance(licence, str) else None,
115
+ "digest": "sha256:" + sha.removeprefix("sha256:")})
116
+ roles = {d["role"] for d in out}
117
+ if roles != set(ROLES):
118
+ return [], "data manifest must list selection and held-out data"
119
+ return out, None
120
+
121
+
122
+ def manifest_key(sets: list[dict[str, Any]]) -> list[tuple[str, str, str]]:
123
+ return sorted((str(d["role"]), str(d["source"]), str(d["digest"])) for d in sets)
124
+
125
+
126
+ def metric_values(metrics: list[dict[str, Any]], name: str, role: str
127
+ ) -> list[dict[str, Any]]:
128
+ return [m for m in metrics if m["name"] == name and m["data_role"] == role]
129
+
130
+
131
+ __all__ = ["MANIFEST_FILE", "METRICS_FILE", "HarnessInvalid", "bundle", "command",
132
+ "manifest_key", "metric_values", "parse_manifest", "parse_metrics", "unbundle"]
loop/prompts.py ADDED
@@ -0,0 +1,88 @@
1
+ """Instructions and output schemas for each LLM step (03-loop.md §5).
2
+
3
+ Every instruction starts with `Task: <name>.` Data the model reads (literature, adapter
4
+ responses, experiment output) goes in the user message, quoted with `LLM.quote` (INV-11).
5
+ No schema has a field that could set an outcome, a limit or a rule in force (INV-2, INV-6).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from .decide import RULES
13
+
14
+
15
+ def _obj(required: list[str], props: dict[str, Any]) -> dict[str, Any]:
16
+ return {"type": "object", "additionalProperties": False, "required": required,
17
+ "properties": props}
18
+
19
+
20
+ _STR = {"type": "string", "minLength": 1}
21
+
22
+ DIRECTIONS = (
23
+ "Task: directions. From the research idea and the quoted literature evidence, propose "
24
+ "up to five distinct research directions that the evidence leaves open and that can be "
25
+ "tested by computation within a small budget. Give each a one-sentence rationale.")
26
+ DIRECTIONS_SCHEMA = _obj(["directions"], {"directions": {
27
+ "type": "array", "minItems": 1, "maxItems": 5,
28
+ "items": _obj(["direction", "rationale"], {"direction": _STR, "rationale": _STR})}})
29
+
30
+ HYPOTHESIS = (
31
+ "Task: hypothesis. Form one testable hypothesis from the direction, and the baseline it "
32
+ "will be compared against: the same experiment without the change. Name one metric with "
33
+ "its unit and direction, the comparison rule and the threshold the treatment must reach "
34
+ "against the baseline, and the data to evaluate on, with a selection set and a separate "
35
+ "held-out set. Rules: difference_at_least (treatment mean minus baseline mean, adjusted "
36
+ "for direction) or ratio_at_least (ratio of the means, adjusted for direction).")
37
+ HYPOTHESIS_SCHEMA = _obj(
38
+ ["treatment", "baseline", "metric", "rule", "threshold", "data"],
39
+ {"treatment": _STR, "baseline": _STR,
40
+ "metric": _obj(["name", "unit", "direction"], {
41
+ "name": _STR, "unit": {"type": "string"},
42
+ "direction": {"enum": ["maximize", "minimize"]}}),
43
+ "rule": {"enum": list(RULES)}, "threshold": {"type": "number"}, "data": _STR})
44
+
45
+ NOVELTY = (
46
+ "Task: novelty. Judge whether the hypothesis overlaps with the quoted closest prior work. "
47
+ "`novel`: no quoted work tests it. `overlap`: a quoted work already tests it; if a "
48
+ "narrower or different version would still be new, give it in `reframed`, otherwise "
49
+ "leave `reframed` empty. `unclear`: the evidence does not settle it.")
50
+ NOVELTY_SCHEMA = _obj(["judgement", "reason", "reframed"], {
51
+ "judgement": {"enum": ["novel", "overlap", "unclear"]}, "reason": _STR,
52
+ "reframed": {"type": "string"}})
53
+
54
+ HARNESS = (
55
+ "Task: harness. Write Python experiment code that tests the hypothesis against its "
56
+ "baseline. Conventions, all required:\n"
57
+ "- It is run as `<entrypoint> --arm treatment|baseline --stage smoke|0|1|...|confirm "
58
+ "--budget-s N`; the seed is in the environment variable PM_SEED. The two arms differ "
59
+ "only in the change under test.\n"
60
+ "- It finishes within N seconds of wall-clock time.\n"
61
+ "- It writes outputs/pm_metrics.json: a JSON list of {name, value, unit, data_role, set}. "
62
+ "Report the named metric with data_role `selection` at every stage. Only when --stage "
63
+ "is `confirm`, also report it on the held-out data with data_role `held_out`.\n"
64
+ "- It writes outputs/pm_data_manifest.json: a JSON list of {source, licence, role, "
65
+ "sha256} for every data set it uses, role `selection` or `held_out`, sha256 of the "
66
+ "data's content as used. Both roles must be present.\n"
67
+ "- Data mounted by the user is read-only under /data/<name>. The network may be off.\n"
68
+ "Give the files, the entrypoint (for example `python main.py`) and a pinned "
69
+ "requirements list (may be empty).")
70
+ FIX = (
71
+ "Task: fix. The harness failed its smoke run. The failure and the job output are "
72
+ "quoted. Return the corrected harness, complete, under the same conventions:\n"
73
+ + HARNESS.split("Conventions, all required:\n", 1)[1])
74
+ HARNESS_SCHEMA = _obj(["files", "entrypoint", "requirements"], {
75
+ "files": {"type": "array", "minItems": 1, "maxItems": 50,
76
+ "items": _obj(["path", "content"], {"path": _STR, "content": {"type": "string"}})},
77
+ "entrypoint": _STR, "requirements": {"type": "string"}})
78
+
79
+ RANK = (
80
+ "Task: rank. Order the frontier items by expected value per unit of remaining budget. "
81
+ "Cheap pilots of new hypotheses compete with promotions. Return every item id once.")
82
+ RANK_SCHEMA = _obj(["order"], {"order": {"type": "array", "items": _STR}})
83
+
84
+ # Thinking counts against these on models that always think (Claude Sonnet 5.5): with 8000,
85
+ # 12 of 16 harness calls returned no text (DL-20261102-03). The cost guard uses the same
86
+ # numbers, so the pre-call bound stays a true upper bound.
87
+ MAX_TOKENS = {"directions": 4000, "hypothesis": 4000, "novelty": 4000, "harness": 32000,
88
+ "fix": 32000, "rank": 2000}
loop/view.py ADDED
@@ -0,0 +1,202 @@
1
+ """The run as `loop` sees it: a replay of the ledger (03-loop.md §4, INV-10).
2
+
3
+ Nothing here is stored. `RunView` is rebuilt from the entries whenever `loop` needs it, so
4
+ a restarted worker sees exactly what the crashed one recorded.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import copy
10
+ from dataclasses import dataclass, field
11
+ from datetime import UTC, datetime
12
+ from typing import Any
13
+
14
+ from ledger import CONFIRM_STAGE, SMOKE_STAGE, Entry
15
+
16
+ from .config import RunConfig
17
+
18
+ FINAL = ("discard", "confirm", "inconclusive")
19
+
20
+
21
+ def parse_at(at: str) -> datetime:
22
+ return datetime.fromisoformat(at.replace("Z", "+00:00")).astimezone(UTC)
23
+
24
+
25
+ def stage_order(stage: str) -> int:
26
+ if stage == SMOKE_STAGE:
27
+ return -1
28
+ if stage == CONFIRM_STAGE:
29
+ return 10_000
30
+ return int(stage)
31
+
32
+
33
+ @dataclass
34
+ class Checkpoint:
35
+ id: str
36
+ requested: str
37
+ options: Any
38
+ decided: str | None = None
39
+ decision: Any = None
40
+
41
+
42
+ @dataclass
43
+ class Hyp:
44
+ id: str
45
+ statement: str
46
+ idea: str
47
+ kind: str
48
+ baseline: str | None
49
+ plan: dict[str, Any]
50
+ reframes: str | None
51
+ novelty: dict[str, Any] | None = None
52
+ decisions: list[tuple[str, dict[str, Any]]] = field(default_factory=list)
53
+
54
+ def final(self) -> dict[str, Any] | None:
55
+ for _, d in reversed(self.decisions):
56
+ if d["outcome"] in FINAL:
57
+ return dict(d)
58
+ return None
59
+
60
+
61
+ @dataclass
62
+ class Experiment:
63
+ id: str
64
+ prereg: str
65
+ arm: str
66
+ seed: int
67
+ payload: dict[str, Any]
68
+ finished_id: str | None = None
69
+ finished: dict[str, Any] | None = None
70
+
71
+
72
+ @dataclass
73
+ class RunView:
74
+ run_id: str
75
+ config: RunConfig
76
+ created_at: datetime
77
+ status: str | None = None
78
+ phase: str | None = None
79
+ reason: str = ""
80
+ resume_status: str | None = None
81
+ inputs: list[str] = field(default_factory=list) # entry ids the phase works from
82
+ status_log: list[tuple[datetime, str]] = field(default_factory=list)
83
+ ideas: dict[str, dict[str, Any]] = field(default_factory=dict)
84
+ hypotheses: dict[str, Hyp] = field(default_factory=dict)
85
+ preregs: dict[str, dict[str, Any]] = field(default_factory=dict) # merged with amendments
86
+ prereg_key: dict[str, tuple[str, str]] = field(default_factory=dict)
87
+ in_force: dict[tuple[str, str], str] = field(default_factory=dict)
88
+ amendments: dict[tuple[str, str], int] = field(default_factory=dict)
89
+ experiments: dict[str, Experiment] = field(default_factory=dict)
90
+ checkpoints: dict[str, Checkpoint] = field(default_factory=dict)
91
+ calls: dict[str, dict[str, Any]] = field(default_factory=dict)
92
+ papers: list[str] = field(default_factory=list)
93
+
94
+ # ---- queries -----------------------------------------------------------------------
95
+
96
+ def active_elapsed_s(self, now: datetime) -> float:
97
+ """Time since the run was created, minus time spent paused (§5 step 5)."""
98
+ total, since, active = 0.0, self.created_at, True
99
+ for at, status in self.status_log:
100
+ if active:
101
+ total += (at - since).total_seconds()
102
+ since = at
103
+ active = status not in ("paused", "delivered", "failed")
104
+ if active:
105
+ total += (now - since).total_seconds()
106
+ return max(total, 0.0)
107
+
108
+ def open_checkpoint(self) -> Checkpoint | None:
109
+ for cp in reversed(list(self.checkpoints.values())): # the latest one waiting
110
+ if cp.decided is None:
111
+ return cp
112
+ return None
113
+
114
+ def active_directions(self) -> list[str]:
115
+ c1 = self.checkpoints.get("C1")
116
+ if c1 is not None and c1.decided is not None:
117
+ keep = list(c1.decision.get("keep", []))
118
+ added = [i for i, p in self.ideas.items() if c1.decided in p.get("parents", [])]
119
+ return [i for i in keep if i in self.ideas] + added
120
+ return [i for i, p in self.ideas.items() if p.get("_actor") != "user"]
121
+
122
+ def treatments(self) -> list[Hyp]:
123
+ return [h for h in self.hypotheses.values() if h.kind == "treatment"]
124
+
125
+ def under(self, prereg_id: str) -> list[Experiment]:
126
+ return [x for x in self.experiments.values() if x.prereg == prereg_id]
127
+
128
+ def stages_of(self, h: str) -> list[tuple[str, str]]:
129
+ """(stage, prereg in force) for the hypothesis's non-smoke stages, in ladder order."""
130
+ found = [(stage, pid) for (hh, stage), pid in self.in_force.items()
131
+ if hh == h and stage != SMOKE_STAGE]
132
+ return sorted(found, key=lambda x: stage_order(x[0]))
133
+
134
+ def decision_at(self, h: str, stage: str) -> dict[str, Any] | None:
135
+ for _, d in reversed(self.hypotheses[h].decisions):
136
+ if d["stage"] == stage:
137
+ return dict(d)
138
+ return None
139
+
140
+
141
+ def build_view(entries: list[Entry]) -> RunView:
142
+ created = entries[0]
143
+ view = RunView(run_id=created.run_id, config=RunConfig.model_validate(
144
+ created.payload["config"]), created_at=parse_at(created.at))
145
+ for e in entries[1:]:
146
+ _apply(view, e)
147
+ return view
148
+
149
+
150
+ def _apply(view: RunView, e: Entry) -> None:
151
+ p = e.payload
152
+ t = e.type
153
+ if t == "run.status":
154
+ view.status_log.append((parse_at(e.at), p["status"]))
155
+ view.status = p["status"]
156
+ view.phase = p.get("phase", view.phase)
157
+ view.reason = p.get("reason", "")
158
+ view.resume_status = p.get("resume_status")
159
+ if "inputs" in p:
160
+ view.inputs = list(p["inputs"])
161
+ elif t == "call.recorded":
162
+ view.calls[e.entry_id] = p
163
+ elif t == "idea.proposed":
164
+ view.ideas[e.entry_id] = {**p, "_actor": e.actor}
165
+ elif t == "hypothesis.proposed":
166
+ view.hypotheses[e.entry_id] = Hyp(
167
+ id=e.entry_id, statement=p["statement"], idea=p["idea"], kind=p["kind"],
168
+ baseline=p.get("baseline"), plan=dict(p.get("plan", {})),
169
+ reframes=p.get("reframes"))
170
+ elif t == "hypothesis.novelty":
171
+ view.hypotheses[p["hypothesis"]].novelty = {**p, "_id": e.entry_id}
172
+ elif t == "prereg.registered":
173
+ key = (p["hypothesis"], p["stage"])
174
+ view.preregs[e.entry_id] = copy.deepcopy(p)
175
+ view.prereg_key[e.entry_id] = key
176
+ view.in_force[key] = e.entry_id
177
+ elif t == "prereg.amended":
178
+ key = view.prereg_key[p["supersedes"]]
179
+ merged = copy.deepcopy(view.preregs[p["supersedes"]])
180
+ merged.update(copy.deepcopy(p["changes"]))
181
+ view.preregs[e.entry_id] = merged
182
+ view.prereg_key[e.entry_id] = key
183
+ view.in_force[key] = e.entry_id
184
+ view.amendments[key] = view.amendments.get(key, 0) + 1
185
+ elif t == "experiment.started":
186
+ view.experiments[e.entry_id] = Experiment(
187
+ id=e.entry_id, prereg=p["prereg"], arm=str(p.get("arm", "treatment")),
188
+ seed=int(p["seed"]), payload=p)
189
+ elif t == "experiment.finished":
190
+ x = view.experiments.get(p["experiment"])
191
+ if x is not None:
192
+ x.finished_id, x.finished = e.entry_id, p
193
+ elif t == "decision.made":
194
+ view.hypotheses[p["hypothesis"]].decisions.append((e.entry_id, p))
195
+ elif t == "checkpoint.requested":
196
+ view.checkpoints[p["checkpoint_id"]] = Checkpoint(
197
+ id=p["checkpoint_id"], requested=e.entry_id, options=p.get("options"))
198
+ elif t == "checkpoint.decided":
199
+ cp = view.checkpoints[p["checkpoint_id"]]
200
+ cp.decided, cp.decision = e.entry_id, p["decision"]
201
+ elif t == "paper.built":
202
+ view.papers.append(e.entry_id)
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.5
2
+ Name: puremethod
3
+ Version: 0.1.0
4
+ Summary: Autonomous research platform: an idea in, a LaTeX paper and a run bundle out
5
+ Author: Networked Systems Laboratory
6
+ License-Expression: Elastic-2.0
7
+ License-File: LICENSE
8
+ License-File: THIRD_PARTY_NOTICES.md
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: anthropic>=0.40
11
+ Requires-Dist: cryptography>=50.0
12
+ Requires-Dist: fastapi<0.144,>=0.143
13
+ Requires-Dist: httpx>=0.28
14
+ Requires-Dist: jsonschema>=4.23
15
+ Requires-Dist: matplotlib>=3.9
16
+ Requires-Dist: openai>=1.50
17
+ Requires-Dist: paramiko>=3.5
18
+ Requires-Dist: platformdirs==4.13.0
19
+ Requires-Dist: psycopg[binary]>=3.2
20
+ Requires-Dist: pydantic>=2.9
21
+ Requires-Dist: starlette<1.9,>=1.8
22
+ Requires-Dist: tiktoken>=0.8
23
+ Requires-Dist: uvicorn>=0.54
24
+ Description-Content-Type: text/markdown
25
+
26
+ # puremethod
27
+
28
+ An autonomous research platform for computational research in ML/DL and simulation/systems.
29
+ You give it a research idea. It maps the literature, forms hypotheses, tests them in staged
30
+ experiments on machines you choose, and writes a LaTeX paper. With the paper you get a run
31
+ bundle: the code, configs, raw results and a log of every idea it tried, failures included.
32
+
33
+ - **Locked criteria.** Each experiment's metric, threshold and data splits are recorded
34
+ before it runs.
35
+ - **Grounded citations.** Every citation comes from a source that was actually retrieved.
36
+ - **Traceable numbers.** Every number, table and figure in the paper comes from a recorded
37
+ run.
38
+
39
+ ## Install
40
+
41
+ You need Python 3.11 or newer. Docker is recommended (isolated experiments, the LaTeX build).
42
+
43
+ ```sh
44
+ pipx install puremethod # or: uv tool install puremethod
45
+ puremethod
46
+ ```
47
+
48
+ `puremethod` starts a local server and opens your browser, already signed in. Everything
49
+ runs on your machine and is reachable only from it (127.0.0.1).
50
+
51
+ ## Commands
52
+
53
+ | Command | Does |
54
+ |---|---|
55
+ | `puremethod` | Start, then open the browser signed in |
56
+ | `puremethod open` | A new sign-in link for a running server |
57
+ | `puremethod stop` | Stop the server |
58
+ | `puremethod doctor` | Check Python, Docker, ports and directories |
59
+ | `puremethod token rotate` | Print a new API token for scripts, and end all sessions |
60
+
61
+ ## Your data and keys
62
+
63
+ - **Your files:** runs, the ledger and outputs are kept in your user data directory.
64
+ - **The master key:** on first start, puremethod creates `master.key` in your user config
65
+ directory. Your stored API keys are encrypted with it. Back it up: without it, stored keys
66
+ cannot be read and must be entered again.
67
+ - **Your accounts:** you bring your own model provider keys and machines. puremethod has
68
+ no account and no billing of its own.
69
+
70
+ ## Status
71
+
72
+ Version 0.1, self-hosted. Literature and citations come from PureScholar. By default,
73
+ puremethod runs a bundled local PureScholar in Docker on this computer; you can instead
74
+ connect to a PureScholar deployment with its URL and key.
75
+
76
+ ## Licence
77
+
78
+ Elastic License 2.0. You may use, modify and share it free of charge. You may not offer it to
79
+ others as a hosted or managed service. See `LICENSE` and `THIRD_PARTY_NOTICES.md`.