1+"""Publish grape-leaves train.py runs as Radicle cc.experiment COBs.
2+
3+This module wraps the python-radicle SDK so train.py can call a single
4+`publish_run(...)` after training completes. It does not run any inference
5+or training itself — it only captures git/env state, looks up the parent
6+commit's prior experiment COB for baseline values, and publishes a new
7+COB through `radicle.experiment.Experiments`.
8+"""
9+
10+from __future__ import annotations
11+
12+import os
13+import platform
14+import subprocess
15+from pathlib import Path
16+from typing import Any
17+
18+import radicle
19+from radicle import experiment
20+
21+_METRIC_DEFS: tuple[tuple[str, str, str], ...] = (
22+ ("val_acc", "%", experiment.Criteria.HIGHER_IS_BETTER),
23+ ("val_loss", "", experiment.Criteria.LOWER_IS_BETTER),
24+ ("train_loss", "", experiment.Criteria.LOWER_IS_BETTER),
25+)
26+
27+
28+def _run_git(repo_dir: Path, *args: str) -> str:
29+ result = subprocess.run(
30+ ["git", "-C", str(repo_dir), *args],
31+ check=True,
32+ capture_output=True,
33+ text=True,
34+ )
35+ return result.stdout.strip()
36+
37+
38+def _capture_env() -> experiment.Env:
39+ try:
40+ memory_bytes = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
41+ except (OSError, ValueError):
42+ memory_bytes = 0
43+ return experiment.Env(
44+ arch=platform.machine() or "",
45+ os=platform.platform() or "",
46+ cpu=platform.processor() or "",
47+ memory_bytes=memory_bytes,
48+ )
49+
50+
51+def _capture_commits(repo_dir: Path) -> tuple[str, str | None]:
52+ candidate = _run_git(repo_dir, "rev-parse", "HEAD")
53+ try:
54+ parent = _run_git(repo_dir, "rev-parse", "HEAD~1")
55+ except subprocess.CalledProcessError:
56+ parent = None
57+ return candidate, parent
58+
59+
60+def _check_clean_worktree(repo_dir: Path, allow_dirty: bool) -> None:
61+ if allow_dirty:
62+ return
63+ # `git diff --quiet` exits 1 when the worktree differs, 0 when clean.
64+ # We intentionally don't go through `_run_git` here because we need the
65+ # return code, not an exception.
66+ unstaged = subprocess.run(
67+ ["git", "-C", str(repo_dir), "diff", "--quiet"],
68+ )
69+ staged = subprocess.run(
70+ ["git", "-C", str(repo_dir), "diff", "--cached", "--quiet"],
71+ )
72+ if unstaged.returncode != 0 or staged.returncode != 0:
73+ raise RuntimeError(
74+ "worktree is dirty — pass --allow-dirty to override"
75+ )
76+
77+
78+def _normalize_rid(value: str) -> str:
79+ # Normalize:
80+ # rad://<rid> -> rad:<rid>
81+ # rad://<rid>/<peer-id> -> rad:<rid> (drop peer suffix)
82+ # rad:<rid> -> rad:<rid> (already canonical)
83+ if value.startswith("rad://"):
84+ body = value[len("rad://"):]
85+ rid_part = body.split("/", 1)[0]
86+ return f"rad:{rid_part}"
87+ return value
88+
89+
90+def _resolve_rid(repo_dir: Path, override: str | None) -> str:
91+ raw = override if override is not None else _run_git(repo_dir, "remote", "get-url", "rad")
92+ return radicle.parse_repo_id(_normalize_rid(raw))
93+
94+
95+def _lookup_baseline(
96+ store: experiment.Experiments,
97+ parent_oid: str | None,
98+ metric_name: str,
99+) -> experiment.Measurement:
100+ if parent_oid is None:
101+ return experiment.Measurement()
102+ best: experiment.Measurement | None = None
103+ best_t = -1
104+ for _id, exp in store.list():
105+ if exp.oid != parent_oid or exp.created_at_millis <= best_t:
106+ continue
107+ for m in exp.metrics:
108+ if m.name == metric_name:
109+ best = m.candidate
110+ best_t = exp.created_at_millis
111+ break
112+ return best if best is not None else experiment.Measurement()
113+
114+
115+def _build_metric(
116+ name: str,
117+ value: float,
118+ unit: str,
119+ criteria: str,
120+ baseline: experiment.Measurement,
121+) -> experiment.Metric:
122+ # val_acc is preserved as a fraction in [0, 1] for compatibility with the
123+ # autoresearch regex pipeline; do not multiply by 100 even though unit='%'.
124+ scaled = round(value * 1000)
125+ candidate = experiment.Measurement(
126+ n=1,
127+ median_x1000=scaled,
128+ std_x1000=0,
129+ samples_x1000=[scaled],
130+ )
131+ return experiment.Metric(
132+ name=name,
133+ baseline=baseline,
134+ candidate=candidate,
135+ unit=unit,
136+ criteria=criteria,
137+ )
138+
139+
140+def _build_config(epochs: int, lr: float, batch_size: int) -> experiment.ExperimentConfig:
141+ # `--log-radicle` is intentionally omitted from bench_cmd so that a
142+ # reproduction of this config does not recursively publish a new COB.
143+ return experiment.ExperimentConfig(
144+ bench_cmd=f"uv run python train.py --epochs {epochs} --lr {lr} --batch-size {batch_size}",
145+ build_cmd="uv pip install -e .",
146+ )
147+
148+
149+def _build_asi(epochs: int, lr: float, batch_size: int) -> dict[str, Any]:
150+ return {
151+ "epochs": epochs,
152+ # Radicle COBs use canonical JSON, which rejects floating-point
153+ # numbers. Store the learning rate as a string while preserving the
154+ # exact command in ExperimentConfig.bench_cmd.
155+ "lr": str(lr),
156+ "batch_size": batch_size,
157+ "source": "grape-leaves/train.py --log-radicle",
158+ }
159+
160+
161+def publish_run(
162+ *,
163+ history: dict[str, list[float]],
164+ epochs: int,
165+ lr: float,
166+ batch_size: int,
167+ rid: str | None = None,
168+ description: str | None = None,
169+ allow_dirty: bool = False,
170+ agent_system: str = "grape-leaves/train.py",
171+ agent_model: str = "deterministic",
172+) -> str:
173+ """Publish this run's metrics as a new cc.experiment COB.
174+
175+ Returns the new experiment object id. Raises ``RuntimeError`` if the
176+ worktree is dirty (unless ``allow_dirty=True``), if HEAD is a root
177+ commit, or if ``history`` is empty for any of the three tracked metrics.
178+ Raises ``radicle.Error`` subclasses on profile / repository / signer /
179+ store failures, and ``subprocess.CalledProcessError`` if git fails.
180+ """
181+ for name, _unit, _criteria in _METRIC_DEFS:
182+ if not history.get(name):
183+ raise RuntimeError("no epoch results to publish")
184+
185+ repo_dir = Path(__file__).parent
186+
187+ resolved_rid = _resolve_rid(repo_dir, rid)
188+ _check_clean_worktree(repo_dir, allow_dirty)
189+ candidate_oid, parent_oid = _capture_commits(repo_dir)
190+ if parent_oid is None:
191+ raise RuntimeError(
192+ "cannot publish on a root commit (no HEAD~1); rad-experiment requires a base OID"
193+ )
194+
195+ profile = radicle.Profile.load()
196+ repo = profile.repository(resolved_rid)
197+ signer = profile.signer()
198+ store = experiment.Experiments.open(repo)
199+
200+ metrics: list[experiment.Metric] = []
201+ for name, unit, criteria in _METRIC_DEFS:
202+ baseline = _lookup_baseline(store, parent_oid, name)
203+ value = history[name][-1]
204+ metrics.append(_build_metric(name, value, unit, criteria, baseline))
205+
206+ action = experiment.Action.publish(
207+ schema_version=experiment.SCHEMA_VERSION,
208+ description=description,
209+ base=parent_oid,
210+ oid=candidate_oid,
211+ metrics=metrics,
212+ env=_capture_env(),
213+ agent_system=agent_system,
214+ agent_model=agent_model,
215+ asi=_build_asi(epochs, lr, batch_size),
216+ bench_hash=None,
217+ config=_build_config(epochs, lr, batch_size),
218+ )
219+
220+ obj_id, _exp = store.publish(action, signer)
221+ print(f"Published experiment COB: {obj_id}")
222+ return obj_id
77 # Training loop
78 # ---------------------------------------------------------------------------
79
80-def train(epochs: int = 10, lr: float = 1e-3, batch_size: int = 32):
80+def train(
81+ epochs: int = 10,
82+ lr: float = 1e-3,
83+ batch_size: int = 32,
84+ log_radicle: bool = False,
85+ description: str | None = None,
86+ allow_dirty: bool = False,
87+):
88 device = (
89 torch.device("mps") if torch.backends.mps.is_available()
90 else torch.device("cuda") if torch.cuda.is_available()
168 fig.savefig(out_dir / "training_curves.png", dpi=150)
169 print(f"Training curves saved to {out_dir / 'training_curves.png'}")
170
171+ if log_radicle:
172+ try:
173+ import radicle_log
174+ radicle_log.publish_run(
175+ history=history,
176+ epochs=epochs,
177+ lr=lr,
178+ batch_size=batch_size,
179+ description=description,
180+ allow_dirty=allow_dirty,
181+ )
182+ except Exception as e:
183+ print(f"WARNING: radicle logging failed: {e!r}")
184+
185
186 if __name__ == "__main__":
187 parser = argparse.ArgumentParser()
188 parser.add_argument("--epochs", type=int, default=10)
189 parser.add_argument("--lr", type=float, default=1e-3)
190 parser.add_argument("--batch-size", type=int, default=32)
191+ parser.add_argument(
192+ "--log-radicle",
193+ action="store_true",
194+ help="Publish this run as a cc.experiment COB AFTER training completes; "
195+ "the worktree cleanliness check runs post-train, so a dirty worktree "
196+ "will not refund the training time",
197+ )
198+ parser.add_argument(
199+ "--description",
200+ default=None,
201+ help="Optional human description recorded on the experiment COB",
202+ )
203+ parser.add_argument(
204+ "--allow-dirty",
205+ action="store_true",
206+ help="Allow publishing when the worktree has uncommitted changes "
207+ "(by default radicle logging refuses, since the COB's commit oid "
208+ "would not match the code that actually ran)",
209+ )
210 train(**vars(parser.parse_args()))
304 { name = "kagglehub" },
305 { name = "matplotlib" },
306 { name = "pillow" },
307+ { name = "radicle" },
308 { name = "scikit-learn" },
309 { name = "torch" },
310 { name = "torchvision" },
315 { name = "kagglehub", specifier = ">=1.0.0" },
316 { name = "matplotlib", specifier = ">=3.10.8" },
317 { name = "pillow", specifier = ">=12.2.0" },
318+ { name = "radicle", editable = "../python-radicle" },
319 { name = "scikit-learn", specifier = ">=1.8.0" },
320 { name = "torch", specifier = ">=2.11.0" },
321 { name = "torchvision", specifier = ">=0.26.0" },
972 ]
973
974 [[package]]
975+name = "radicle"
976+version = "0.0.0"
977+source = { editable = "../python-radicle" }
978+
979+[package.metadata]
980+
981+[package.metadata.requires-dev]
982+dev = [
983+ { name = "maturin", specifier = ">=1.7,<2.0" },
984+ { name = "pytest", specifier = ">=8" },
985+]
986+
987+[[package]]
988 name = "requests"
989 version = "2.33.1"
990 source = { registry = "https://pypi.org/simple" }