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.
- app/__init__.py +2 -0
- app/__main__.py +31 -0
- app/_web/404/index.html +1 -0
- app/_web/404.html +1 -0
- app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
- app/_web/__next._full.txt +36 -0
- app/_web/__next._tree.txt +4 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
- app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
- app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
- app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
- app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
- app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
- app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
- app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
- app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
- app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
- app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
- app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
- app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
- app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
- app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
- app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
- app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
- app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
- app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
- app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
- app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
- app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
- app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
- app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
- app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
- app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
- app/_web/_not-found/__next._full.txt +24 -0
- app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
- app/_web/_not-found/__next._tree.txt +4 -0
- app/_web/_not-found/index.html +1 -0
- app/_web/_not-found/index.txt +24 -0
- app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
- app/_web/credentials/__next._full.txt +39 -0
- app/_web/credentials/__next._tree.txt +5 -0
- app/_web/credentials/index.html +1 -0
- app/_web/credentials/index.txt +39 -0
- app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
- app/_web/executors/__next._full.txt +39 -0
- app/_web/executors/__next._tree.txt +5 -0
- app/_web/executors/index.html +1 -0
- app/_web/executors/index.txt +39 -0
- app/_web/index.html +1 -0
- app/_web/index.txt +36 -0
- app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
- app/_web/new/__next._full.txt +39 -0
- app/_web/new/__next._tree.txt +5 -0
- app/_web/new/index.html +1 -0
- app/_web/new/index.txt +39 -0
- app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
- app/_web/profiles/__next._full.txt +39 -0
- app/_web/profiles/__next._tree.txt +5 -0
- app/_web/profiles/index.html +1 -0
- app/_web/profiles/index.txt +39 -0
- app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
- app/_web/run/__next._full.txt +40 -0
- app/_web/run/__next._tree.txt +5 -0
- app/_web/run/index.html +1 -0
- app/_web/run/index.txt +40 -0
- app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
- app/_web/services/__next._full.txt +39 -0
- app/_web/services/__next._tree.txt +5 -0
- app/_web/services/index.html +1 -0
- app/_web/services/index.txt +39 -0
- app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
- app/_web/setup/__next._full.txt +39 -0
- app/_web/setup/__next._tree.txt +5 -0
- app/_web/setup/index.html +1 -0
- app/_web/setup/index.txt +39 -0
- app/_web/signin/__next._full.txt +30 -0
- app/_web/signin/__next._tree.txt +4 -0
- app/_web/signin/__next.signin.__PAGE__.txt +36 -0
- app/_web/signin/index.html +1 -0
- app/_web/signin/index.txt +30 -0
- app/accounts.py +137 -0
- app/addresses.py +30 -0
- app/api/__init__.py +61 -0
- app/api/auth_routes.py +54 -0
- app/api/common.py +102 -0
- app/api/config_routes.py +562 -0
- app/api/links.py +22 -0
- app/api/run_routes.py +311 -0
- app/api/setup_routes.py +267 -0
- app/bundled.py +521 -0
- app/compose.py +229 -0
- app/db.py +186 -0
- app/executor_config.py +95 -0
- app/keys.py +29 -0
- app/launcher.py +389 -0
- app/leases.py +40 -0
- app/literature_sources.py +131 -0
- app/model_endpoints.py +104 -0
- app/model_search.py +252 -0
- app/outputs.py +77 -0
- app/paths.py +93 -0
- app/readonly.py +70 -0
- app/repo.py +746 -0
- app/scan.py +65 -0
- app/server.py +24 -0
- app/settings.py +125 -0
- app/setup.py +117 -0
- app/ssh_chain.py +102 -0
- app/ssh_hosts.py +244 -0
- app/ssh_machines.py +101 -0
- app/statuses.py +4 -0
- app/supervisor.py +87 -0
- app/tunnels.py +222 -0
- app/unseal.py +27 -0
- app/worker.py +657 -0
- executor/__init__.py +47 -0
- executor/base.py +161 -0
- executor/common.py +139 -0
- executor/local_docker.py +239 -0
- executor/local_process.py +247 -0
- executor/ssh.py +481 -0
- executor/types.py +128 -0
- executor/watchdog.py +108 -0
- ledger/__init__.py +46 -0
- ledger/artifacts.py +62 -0
- ledger/canonical.py +48 -0
- ledger/core.py +337 -0
- ledger/errors.py +36 -0
- ledger/model.py +107 -0
- ledger/payloads.py +228 -0
- ledger/rules.py +204 -0
- ledger/storage.py +226 -0
- llm/__init__.py +45 -0
- llm/backends/__init__.py +0 -0
- llm/backends/anthropic_backend.py +145 -0
- llm/backends/base.py +87 -0
- llm/backends/openai_backend.py +211 -0
- llm/core.py +416 -0
- llm/prices.py +98 -0
- llm/tokens.py +18 -0
- llm/types.py +106 -0
- loop/__init__.py +19 -0
- loop/config.py +108 -0
- loop/core.py +1160 -0
- loop/decide.py +61 -0
- loop/harness.py +132 -0
- loop/prompts.py +88 -0
- loop/view.py +202 -0
- puremethod-0.1.0.dist-info/METADATA +79 -0
- puremethod-0.1.0.dist-info/RECORD +171 -0
- puremethod-0.1.0.dist-info/WHEEL +4 -0
- puremethod-0.1.0.dist-info/entry_points.txt +2 -0
- puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
- puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
- purescholar/__init__.py +43 -0
- purescholar/client.py +380 -0
- purescholar/types.py +164 -0
- puretrace/__init__.py +49 -0
- puretrace/client.py +320 -0
- puretrace/types.py +166 -0
- writer/__init__.py +8 -0
- writer/core.py +430 -0
- writer/facts.py +162 -0
- writer/latex.py +62 -0
- writer/lint.py +85 -0
- writer/macros.py +114 -0
- writer/render.py +120 -0
- writer/tectonic.py +79 -0
- 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`.
|