From b231fee3341d342acd2b9d6ae19673c1e8e200dd Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Mon, 2 Nov 2026 14:07:36 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20cortex.py=20library=20=E2=80=94=20ledge?= =?UTF-8?q?r=20entry=20with=20the=20results-index=20hash=20(results-librar?= =?UTF-8?q?y=205b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New entry kind `library` (decision 65, decide-and-flag): facts read from a PyAutoFit results index only — sha256 equal to autofit.export.index_hash and the state counts; written only by the verb, never `log --kind`; never a result/lesson or ## Now. REFERENCE, AGENTS and tests (14 new). Refs #84 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 7 +- REFERENCE.md | 41 ++++++- docs/schema_decisions.md | 23 ++++ scripts/cortex.py | 149 ++++++++++++++++++++++++- tests/test_cortex.py | 2 +- tests/test_library.py | 230 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 442 insertions(+), 10 deletions(-) create mode 100644 tests/test_library.py diff --git a/AGENTS.md b/AGENTS.md index e6f5523..207efde 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ not re-derive them. ledger file"). Title `# — `; header `Project:` and `Issue:` (`Repo#N`, an issue URL, or `none`); then exactly three sections: `## Now`, `## Runs` (`open | running`), `## Log` (newest first, kind - `run | result | lesson | note`). Every `status: active` row must have one. + `run | result | lesson | note | library`). Every `status: active` row must have one. `projects/AGENTS.md` is the doctrine for the folder. - **`archive/`** — frozen: the 2026-08/09 task, ruling and batch ledgers, kept because the science repos cite their ids. Nothing writes there; any @@ -107,6 +107,11 @@ not re-derive them. runs whose tasks started become `running`, runs whose tasks all ended go through `done` with the task counts. Matched by SLURM job id; never writes a `result`, `lesson` or `## Now` (decision 64). + - `library [--name NAME] [--dry-run]` — one `library` + entry naming the PyAutoFit results index (`autofit.results`) a library was + built from: its hash (equal to `autofit.export.index_hash`) and its state + counts, read from the index files. Facts only; never a `result`, `lesson` + or `## Now`; a re-run appends another line (decision 65). - `log "" [--kind note|result|lesson]` — the human's words, dated today, at the head of the log. - `now ""` — rewrites `## Now`. diff --git a/REFERENCE.md b/REFERENCE.md index 000c2b2..b3844c7 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -29,7 +29,7 @@ project. ## Log -- — — +- — — an optional continuation line, indented two spaces ``` @@ -52,7 +52,7 @@ A job id appears once per file. A retired project lists no runs. ### Log lines (`## Log`) Newest first — `check` refuses a date that is later than the entry above it. -Four kinds: +Five kinds: | Kind | Written by | What it is | |---|---|---| @@ -60,6 +60,7 @@ Four kinds: | `result` | `cortex.py log --kind result` | what the human read off the results, in their words | | `lesson` | `cortex.py log --kind lesson` | something the human wants kept — a trap, a rule, a fact about the setup | | `note` | `cortex.py log` (default), `new`, `retire` | anything else: a thought, a plan, `ledger opened`, `retired: ` | +| `library` | `cortex.py library` only | `: index — results ( , …)` — the results index a library was built from (decision 65) | The board and the issue block show the newest `LOG_WINDOW = 5` entries; the file keeps them all. An entry is never edited or removed: a wrong entry is @@ -96,6 +97,7 @@ creates an issue; `Issue:` is set with `new --issue` or `link`. | `issue [-n N]` | nothing (prints) | — | | `link ` | the `Issue:` header line | the ref is not `Repo#N`, an issue URL or `none` | | `retire --why "…"` | `projects.yaml` (`status: retired`, `note:`), one `note` entry | the row is already retired; the ledger still lists a run; `--why` holds `"` | +| `library [--name NAME] [--dry-run]` | one `library` entry at the head of the log | the folder or its `index.json` is missing; `index.json` is not schema 1; the key is not a row; a `--today` earlier than the head entry | | `sync-runs [--digest PATH] [--dry-run]` | each run line the digest settles: `open` → `running` (+ a `run` entry), or `done` with the task counts | the digest is not a schema-1 `digest.json` with a readable `tasks.jsonl` beside it; no `--digest` and no `digest:` in the row | Every verb takes `--root ` (default: this checkout) and every dated verb @@ -166,6 +168,41 @@ from the scheduler's accounting. What the runs *mean* is the human's: a `result` or `lesson` entry and `## Now` are only ever written on their ask, and `sync-runs` writes neither (schema decision 64). +## The results index a library was built from (`library`) + +A results library — a catalogue, a data release, a paper's fits — is built +from a PyAutoFit results index (`autofit.results.ResultsIndex`): a folder +holding `index.json` (schema 1) and `records/.json`, one `ResultRecord` +per result. `library ` reads those files and prepends one +entry naming exactly which results the library was built from: + +``` +- 2026-09-03 — library — dr1 v1.0: index 6369f0eaac33 — 5 results (completed 2, failed 1, superseded 1, never_started 1) +``` + +- **The hash is `autofit.export.index_hash`**, computed from the JSON with no + autofit import: sha256 over the records sorted by `output_path`, each + contributing `||\n` (a missing completion + time is `None`). The entry shows its first 12 hex digits; the verb prints + the full hash. As in `ResultsIndex.load`, a record that does not read, lacks + `output_path`/`root`/`state`, or is not `schema` 1 is skipped. +- **The counts are the records' states**, non-zero only, in `ResultState` + order (`completed`, `running`, `interrupted_valid_ckpt`, `corrupt_ckpt`, + `corrupt_zip`, `failed`, `superseded`, `never_started`); a state outside it + follows, alphabetically. The `counts` in `index.json` are not read: the + records are the truth, and the hash is taken over them. +- `--name` is what the entry calls the library; by default the index folder's + name, or its parent's when it is autofit's default `.autofit_index`. +- An index whose `records/` is empty or absent is 0 results. A missing folder, + a missing or non-schema-1 `index.json`, or a key with no row refuses, exit 1, + nothing written. +- **One entry per call.** A re-run appends another dated line; no entry is + ever edited. `--dry-run` prints the entry and writes nothing. + +**The boundary** is decision 60's: the entry is facts read from the index +files, never inferred. `library` writes no `result` or `lesson`, never touches +`## Now` or `## Runs` (schema decision 65). + ## Repository layout ``` diff --git a/docs/schema_decisions.md b/docs/schema_decisions.md index 663f2b0..44fd8a9 100644 --- a/docs/schema_decisions.md +++ b/docs/schema_decisions.md @@ -522,3 +522,26 @@ Choices made in phase 2 — the conductor, the board and the workflows hand-read `squeue`; the digest is one small file the collector already writes. Decision 60 still holds: nothing in a ledger is inferred from results, and a run's start and end are cluster facts. + +65. **A `library` entry records which results a library was built from** + (2026-11-02, PyAutoCortex#84; results-library phase 5b — decide-and-flag, + awaiting the human's ruling at merge). The log gains a fifth kind, + `library`, written only by `cortex.py library + [--name NAME]` (`log --kind` cannot write it): `: index — + results ( , …)`, read from a PyAutoFit results index + (`autofit.results`: `index.json` schema 1 and `records/*.json`). The hash + is `autofit.export.index_hash`, recomputed from the JSON without + importing autofit: sha256 over the records sorted by `output_path`, each + contributing `||\n`, skipping a record + that does not read, lacks a required field or is not `schema` 1, as + `ResultsIndex.load` does. The counts are the records' non-zero states in + `ResultState` order. Facts read from the index files only, never inferred + (decision 60): it never writes a `result` or a `lesson` and never touches + `## Now` or `## Runs`. One entry per invocation — a re-run appends another + dated line rather than editing one, as every entry is append-only. + Why: a release (a catalogue, a paper's fits) has to say which results it + was built from, and the export package already names them by this hash; + a dated ledger line with the same 12 digits ties the science history to + the package without anyone retyping a number. Like a run's start and end + (decision 64), which results an index holds is a fact a script may read; + what they mean stays the human's. diff --git a/scripts/cortex.py b/scripts/cortex.py index fb51b58..e762061 100644 --- a/scripts/cortex.py +++ b/scripts/cortex.py @@ -34,6 +34,7 @@ python3 scripts/cortex.py link # set `Issue:` python3 scripts/cortex.py retire --why "" # a project's row python3 scripts/cortex.py sync-runs --digest [--dry-run] + python3 scripts/cortex.py library [--name NAME] [--dry-run] Exit codes: 0 = done · 1 = drift or a refused edit · 2 = bad arguments. """ @@ -41,6 +42,7 @@ from __future__ import annotations import argparse +import hashlib import json import re import sys @@ -58,8 +60,11 @@ SECTIONS = ("Now", "Runs", "Log") HEADER_KEYS = ("Project", "Issue") #: what an entry is: a run set off or finished (recorded by this script), a -#: result the human read off, a lesson they want kept, or anything else. -KINDS = ("run", "result", "lesson", "note") +#: result the human read off, a lesson they want kept, anything else, or the +#: results index a library was built from (`library`, decision 65). +KINDS = ("run", "result", "lesson", "note", "library") +#: the kinds `log --kind` may write; `library` is written only by `library`. +LOG_KINDS = ("run", "result", "lesson", "note") #: a run on the cluster is `open` (submitted, not seen running) or `running`; #: there is no third state — a finished run is a log entry, not a run line. RUN_STATES = ("open", "running") @@ -97,7 +102,7 @@ r"^- (?P\d+(?:_\d+|_\[[\d,\-]+\])?) — (?Popen|running) — " r"(?P[a-z][a-z0-9_-]*) — (?P\d{4}-\d{2}-\d{2}) — (?P\S.*)$") LOG_LINE_RE = re.compile( - r"^- (?P\d{4}-\d{2}-\d{2}) — (?Prun|result|lesson|note) — (?P\S.*)$") + r"^- (?P\d{4}-\d{2}-\d{2}) — (?P" + "|".join(KINDS) + r") — (?P\S.*)$") CONT_RE = re.compile(r"^ (?P\S.*)$") HEADER_KEY_RE = re.compile(r"^([A-Z][A-Za-z0-9-]*):(?:[ \t]+(.*?))?[ \t]*$") HEADER_LINES = 12 @@ -579,8 +584,8 @@ def _prepend_entry(text: str, entry_line: str) -> str: def add_entry(root: Path, key: str, kind: str, text: str, today: date) -> str: - if kind not in KINDS: - raise CortexError(f"--kind must be one of {', '.join(KINDS)}") + if kind not in LOG_KINDS: + raise CortexError(f"--kind must be one of {', '.join(LOG_KINDS)}") text = _one_line(text, "the entry") led = _ledger_at(root, key) raw = led.path.read_text(encoding="utf-8") @@ -1153,6 +1158,122 @@ def sync_runs(root: Path, key: str, digest_path: Path, today: date, *, return [head] + changes + notes +# --------------------------------------------------------------------------- # +# the results index a library was built from (`library`, decision 65) +# --------------------------------------------------------------------------- # +#: the schema of a PyAutoFit results index (`autofit.results.record.SCHEMA`). +INDEX_SCHEMA = 1 +#: the fields `ResultRecord.from_dict` cannot do without. +INDEX_RECORD_FIELDS = ("output_path", "root", "state") +#: `autofit.ResultState`, in its own order — the order the counts are shown in; +#: a state outside it follows, alphabetically. +RESULT_STATES = ("completed", "running", "interrupted_valid_ckpt", "corrupt_ckpt", + "corrupt_zip", "failed", "superseded", "never_started") +#: the folder name `ResultsIndex` defaults to, so a bare default name says nothing. +DEFAULT_INDEX_DIRNAME = ".autofit_index" + + +def read_index_records(index_dir: Path) -> "list[dict]": + """The records of a PyAutoFit results index, as `ResultsIndex.records()` + returns them: `records/*.json`, skipping a file that does not read, lacks a + required field or is not `schema` 1, one per `output_path`, sorted by it. + A CortexError when the folder or its `index.json` is missing or unreadable; + an empty or absent `records/` is zero records.""" + index_dir = Path(index_dir) + if not index_dir.is_dir(): + raise CortexError(f"{index_dir}: no such results index folder") + manifest_path = index_dir / "index.json" + if not manifest_path.is_file(): + raise CortexError(f"{index_dir}: no index.json — not a results index " + f"(`python -m autofit.results index` writes one)") + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as e: + raise CortexError(f"{manifest_path}: not readable JSON ({e})") + if not isinstance(manifest, dict) or manifest.get("schema") != INDEX_SCHEMA: + raise CortexError(f"{manifest_path}: not a schema-{INDEX_SCHEMA} results index") + by_path: "dict[str, dict]" = {} + records_dir = index_dir / "records" + if records_dir.is_dir(): + for p in sorted(records_dir.glob("*.json")): + try: + rec = json.loads(p.read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + if not isinstance(rec, dict) or any(f not in rec for f in INDEX_RECORD_FIELDS): + continue + if rec.get("schema", INDEX_SCHEMA) != INDEX_SCHEMA: + continue + by_path[rec["output_path"]] = rec + return [by_path[k] for k in sorted(by_path)] + + +def index_hash(records: "list[dict]") -> str: + """`autofit.export.index_hash`, from the JSON: sha256 over each record, in + output-path order, of `||\\n` (a missing + completion time is `None`, as Python formats it).""" + digest = hashlib.sha256() + for rec in records: + digest.update(f"{rec['output_path']}|{rec['state']}|{rec.get('completed_at')}\n" + .encode()) + return digest.hexdigest() + + +def _state_counts(records: "list[dict]") -> str: + from collections import Counter + c: "Counter[str]" = Counter(str(rec["state"]) for rec in records) + order = [s for s in RESULT_STATES if c[s]] + sorted(s for s in c if s not in RESULT_STATES) + return ", ".join(f"{s} {c[s]}" for s in order) + + +def _index_name(index_dir: Path) -> str: + """The default `--name`: the index folder's name, or its parent's when it is + the default `.autofit_index`.""" + d = Path(index_dir).resolve() + return d.parent.name if d.name == DEFAULT_INDEX_DIRNAME else d.name + + +def library_entry(index_dir: Path, name: str) -> "tuple[str, str]": + """The entry text for a results index and its full hash: + `: index — results ( , …)`.""" + records = read_index_records(index_dir) + digest = index_hash(records) + n = len(records) + text = f"{name}: index {digest[:12]} — {n} result{'' if n == 1 else 's'}" + if records: + text += f" ({_state_counts(records)})" + return text, digest + + +def record_library(root: Path, key: str, index_dir: Path, today: date, *, + name: "str | None" = None, dry_run: bool = False) -> "list[str]": + """Append one `library` entry naming the results index a library was built + from — facts read from the index files only (decision 65). One entry per + call: a re-run appends another dated line, it never edits one. Made in + memory, read back, written once; never `result`, `lesson` or `## Now`. + Returns the lines to print, the first `library: : `.""" + rows, _ = load_projects(root) + if PROJECT_KEY_RE.match(key or "") and key not in rows: + raise CortexError(f"{key} is not a projects.yaml key") + name = _one_line(name if name is not None else _index_name(index_dir), "--name") + text, digest = library_entry(index_dir, name) + led = _ledger_at(root, key) + stamp = today.isoformat() + if not dry_run: + if led.log and stamp < led.log[0].date: + raise CortexError(f"the log's head is {led.log[0].date}; an entry dated {stamp} " + f"would break newest-first — nothing written") + new = _prepend_entry(led.path.read_text(encoding="utf-8"), + f"- {stamp} — library — {text}") + after = parse_ledger(new, led.key, led.path, led.rel) + if after.problems: + raise CortexError("the edit would not read back — nothing written: " + + "; ".join(after.problems)) + led.path.write_text(new, encoding="utf-8") + head = f"library: {key}: {text}" + (" (dry run)" if dry_run else "") + return [head, f" index sha256 {digest}"] + + # --------------------------------------------------------------------------- # # CLI # --------------------------------------------------------------------------- # @@ -1212,6 +1333,13 @@ def cmd_sync_runs(args) -> int: return 0 +def cmd_library(args) -> int: + for line in record_library(args.root, args.project, Path(args.index_dir), _today(args), + name=args.name, dry_run=args.dry_run): + print(line) + return 0 + + def cmd_retire(args) -> int: print(retire_project(args.root, args.project, args.why, _today(args))) return 0 @@ -1266,7 +1394,7 @@ def build_parser() -> argparse.ArgumentParser: p = sub.add_parser("log", help="write a dated entry in the human's words") p.add_argument("project") p.add_argument("text") - p.add_argument("--kind", default="note", choices=KINDS) + p.add_argument("--kind", default="note", choices=LOG_KINDS) _common(p, dated=True) p.set_defaults(func=cmd_log) @@ -1317,6 +1445,15 @@ def build_parser() -> argparse.ArgumentParser: _common(p, dated=True) p.set_defaults(func=cmd_sync_runs) + p = sub.add_parser("library", help="log the results index a library was built from " + "(its hash and state counts)") + p.add_argument("project") + p.add_argument("index_dir", help="a PyAutoFit results index folder (holds index.json)") + p.add_argument("--name", help="what the entry calls it (default: the index folder's name)") + p.add_argument("--dry-run", action="store_true", help="print the entry; write nothing") + _common(p, dated=True) + p.set_defaults(func=cmd_library) + p = sub.add_parser("retire", help="retire a project's row (with `birth`, the only verbs " "that write projects.yaml)") p.add_argument("project") diff --git a/tests/test_cortex.py b/tests/test_cortex.py index e27fa02..afb4bef 100644 --- a/tests/test_cortex.py +++ b/tests/test_cortex.py @@ -419,7 +419,7 @@ def test_retire_preserves_every_other_byte_of_projects_yaml(tmp_path): def test_every_verb_is_registered(): parser = cortex.build_parser() verbs = set(parser._subparsers._group_actions[0].choices) - assert verbs == {"check", "new", "run", "running", "done", "log", "now", "issue", "link", "retire", "birth", "sync-runs"} + assert verbs == {"check", "new", "run", "running", "done", "log", "now", "issue", "link", "retire", "birth", "sync-runs", "library"} def test_a_bad_root_is_a_usage_error(tmp_path): diff --git a/tests/test_library.py b/tests/test_library.py new file mode 100644 index 0000000..24ddfe6 --- /dev/null +++ b/tests/test_library.py @@ -0,0 +1,230 @@ +"""Contract tests for `cortex.py library` (schema decision 65): one fact-only +`library` entry naming the PyAutoFit results index a library was built from — +its hash and its state counts, read from the index files, nothing else. + +The index fixtures are written per test in the shape `autofit.results.ResultsIndex` +writes them: `index.json` (schema 1) and `records/.json`, one +`ResultRecord` dict per result. Where autofit is importable, the hash is also +checked against `autofit.export.index_hash` on an index autofit built itself. +""" + +import hashlib +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +import cortex # noqa: E402 + +REPO = Path(__file__).resolve().parents[1] +SCRIPT = REPO / "scripts" / "cortex.py" +SKELETON = REPO / "tests" / "fixtures" / "skeleton" +TODAY = "2026-09-03" +EX = "projects/example.md" + + +def _copy(tmp_path: Path) -> Path: + root = tmp_path / "tree" + shutil.copytree(SKELETON, root) + return root + + +def _record(output_path, state, completed_at=None, **extra): + rec = {"output_path": output_path, "root": "/results", "state": state, + "completed_at": completed_at, "schema": 1} + rec.update(extra) + return rec + + +def _index(tmp_path: Path, records, name="dr1_index", manifest_schema=1) -> Path: + d = tmp_path / name + (d / "records").mkdir(parents=True) + (d / "index.json").write_text(json.dumps( + {"schema": manifest_schema, "roots": ["/results"], "updated": "2026-09-03T08:00:00Z", + "latest_key": ["path_prefix", "name", "unique_tag"], "counts": {}})) + for rec in records: + key = hashlib.sha1(rec["output_path"].encode("utf-8")).hexdigest() + (d / "records" / f"{key}.json").write_text(json.dumps(rec, indent=1)) + return d + + +RECORDS = [ + _record("/results/lens_c/abc", "failed"), + _record("/results/lens_a/abc", "completed", "2026-09-01T10:00:00+00:00"), + _record("/results/lens_b/abc", "superseded", "2026-08-30T10:00:00+00:00"), + _record("/results/lens_b/def", "completed", "2026-09-02T10:00:00+00:00"), + _record("/results/lens_d/abc", "never_started"), +] + + +def _expected_hash(records) -> str: + h = hashlib.sha256() + for rec in sorted(records, key=lambda r: r["output_path"]): + h.update(f"{rec['output_path']}|{rec['state']}|{rec['completed_at']}\n".encode()) + return h.hexdigest() + + +def _run(*args, root): + return subprocess.run([sys.executable, str(SCRIPT), *args, "--root", str(root)], + capture_output=True, text=True) + + +def _ledger(root): + return next(l for l in cortex.load_ledgers(root)[0] if l.key == "example") + + +def test_appends_one_library_entry_with_hash_and_counts(tmp_path): + root = _copy(tmp_path) + idx = _index(tmp_path, RECORDS) + r = _run("library", "example", str(idx), "--name", "dr1 v1.0", "--today", TODAY, + root=root) + assert r.returncode == 0, r.stderr + digest = _expected_hash(RECORDS) + line = (f"- {TODAY} — library — dr1 v1.0: index {digest[:12]} — 5 results " + f"(completed 2, failed 1, superseded 1, never_started 1)") + assert (root / EX).read_text().split("## Log\n\n")[1].splitlines()[0] == line + assert r.stdout.splitlines()[0] == f"library: example: {line.split(' — library — ')[1]}" + assert r.stdout.splitlines()[1] == f" index sha256 {digest}" + assert _ledger(root).log[0].kind == "library" + assert cortex.check_problems(root) == [] + + +def test_default_name_is_the_index_folder_or_its_parent(tmp_path): + root = _copy(tmp_path) + idx = _index(tmp_path, RECORDS[:1], name="euclid_dr1_index") + cortex.record_library(root, "example", idx, cortex.date.fromisoformat(TODAY)) + assert _ledger(root).log[0].text().startswith("euclid_dr1_index: index ") + nested = _index(tmp_path / "euclid_dr1", RECORDS[:1], name=".autofit_index") + cortex.record_library(root, "example", nested, cortex.date.fromisoformat(TODAY)) + assert _ledger(root).log[0].text().startswith("euclid_dr1: index ") + + +def test_rerun_appends_another_line_never_edits(tmp_path): + root = _copy(tmp_path) + idx = _index(tmp_path, RECORDS) + cortex.record_library(root, "example", idx, cortex.date.fromisoformat(TODAY)) + cortex.record_library(root, "example", idx, cortex.date.fromisoformat("2026-09-04")) + log = _ledger(root).log + assert [e.kind for e in log[:2]] == ["library", "library"] + assert [e.date for e in log[:2]] == ["2026-09-04", TODAY] + assert log[0].text() == log[1].text() + assert cortex.check_problems(root) == [] + + +def test_skips_unreadable_and_other_schema_records_as_autofit_does(tmp_path): + idx = _index(tmp_path, RECORDS[:2] + [_record("/results/lens_z/abc", "completed", + "2026-09-01", schema=2)]) + (idx / "records" / "broken.json").write_text("{ not json") + (idx / "records" / "partial.json").write_text(json.dumps({"output_path": "/x"})) + (idx / "records" / "notes.txt").write_text("not a record") + text, digest = cortex.library_entry(idx, "n") + assert digest == _expected_hash(RECORDS[:2]) + assert text.endswith("— 2 results (completed 1, failed 1)") + + +def test_empty_records_dir_is_zero_results(tmp_path): + root = _copy(tmp_path) + idx = _index(tmp_path, []) + r = _run("library", "example", str(idx), "--today", TODAY, root=root) + assert r.returncode == 0, r.stderr + head = _ledger(root).log[0] + assert head.text() == f"dr1_index: index {hashlib.sha256().hexdigest()[:12]} — 0 results" + assert cortex.check_problems(root) == [] + + +def test_dry_run_changes_nothing(tmp_path): + root = _copy(tmp_path) + before = (root / EX).read_text() + r = _run("library", "example", str(_index(tmp_path, RECORDS)), "--dry-run", + "--today", TODAY, root=root) + assert r.returncode == 0, r.stderr + assert r.stdout.splitlines()[0].startswith("library: example: dr1_index: index ") + assert r.stdout.splitlines()[0].endswith(" (dry run)") + assert (root / EX).read_text() == before + + +@pytest.mark.parametrize("make, message", [ + (lambda tmp: tmp / "nowhere", "no such results index folder"), + (lambda tmp: (tmp / "bare").mkdir() or tmp / "bare", "no index.json"), + (lambda tmp: _index(tmp, RECORDS, manifest_schema=2), "not a schema-1 results index"), +]) +def test_refuses_a_missing_or_foreign_index(tmp_path, make, message): + root = _copy(tmp_path) + before = (root / EX).read_text() + r = _run("library", "example", str(make(tmp_path)), "--today", TODAY, root=root) + assert r.returncode == 1 + assert message in r.stderr + assert (root / EX).read_text() == before + + +def test_refuses_an_unknown_project(tmp_path): + root = _copy(tmp_path) + r = _run("library", "nosuch", str(_index(tmp_path, RECORDS)), "--today", TODAY, + root=root) + assert r.returncode == 1 + assert "nosuch is not a projects.yaml key" in r.stderr + + +def test_refuses_a_date_before_the_log_head(tmp_path): + root = _copy(tmp_path) + before = (root / EX).read_text() + r = _run("library", "example", str(_index(tmp_path, RECORDS)), "--today", "2026-01-01", + root=root) + assert r.returncode == 1 + assert "newest-first" in r.stderr + assert (root / EX).read_text() == before + + +def test_never_writes_result_lesson_runs_or_now(tmp_path): + root = _copy(tmp_path) + before = _ledger(root) + cortex.record_library(root, "example", _index(tmp_path, RECORDS), + cortex.date.fromisoformat(TODAY)) + after = _ledger(root) + assert after.now == before.now + assert [r.line() for r in after.runs] == [r.line() for r in before.runs] + assert [(e.date, e.kind, e.text()) for e in after.log[1:]] == \ + [(e.date, e.kind, e.text()) for e in before.log] + + +def test_log_verb_cannot_write_a_library_entry(tmp_path): + root = _copy(tmp_path) + r = _run("log", "example", "hand-typed", "--kind", "library", "--today", TODAY, root=root) + assert r.returncode == 2 + + +def test_hash_matches_autofit_index_hash(tmp_path): + """The Cortex hash equals `autofit.export.index_hash`: on an index autofit + built from synthetic results, and on the hand-written fixture loaded by + autofit.""" + pytest.importorskip("autofit") + from autofit.export import index_hash + from autofit.results import ResultsIndex + + out = tmp_path / "output" + for i, kind in enumerate(["completed", "completed", "failed", "never_started"]): + d = out / "prefix" / f"fit_{i}" / "abc" + (d / "files").mkdir(parents=True) + (d / "files" / "search.json").write_text("{}") + if kind == "completed": + (d / ".completed").write_text("") + if kind == "failed": + Path(f"{d}.status.json").write_text(json.dumps( + {"state": "failed", "updated": "2026-09-03T08:00:00+00:00"})) + built = ResultsIndex(out, index_dir=tmp_path / "built_index") + built.update() + text, digest = cortex.library_entry(built.index_dir, "built") + assert digest == index_hash(built) + counts = built.counts() # every ResultState, in autofit's order, zeros included + assert tuple(counts) == cortex.RESULT_STATES + shown = ", ".join(f"{s} {n}" for s, n in counts.items() if n) + assert text.endswith(f"— 4 results ({shown})") + + hand = _index(tmp_path, RECORDS) + _, digest = cortex.library_entry(hand, "hand") + assert digest == index_hash(ResultsIndex(tmp_path, index_dir=hand)) == _expected_hash(RECORDS)