lab-kit-cli 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.
- lab_kit/__init__.py +3 -0
- lab_kit/__main__.py +3 -0
- lab_kit/_data/agents/reporter.md +30 -0
- lab_kit/_data/agents/reviewer.md +36 -0
- lab_kit/_data/agents/runner.md +41 -0
- lab_kit/_data/agents/scout.md +28 -0
- lab_kit/_data/method/DISCIPLINE.md +110 -0
- lab_kit/_data/method/LADDER.md +72 -0
- lab_kit/_data/skills/experiment/SKILL.md +80 -0
- lab_kit/_data/skills/plan-mission/SKILL.md +78 -0
- lab_kit/_data/skills/review/SKILL.md +75 -0
- lab_kit/_data/skills/run-mission/SKILL.md +83 -0
- lab_kit/_data/skills/set-up-lab/SKILL.md +81 -0
- lab_kit/checks/__init__.py +1 -0
- lab_kit/checks/base.py +41 -0
- lab_kit/checks/files.py +312 -0
- lab_kit/checks/locks.py +134 -0
- lab_kit/checks/results.py +172 -0
- lab_kit/checks/run.py +63 -0
- lab_kit/cli.py +174 -0
- lab_kit/commands/__init__.py +1 -0
- lab_kit/commands/experiments.py +89 -0
- lab_kit/commands/runs.py +105 -0
- lab_kit/commands/setup.py +126 -0
- lab_kit/commands/status.py +26 -0
- lab_kit/data.py +49 -0
- lab_kit/errors.py +2 -0
- lab_kit/frozen.py +48 -0
- lab_kit/gitlog.py +55 -0
- lab_kit/lab.py +135 -0
- lab_kit/ops.py +94 -0
- lab_kit/protocol.py +100 -0
- lab_kit/records.py +245 -0
- lab_kit/rederive.py +54 -0
- lab_kit/supervise.py +80 -0
- lab_kit_cli-0.1.0.dist-info/METADATA +92 -0
- lab_kit_cli-0.1.0.dist-info/RECORD +40 -0
- lab_kit_cli-0.1.0.dist-info/WHEEL +4 -0
- lab_kit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lab_kit_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Checks on results and scores: numbers re-derive, results rest on finished runs, scores match the lock."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import PurePosixPath
|
|
6
|
+
|
|
7
|
+
from .. import protocol as protocol_mod
|
|
8
|
+
from .. import records
|
|
9
|
+
from .. import rederive as rederive_mod
|
|
10
|
+
from ..errors import LabError
|
|
11
|
+
from ..lab import Lab
|
|
12
|
+
from .base import Reporter
|
|
13
|
+
|
|
14
|
+
SCORE_KEYS = ("protocol", "lock", "review", "scores", "record")
|
|
15
|
+
# The journal kinds that can say why a scored experiment has no report.
|
|
16
|
+
EXPLAINING_KINDS = ("lesson", "kill", "decision", "pivot")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _doc_path(lab: Lab, doc) -> str:
|
|
20
|
+
return lab.doc_path(doc.meta_file.path) if doc.meta_file else doc.key
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def rederive(lab: Lab, rep: Reporter) -> None:
|
|
24
|
+
"""Every live result's re-derive command prints its number exactly."""
|
|
25
|
+
for doc in rederive_mod.live_results(lab):
|
|
26
|
+
outcome = rederive_mod.run(lab, doc)
|
|
27
|
+
if not outcome.ok:
|
|
28
|
+
rep.add(_doc_path(lab, doc), f"{doc.id} does not re-derive: {outcome.detail}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def read_score(lab: Lab, slug: str) -> dict | None:
|
|
32
|
+
path = records.score_path(lab, slug)
|
|
33
|
+
if not path.is_file():
|
|
34
|
+
return None
|
|
35
|
+
data = records.read_yaml(path, lab.rel(path))
|
|
36
|
+
missing = [k for k in SCORE_KEYS if k not in data]
|
|
37
|
+
if missing:
|
|
38
|
+
raise LabError(f"{lab.rel(path)}: missing {', '.join(missing)}")
|
|
39
|
+
return data
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def review_passed(lab: Lab, score: dict) -> str | None:
|
|
43
|
+
"""None when the score records a reviewer pass, or why it does not."""
|
|
44
|
+
review = score.get("review")
|
|
45
|
+
if not isinstance(review, dict):
|
|
46
|
+
return "`review` must hold `verdict` and `record`"
|
|
47
|
+
if review.get("verdict") != "pass":
|
|
48
|
+
return f"`review.verdict` is `{review.get('verdict') or ''}`, not `pass`"
|
|
49
|
+
pointer = str(review.get("record") or "").split("#", 1)[0]
|
|
50
|
+
if not pointer:
|
|
51
|
+
return "`review.record` points nowhere: it names the file where the reviewer's verdict is recorded"
|
|
52
|
+
if not (lab.root / pointer).is_file():
|
|
53
|
+
return f"`review.record` points to {pointer}, which does not exist"
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _inside_finished_run(lab: Lab, slug: str, evidence: str) -> str | None:
|
|
58
|
+
"""None when the evidence lies in the out/ folder of a finished run of this experiment, or why not."""
|
|
59
|
+
parts = PurePosixPath(evidence).parts
|
|
60
|
+
if len(parts) < 6 or parts[:2] != ("experiments", slug) or parts[2] != "runs" or parts[4] != "out":
|
|
61
|
+
return f"its evidence `{evidence}` is not inside experiments/{slug}/runs/<run>/out/"
|
|
62
|
+
for run in records.runs(lab, slug):
|
|
63
|
+
if run.run_id == parts[3]:
|
|
64
|
+
if run.state != "finished":
|
|
65
|
+
return f"its evidence is in run {run.run_id}, which is {run.state}, not finished"
|
|
66
|
+
return None
|
|
67
|
+
return f"its evidence names run {parts[3]}, which has no {records.RUN}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def result_grounded(lab: Lab, rep: Reporter) -> None:
|
|
71
|
+
"""Every live result names a locked protocol, its evidence sits in a finished run, and a reviewer passed it."""
|
|
72
|
+
for doc in rederive_mod.live_results(lab):
|
|
73
|
+
where = _doc_path(lab, doc)
|
|
74
|
+
|
|
75
|
+
def one(doc=doc, where=where) -> None:
|
|
76
|
+
slug = str(doc.meta.get("protocol", ""))
|
|
77
|
+
proto = protocol_mod.find(lab, slug)
|
|
78
|
+
if proto is None or proto.status not in ("locked", "abandoned"):
|
|
79
|
+
rep.add(where, f"{doc.id} names protocol `{slug}`, which is not a locked protocol")
|
|
80
|
+
return
|
|
81
|
+
slug = proto.slug
|
|
82
|
+
if records.read_lock(lab, slug) is None:
|
|
83
|
+
rep.add(where, f"{doc.id} names protocol `{slug}`, which has no {records.LOCK}")
|
|
84
|
+
why = _inside_finished_run(lab, slug, PurePosixPath(str(doc.meta.get("evidence", ""))).as_posix())
|
|
85
|
+
if why:
|
|
86
|
+
rep.add(where, f"{doc.id}: {why}")
|
|
87
|
+
score = read_score(lab, slug)
|
|
88
|
+
if score is None:
|
|
89
|
+
rep.add(where, f"{doc.id}: experiments/{slug}/{records.SCORE} does not exist, so no reviewer "
|
|
90
|
+
"pass is recorded")
|
|
91
|
+
return
|
|
92
|
+
why = review_passed(lab, score)
|
|
93
|
+
if why:
|
|
94
|
+
rep.add(where, f"{doc.id}: experiments/{slug}/{records.SCORE} records no reviewer pass: {why}")
|
|
95
|
+
rep.guard(where, one)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def score_exact(lab: Lab, rep: Reporter) -> None:
|
|
99
|
+
"""A score names the lock's hash and scores exactly the lock's prediction and rule ids."""
|
|
100
|
+
for slug in lab.experiment_slugs():
|
|
101
|
+
path = f"experiments/{slug}/{records.SCORE}"
|
|
102
|
+
|
|
103
|
+
def one(slug=slug, path=path) -> None:
|
|
104
|
+
score = read_score(lab, slug)
|
|
105
|
+
if score is None:
|
|
106
|
+
return
|
|
107
|
+
lock = records.read_lock(lab, slug)
|
|
108
|
+
if lock is None:
|
|
109
|
+
rep.add(path, f"scores experiment `{slug}`, which has no {records.LOCK}")
|
|
110
|
+
return
|
|
111
|
+
if score.get("protocol") != lock.protocol:
|
|
112
|
+
rep.add(path, f"names protocol `{score.get('protocol')}`, not `{lock.protocol}`")
|
|
113
|
+
if score.get("lock") != lock.sha256:
|
|
114
|
+
rep.add(path, "names a lock hash that is not the one in its lock.json")
|
|
115
|
+
proto = protocol_mod.find(lab, lock.protocol)
|
|
116
|
+
if proto is None:
|
|
117
|
+
return
|
|
118
|
+
wanted = protocol_mod.scored_ids(proto.text(), proto.path)
|
|
119
|
+
scores = score.get("scores")
|
|
120
|
+
if not isinstance(scores, dict):
|
|
121
|
+
rep.add(path, "`scores` must map each prediction and rule id to its verdict")
|
|
122
|
+
return
|
|
123
|
+
for ident in wanted:
|
|
124
|
+
if ident not in scores:
|
|
125
|
+
rep.add(path, f"does not score {ident}, which the lock names")
|
|
126
|
+
for ident, entry in scores.items():
|
|
127
|
+
if ident not in wanted:
|
|
128
|
+
rep.add(path, f"scores {ident}, which the lock does not name")
|
|
129
|
+
continue
|
|
130
|
+
allowed = records.PREDICTION_VERDICTS if str(ident).startswith("P") else records.RULE_VERDICTS
|
|
131
|
+
if not isinstance(entry, dict):
|
|
132
|
+
rep.add(path, f"{ident} must hold `verdict`, `value` and `evidence`")
|
|
133
|
+
continue
|
|
134
|
+
if entry.get("verdict") not in allowed:
|
|
135
|
+
rep.add(path, f"{ident} has verdict `{entry.get('verdict') or ''}`; "
|
|
136
|
+
f"allowed: {', '.join(allowed)}")
|
|
137
|
+
evidence = str(entry.get("evidence") or "")
|
|
138
|
+
if not evidence:
|
|
139
|
+
rep.add(path, f"{ident} names no evidence")
|
|
140
|
+
elif not (lab.root / evidence).exists():
|
|
141
|
+
rep.add(path, f"{ident} names evidence {evidence}, which does not exist")
|
|
142
|
+
rep.guard(path, one)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def scored_reported(lab: Lab, rep: Reporter) -> None:
|
|
146
|
+
"""Every scored experiment has a report citing its results, or a journal entry about it saying why not."""
|
|
147
|
+
lib = lab.library()
|
|
148
|
+
for slug in lab.experiment_slugs():
|
|
149
|
+
if not records.score_path(lab, slug).is_file():
|
|
150
|
+
continue
|
|
151
|
+
proto = protocol_mod.find(lab, slug)
|
|
152
|
+
name = proto.slug if proto else slug
|
|
153
|
+
results = [d for d in lib.documents if d.is_a("result")
|
|
154
|
+
and str(d.meta.get("protocol", "")).casefold() == name.casefold()]
|
|
155
|
+
reported = any(
|
|
156
|
+
any(target in results for link in lib.doc_links(doc) for target in link.docs)
|
|
157
|
+
for doc in lib.documents if doc.is_a("report"))
|
|
158
|
+
if reported:
|
|
159
|
+
continue
|
|
160
|
+
explained = False
|
|
161
|
+
for doc in lib.documents:
|
|
162
|
+
if not doc.is_a("journal") or doc.meta.get("kind") not in EXPLAINING_KINDS:
|
|
163
|
+
continue
|
|
164
|
+
about = doc.meta.get("about") or []
|
|
165
|
+
about = about if isinstance(about, list) else [about]
|
|
166
|
+
if any(str(a).casefold() == name.casefold() for a in about):
|
|
167
|
+
explained = True
|
|
168
|
+
break
|
|
169
|
+
if not explained:
|
|
170
|
+
rep.add(f"experiments/{slug}/{records.SCORE}",
|
|
171
|
+
f"experiment `{slug}` is scored, but no report cites its results and no journal entry "
|
|
172
|
+
"about its protocol says why not")
|
lab_kit/checks/run.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""The lab gate: `folio check` on the library, then every lab check, in one report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from folio.checks.problems import Problem
|
|
6
|
+
from folio.checks.run import run as folio_run
|
|
7
|
+
|
|
8
|
+
from ..errors import LabError
|
|
9
|
+
from ..lab import Lab
|
|
10
|
+
from . import files, locks, results
|
|
11
|
+
from .base import LabCheck, Reporter
|
|
12
|
+
|
|
13
|
+
FOLIO = "folio"
|
|
14
|
+
|
|
15
|
+
CHECKS: tuple[LabCheck, ...] = (
|
|
16
|
+
LabCheck("lab-lock-recorded", "error", locks.lock_recorded),
|
|
17
|
+
LabCheck("lab-lock-intact", "error", locks.lock_intact),
|
|
18
|
+
LabCheck("lab-run-after-lock", "error", locks.run_after_lock),
|
|
19
|
+
LabCheck("lab-roster-frozen", "error", locks.roster_frozen),
|
|
20
|
+
LabCheck("lab-evidence-sealed", "error", locks.evidence_sealed),
|
|
21
|
+
LabCheck("lab-rederive", "error", results.rederive),
|
|
22
|
+
LabCheck("lab-result-grounded", "error", results.result_grounded),
|
|
23
|
+
LabCheck("lab-score-exact", "error", results.score_exact),
|
|
24
|
+
LabCheck("lab-ids-resolve", "error", files.ids_resolve),
|
|
25
|
+
LabCheck("lab-frozen-intact", "error", files.frozen_intact),
|
|
26
|
+
LabCheck("lab-selftest", "error", files.selftest),
|
|
27
|
+
LabCheck("lab-mission-approved", "error", files.mission_approved),
|
|
28
|
+
LabCheck("lab-spend-recorded", "error", files.spend_recorded),
|
|
29
|
+
LabCheck("lab-append-only", "error", files.append_only),
|
|
30
|
+
LabCheck("lab-method-current", "error", files.method_current),
|
|
31
|
+
LabCheck("lab-state-pointer", "warning", files.state_pointer),
|
|
32
|
+
LabCheck("lab-scored-reported", "warning", results.scored_reported),
|
|
33
|
+
)
|
|
34
|
+
BY_NAME = {c.name: c for c in CHECKS}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def names() -> list[str]:
|
|
38
|
+
return [FOLIO, *BY_NAME]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def folio_problems(lab: Lab) -> list[Problem]:
|
|
42
|
+
"""folio's own gate on the library, with paths from the lab root."""
|
|
43
|
+
lib = lab.library()
|
|
44
|
+
return [Problem(lab.doc_path(p.path), p.severity, p.check, p.message) for p in folio_run(lib)]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run(lab: Lab, only: list[str] | None = None) -> list[Problem]:
|
|
48
|
+
"""Every problem, folio's first, then the lab checks' in the order of the spec."""
|
|
49
|
+
wanted = only or names()
|
|
50
|
+
unknown = [n for n in wanted if n not in names()]
|
|
51
|
+
if unknown:
|
|
52
|
+
raise LabError(f"no check named {', '.join(unknown)}; the checks are: {', '.join(names())}")
|
|
53
|
+
lab.library() # fail loud, once, when the library does not load
|
|
54
|
+
problems: list[Problem] = []
|
|
55
|
+
if FOLIO in wanted:
|
|
56
|
+
problems.extend(folio_problems(lab))
|
|
57
|
+
for check in CHECKS:
|
|
58
|
+
if check.name not in wanted:
|
|
59
|
+
continue
|
|
60
|
+
rep = Reporter(check.name, check.severity)
|
|
61
|
+
check.fn(lab, rep)
|
|
62
|
+
problems.extend(sorted(set(rep.problems)))
|
|
63
|
+
return problems
|
lab_kit/cli.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""The `lab-kit` command line. Only skills call it, the way an agent calls git."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__, frozen
|
|
11
|
+
from . import lab as lab_mod
|
|
12
|
+
from . import rederive as rederive_mod
|
|
13
|
+
from .checks import run as checks_run
|
|
14
|
+
from .commands import experiments, runs, setup, status
|
|
15
|
+
from .errors import LabError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _lab() -> lab_mod.Lab:
|
|
19
|
+
return lab_mod.load(Path.cwd())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _print(lines: list[str]) -> None:
|
|
23
|
+
for line in lines:
|
|
24
|
+
print(line)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
28
|
+
_print(setup.init(Path.cwd(), args.library))
|
|
29
|
+
return 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
33
|
+
lab = _lab()
|
|
34
|
+
problems = checks_run.run(lab, args.only)
|
|
35
|
+
errors = sum(1 for p in problems if p.severity == "error")
|
|
36
|
+
warnings = sum(1 for p in problems if p.severity == "warning")
|
|
37
|
+
if args.json:
|
|
38
|
+
print(json.dumps({"problems": [p.as_dict() for p in problems], "errors": errors, "warnings": warnings},
|
|
39
|
+
indent=2, ensure_ascii=False))
|
|
40
|
+
else:
|
|
41
|
+
_print([p.line() for p in problems])
|
|
42
|
+
print(f"lab-kit check: {errors} errors, {warnings} warnings")
|
|
43
|
+
return 1 if errors else 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cmd_experiment(args: argparse.Namespace) -> int:
|
|
47
|
+
_print(experiments.experiment(_lab(), args.slug))
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cmd_lock(args: argparse.Namespace) -> int:
|
|
52
|
+
_print(experiments.lock(_lab(), args.slug))
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
57
|
+
command = list(args.command or [])
|
|
58
|
+
changed, code = runs.start(_lab(), args.slug, command, mission=args.mission, spend=args.spend, wait=args.wait)
|
|
59
|
+
_print(changed)
|
|
60
|
+
return 0 if code == 0 else 1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_runs(args: argparse.Namespace) -> int:
|
|
64
|
+
_print(runs.listing(_lab(), live=args.live))
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cmd_score(args: argparse.Namespace) -> int:
|
|
69
|
+
_print(experiments.score(_lab(), args.slug))
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cmd_rederive(args: argparse.Namespace) -> int:
|
|
74
|
+
lab = _lab()
|
|
75
|
+
doc = rederive_mod.result_doc(lab, args.id)
|
|
76
|
+
outcome = rederive_mod.run(lab, doc)
|
|
77
|
+
if outcome.ok:
|
|
78
|
+
print(f"{doc.id}: re-derived `{outcome.printed}`, its number")
|
|
79
|
+
return 0
|
|
80
|
+
print(f"{doc.id}: does not re-derive: {outcome.detail}")
|
|
81
|
+
return 1
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cmd_freeze(args: argparse.Namespace) -> int:
|
|
85
|
+
_print(frozen.freeze(_lab(), args.path))
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
90
|
+
_print(status.status(_lab()))
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cmd_version(args: argparse.Namespace) -> int:
|
|
95
|
+
print(f"lab-kit {__version__}")
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parser() -> argparse.ArgumentParser:
|
|
100
|
+
p = argparse.ArgumentParser(prog="lab-kit", description="A research lab's method and machinery on folio.")
|
|
101
|
+
sub = p.add_subparsers(dest="command_name")
|
|
102
|
+
|
|
103
|
+
s = sub.add_parser("init", help="Write lab.yaml, ops/, experiments/, .lab/, the skills and the roles. "
|
|
104
|
+
"Before a library exists, only the method, the skills and the roles.")
|
|
105
|
+
s.add_argument("--library", help="the folder holding folio.yaml, from the lab root (default: the lab root)")
|
|
106
|
+
s.set_defaults(func=cmd_init)
|
|
107
|
+
|
|
108
|
+
s = sub.add_parser("check", help="The lab gate: folio check, then the lab checks. Changes nothing.")
|
|
109
|
+
s.add_argument("--only", action="append", metavar="ID", help="run only this check (repeatable); "
|
|
110
|
+
"`folio` names folio's gate")
|
|
111
|
+
s.add_argument("--json", action="store_true")
|
|
112
|
+
s.set_defaults(func=cmd_check)
|
|
113
|
+
|
|
114
|
+
s = sub.add_parser("experiment", help="Create experiments/<slug>/ with bin/, runs/ and a gitignore.")
|
|
115
|
+
s.add_argument("slug")
|
|
116
|
+
s.set_defaults(func=cmd_experiment)
|
|
117
|
+
|
|
118
|
+
s = sub.add_parser("lock", help="Write lock.json for a locked protocol whose gate is green.")
|
|
119
|
+
s.add_argument("slug")
|
|
120
|
+
s.set_defaults(func=cmd_lock)
|
|
121
|
+
|
|
122
|
+
s = sub.add_parser("run", usage="lab-kit run <slug> [--mission <file>] [--spend] [--wait] -- <command>",
|
|
123
|
+
help="Check the lock, write run.json, and run the command in the background.")
|
|
124
|
+
s.add_argument("slug")
|
|
125
|
+
s.add_argument("--mission", help="the mission file this run belongs to")
|
|
126
|
+
s.add_argument("--spend", action="store_true", help="the run spends tokens or money")
|
|
127
|
+
s.add_argument("--wait", action="store_true", help="wait for the command to end")
|
|
128
|
+
s.set_defaults(func=cmd_run)
|
|
129
|
+
|
|
130
|
+
s = sub.add_parser("runs", help="List runs and their state: running, finished, failed, orphaned.")
|
|
131
|
+
s.add_argument("--live", action="store_true", help="only running and orphaned runs")
|
|
132
|
+
s.set_defaults(func=cmd_runs)
|
|
133
|
+
|
|
134
|
+
s = sub.add_parser("score", help="Create score.yaml from the lock's prediction and rule ids.")
|
|
135
|
+
s.add_argument("slug")
|
|
136
|
+
s.set_defaults(func=cmd_score)
|
|
137
|
+
|
|
138
|
+
s = sub.add_parser("rederive", help="Run one result's re-derive command against its number.")
|
|
139
|
+
s.add_argument("id")
|
|
140
|
+
s.set_defaults(func=cmd_rederive)
|
|
141
|
+
|
|
142
|
+
s = sub.add_parser("freeze", help="Record a frozen surface's hashes. Only the operator asks for this.")
|
|
143
|
+
s.add_argument("path")
|
|
144
|
+
s.set_defaults(func=cmd_freeze)
|
|
145
|
+
|
|
146
|
+
s = sub.add_parser("status", help="The state, the active mission, live runs, and protocols by status.")
|
|
147
|
+
s.set_defaults(func=cmd_status)
|
|
148
|
+
|
|
149
|
+
s = sub.add_parser("version", help="The lab-kit version.")
|
|
150
|
+
s.set_defaults(func=cmd_version)
|
|
151
|
+
return p
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def main(argv: list[str] | None = None) -> int:
|
|
155
|
+
p = parser()
|
|
156
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
157
|
+
command: list[str] | None = None
|
|
158
|
+
if "--" in argv: # what follows `--` is the lab's own command, passed on untouched
|
|
159
|
+
cut = argv.index("--")
|
|
160
|
+
argv, command = argv[:cut], argv[cut + 1:]
|
|
161
|
+
args = p.parse_args(argv)
|
|
162
|
+
args.command = command
|
|
163
|
+
if args.command_name is None:
|
|
164
|
+
p.print_help()
|
|
165
|
+
return 2
|
|
166
|
+
try:
|
|
167
|
+
return args.func(args)
|
|
168
|
+
except LabError as exc:
|
|
169
|
+
print(f"lab-kit: error: {exc}", file=sys.stderr)
|
|
170
|
+
return 2
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__":
|
|
174
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The `lab-kit` commands that change files, and the ones that report."""
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""`lab-kit experiment`, `lab-kit lock` and `lab-kit score`: an experiment's folder, its lock and its scorecard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from folio.checks.run import run as folio_run
|
|
6
|
+
from folio.util import is_slug
|
|
7
|
+
|
|
8
|
+
from .. import protocol as protocol_mod
|
|
9
|
+
from .. import records
|
|
10
|
+
from ..errors import LabError
|
|
11
|
+
from ..lab import Lab
|
|
12
|
+
|
|
13
|
+
GITIGNORE = "# A run's scratch and log stay out of git; out/ is committed once, at the end.\nruns/*/work/\nruns/*/run.log\n"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def experiment(lab: Lab, slug: str) -> list[str]:
|
|
17
|
+
if not is_slug(slug):
|
|
18
|
+
raise LabError(f"`{slug}` is not a slug: lowercase letters, digits and single hyphens")
|
|
19
|
+
proto = protocol_mod.require(lab, slug)
|
|
20
|
+
folder = lab.experiments / proto.slug
|
|
21
|
+
if folder.exists():
|
|
22
|
+
raise LabError(f"experiments/{proto.slug}/ exists already")
|
|
23
|
+
(folder / "bin").mkdir(parents=True)
|
|
24
|
+
(folder / "runs").mkdir()
|
|
25
|
+
(folder / "bin" / ".gitkeep").touch()
|
|
26
|
+
(folder / "runs" / ".gitkeep").touch()
|
|
27
|
+
(folder / ".gitignore").write_text(GITIGNORE, encoding="utf-8")
|
|
28
|
+
base = f"experiments/{proto.slug}"
|
|
29
|
+
return [f"created {base}/bin/", f"created {base}/runs/", f"created {base}/.gitignore"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def lock(lab: Lab, slug: str) -> list[str]:
|
|
33
|
+
proto = protocol_mod.require(lab, slug)
|
|
34
|
+
if proto.status != "locked":
|
|
35
|
+
raise LabError(f"{proto.path} has status `{proto.status}`; set it to `locked` through folio's write "
|
|
36
|
+
"skill first")
|
|
37
|
+
folder = lab.experiments / proto.slug
|
|
38
|
+
if not folder.is_dir():
|
|
39
|
+
raise LabError(f"experiments/{proto.slug}/ does not exist; run `lab-kit experiment {proto.slug}` first")
|
|
40
|
+
path = records.lock_path(lab, proto.slug)
|
|
41
|
+
if path.exists():
|
|
42
|
+
raise LabError(f"experiments/{proto.slug}/{records.LOCK} exists already: a protocol is locked once")
|
|
43
|
+
main = proto.doc.main
|
|
44
|
+
errors = [p for p in folio_run(lab.library()) if p.severity == "error" and main is not None
|
|
45
|
+
and p.path == main.path]
|
|
46
|
+
if errors:
|
|
47
|
+
raise LabError(f"{proto.path} does not pass the gate:\n" + "\n".join(f" {p.line()}" for p in errors))
|
|
48
|
+
protocol_mod.pinned_config(proto.text(), proto.path)
|
|
49
|
+
protocol_mod.scored_ids(proto.text(), proto.path)
|
|
50
|
+
records.write_json(path, {
|
|
51
|
+
"protocol": proto.slug,
|
|
52
|
+
"path": proto.path,
|
|
53
|
+
"sha256": protocol_mod.hash_text(proto.text()),
|
|
54
|
+
"locked": records.now(),
|
|
55
|
+
})
|
|
56
|
+
return [f"created experiments/{proto.slug}/{records.LOCK}"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def score(lab: Lab, slug: str) -> list[str]:
|
|
61
|
+
lock_record = records.read_lock(lab, slug)
|
|
62
|
+
if lock_record is None:
|
|
63
|
+
raise LabError(f"experiments/{slug}/{records.LOCK} does not exist: only a locked experiment is scored")
|
|
64
|
+
proto = protocol_mod.require(lab, lock_record.protocol)
|
|
65
|
+
path = records.score_path(lab, slug)
|
|
66
|
+
if path.exists():
|
|
67
|
+
raise LabError(f"experiments/{slug}/{records.SCORE} exists already")
|
|
68
|
+
ids = protocol_mod.scored_ids(proto.text(), proto.path)
|
|
69
|
+
lines = [
|
|
70
|
+
f"# The scorecard of experiment {slug}, against its lock.",
|
|
71
|
+
f"# A prediction's verdict: {', '.join(records.PREDICTION_VERDICTS)}.",
|
|
72
|
+
f"# A decision rule's verdict: {', '.join(records.RULE_VERDICTS)}.",
|
|
73
|
+
"# `evidence` is a path from the lab root, inside a finished run.",
|
|
74
|
+
f"protocol: {proto.slug}",
|
|
75
|
+
f"lock: {lock_record.sha256}",
|
|
76
|
+
"review: # the independent reviewer's pass: `pass` or `fail`, and where its verdict is recorded",
|
|
77
|
+
' verdict: ""',
|
|
78
|
+
' record: ""',
|
|
79
|
+
"scores:",
|
|
80
|
+
]
|
|
81
|
+
for ident in ids:
|
|
82
|
+
lines += [f" {ident}:", ' verdict: ""', ' value: ""', ' evidence: ""']
|
|
83
|
+
lines += [
|
|
84
|
+
"# The numbers to record as results, each keyed by a short name, with the result's fields:",
|
|
85
|
+
"# title, protocol, number, baseline, bound, evidence, rederive.",
|
|
86
|
+
"record: {}",
|
|
87
|
+
]
|
|
88
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
89
|
+
return [f"created experiments/{slug}/{records.SCORE} with {len(ids)} ids to score: {', '.join(ids)}"]
|
lab_kit/commands/runs.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""`lab-kit run` and `lab-kit runs`: start a run against the lock, and list the runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as _dt
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from .. import ops
|
|
11
|
+
from .. import protocol as protocol_mod
|
|
12
|
+
from .. import records
|
|
13
|
+
from ..errors import LabError
|
|
14
|
+
from ..lab import Lab
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _run_id(lab: Lab, slug: str) -> str:
|
|
18
|
+
stamp = _dt.datetime.now(_dt.timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
19
|
+
run_id, n = stamp, 1
|
|
20
|
+
while (lab.experiments / slug / "runs" / run_id).exists():
|
|
21
|
+
n += 1
|
|
22
|
+
run_id = f"{stamp}-{n}"
|
|
23
|
+
return run_id
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _check_mission(lab: Lab, mission: str | None, spend: bool) -> str | None:
|
|
27
|
+
if mission is None:
|
|
28
|
+
if spend:
|
|
29
|
+
raise LabError("a run that spends names its mission: --mission ops/missions/<file>.md")
|
|
30
|
+
return None
|
|
31
|
+
target = (lab.root / mission).resolve()
|
|
32
|
+
if not target.is_file():
|
|
33
|
+
raise LabError(f"mission {mission} does not exist")
|
|
34
|
+
rel = lab.rel(target)
|
|
35
|
+
record = ops.read_mission(lab, target)
|
|
36
|
+
problem = record.approval_problem()
|
|
37
|
+
if problem is not None:
|
|
38
|
+
raise LabError(problem)
|
|
39
|
+
if spend and record.cap is None:
|
|
40
|
+
raise LabError(f"{rel} records no numeric `cap:`; no run spends without the operator's approval and a cap")
|
|
41
|
+
return rel
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def start(lab: Lab, slug: str, command: list[str], mission: str | None = None, spend: bool = False,
|
|
45
|
+
wait: bool = False) -> tuple[list[str], int]:
|
|
46
|
+
"""Start a run; with `wait`, block until it ends. Returns what changed and the command's exit code."""
|
|
47
|
+
if not command:
|
|
48
|
+
raise LabError("name the command after `--`: lab-kit run <slug> -- <command>")
|
|
49
|
+
lock = records.read_lock(lab, slug)
|
|
50
|
+
if lock is None:
|
|
51
|
+
raise LabError(f"experiments/{slug}/{records.LOCK} does not exist: no run starts before the lock")
|
|
52
|
+
proto = protocol_mod.require(lab, lock.protocol)
|
|
53
|
+
if proto.status != "locked":
|
|
54
|
+
raise LabError(f"{proto.path} has status `{proto.status}`, not `locked`")
|
|
55
|
+
if protocol_mod.hash_text(proto.text()) != lock.sha256:
|
|
56
|
+
raise LabError(f"{proto.path} changed since its lock; no run starts against a broken lock")
|
|
57
|
+
config = protocol_mod.pinned_config(proto.text(), proto.path)
|
|
58
|
+
mission_rel = _check_mission(lab, mission, spend)
|
|
59
|
+
run_id = _run_id(lab, slug)
|
|
60
|
+
run_dir = lab.experiments / slug / "runs" / run_id
|
|
61
|
+
(run_dir / "out").mkdir(parents=True)
|
|
62
|
+
(run_dir / "work").mkdir()
|
|
63
|
+
records.write_json(run_dir / records.RUN, {
|
|
64
|
+
"protocol": lock.protocol, "lock": lock.sha256, "config": config, "mission": mission_rel,
|
|
65
|
+
"spend": spend, "command": command, "started": records.now(), "ended": None, "exit": None,
|
|
66
|
+
"pid": None, "spent": None,
|
|
67
|
+
})
|
|
68
|
+
base = f"experiments/{slug}/runs/{run_id}"
|
|
69
|
+
changed = [f"created {base}/{records.RUN}", f"created {base}/out/", f"started {' '.join(command)}",
|
|
70
|
+
f"log: {base}/run.log"]
|
|
71
|
+
child = subprocess.Popen([sys.executable, "-m", "lab_kit.supervise", str(run_dir), str(lab.root)],
|
|
72
|
+
cwd=lab.root, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
73
|
+
stderr=subprocess.DEVNULL, start_new_session=True, env=_child_env())
|
|
74
|
+
data = records.read_json(run_dir / records.RUN, f"{base}/{records.RUN}")
|
|
75
|
+
data["pid"] = child.pid
|
|
76
|
+
records.write_json(run_dir / records.RUN, data)
|
|
77
|
+
if not wait:
|
|
78
|
+
return changed + [f"running in the background; `lab-kit runs --live` shows it"], 0
|
|
79
|
+
code = child.wait()
|
|
80
|
+
data = records.read_json(run_dir / records.RUN, f"{base}/{records.RUN}")
|
|
81
|
+
changed.append(f"wrote {base}/{records.MANIFEST}")
|
|
82
|
+
changed.append(f"ended with exit code {data.get('exit')}")
|
|
83
|
+
return changed, code
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _child_env() -> dict[str, str]:
|
|
87
|
+
"""The environment for the supervisor, with this package importable the way it is here."""
|
|
88
|
+
env = dict(os.environ)
|
|
89
|
+
here = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
90
|
+
env["PYTHONPATH"] = here + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
|
|
91
|
+
return env
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def listing(lab: Lab, live: bool = False) -> list[str]:
|
|
95
|
+
rows = []
|
|
96
|
+
for run in records.runs(lab):
|
|
97
|
+
state = run.state
|
|
98
|
+
if live and state not in ("running", "orphaned"):
|
|
99
|
+
continue
|
|
100
|
+
exit_code = run.data.get("exit")
|
|
101
|
+
rows.append(f"{run.slug}/{run.run_id} {state:<9} started {run.data.get('started')}"
|
|
102
|
+
+ (f" exit {exit_code}" if exit_code is not None else ""))
|
|
103
|
+
if not rows:
|
|
104
|
+
return ["No live runs." if live else "No runs."]
|
|
105
|
+
return rows
|