diff --git a/scripts/check-memory-rotation.py b/scripts/check-memory-rotation.py index 7a1170a2..a2642023 100644 --- a/scripts/check-memory-rotation.py +++ b/scripts/check-memory-rotation.py @@ -48,6 +48,15 @@ `.emrg/sessions/*/memory/**/*.md` - the two places the rotation's rule names - and keeps the newest file strictly newer than that project's stamp. +**The rotation's third key has a carrier here too.** §1.5 orders the rotation by changed +projects first and **least recently visited** after that, and the second key can only be +ordered from a bookmark that records which cycle read which row. This tool therefore reads +a row's cycle id wherever the row puts it and prints it beside the project, plus one line +saying how many of the table's rows record one - so a bookmark that records none says so, +instead of leaving every project looking equally recent (issue #2525). The field is read, +never required: a row written before the column existed is not unreadable, it is a row that +does not answer this question yet. + **The bookmark is not memory content.** On this host it lives under the evolution project's own memory root, so counting it would make that project's newest file the clock itself: the row could never be current, and every run would report the project changed on @@ -147,6 +156,14 @@ def now() -> datetime: #: visited. KEY_CELL = re.compile(r"^`(?P[^`]+)`\s*(?:\((?P[^)]*)\))?$") +#: A row's **reading cycle**: the cycle id §1.5 asks a row to carry beside the stamp it read, +#: so that "least recently visited" - the rotation's third key - can be ordered from the +#: bookmark instead of from recollection. Found, never required: this installation's 32 rows +#: carried none when the field was added (issue #2525), and a reader that refused them would +#: have called every live project unreadable. A row without one is reported as not recording +#: it, which is a fact about that row rather than a fault in the reading. +READ_BY_CELL = re.compile(r"\bcyc\d{8}-\d{6}\b") + #: What a row's **value cell** carries: a stamp, or one of the two notes. A cell may append #: a parenthetical - this host writes `path missing (gone from disk)`, another writes the #: stamp followed by the memory file it was read from - so the note is read from the cell @@ -257,6 +274,22 @@ def _value_of(cells: list[str]) -> str | None: return None +def _read_by_of(cells: list[str]) -> str | None: + """The cycle id a row carries, from whichever cell holds it, or `None`. + + Searched rather than counted, for the reason `_value_of` gives: the column's index + belongs to the installation writing the bookmark, and this host's table puts the cycle + nowhere at all. A cell is only read as a cycle when it *is* one - `cyc` and a stamp of + the right width - so a date, a path or a word that resembles an id is left alone + (issue #2525). + """ + for cell in cells: + found = READ_BY_CELL.search(cell) + if found: + return found.group(0) + return None + + #: The heading that opens a bookmark's per-cycle narration section. #: #: The bookmark keeps **exactly one** such section, replaced in place each cycle. That is @@ -324,7 +357,8 @@ def parse_bookmark(path: Path) -> list[dict]: continue key = key_cell.group("key") hint = key_cell.group("hint") - rows.append({"key": f"{key} ({hint})" if hint else key, "value": value}) + rows.append({"key": f"{key} ({hint})" if hint else key, "value": value, + "read_by": _read_by_of(cells[1:])}) if not saw_table: sections = [line.strip() for line in lines if CYCLE_SECTION.match(line)] if sections: @@ -567,7 +601,8 @@ def project_state(project: dict, row: dict | None, bookmark_path: Path | None = """ name = project["name"] raw_path = project.get("path") or "" - state = {"name": name, "path": raw_path, "key": row["key"] if row else None} + state = {"name": name, "path": raw_path, "key": row["key"] if row else None, + "read_by": row.get("read_by") if row else None} if row is None: state.update(state="no bookmark row", bookmark=None, newest=None) return state @@ -653,6 +688,7 @@ def read(projects_path: Path, bookmark_path: Path) -> dict: "bookmark_file": str(bookmark_path), "project_count": len(projects), "row_count": len(rows), + "rows_with_cycle": sum(1 for row in rows if row.get("read_by")), "states": states, "faults": faults, "cycle_sections": len(sections), @@ -660,6 +696,15 @@ def read(projects_path: Path, bookmark_path: Path) -> dict: } +def _cycle_of(state: dict) -> str: + """The cycle that read this project's row, or a phrase saying the row recorded none. + + A phrase rather than a blank, because the two are different readings: a project read by + `cyc…` and one whose row predates the column must not print alike. + """ + return state.get("read_by") or "no cycle recorded" + + def render(reading: dict, show_all: bool) -> str: """The report, with both subjects named before any verdict.""" out = [ @@ -674,7 +719,7 @@ def render(reading: dict, show_all: bool) -> str: out.append(f"changed since their bookmark: {len(changed)}") for state in changed: out.append(f" {state['name']} {state['bookmark']} -> {state['newest']['mtime']} " - f"{state['newest']['path']}") + f"{state['newest']['path']} read by {_cycle_of(state)}") else: out.append("changed since their bookmark: 0 - every project is at or before its stamp") if show_all: @@ -682,9 +727,18 @@ def render(reading: dict, show_all: bool) -> str: if others: out.append("every other project:") for state in others: - out.append(f" {state['state']:<16} {state['name']} {state['path']}") + out.append(f" {state['state']:<16} {state['name']} {state['path']} " + f"read by {_cycle_of(state)}") summary = ", ".join(f"{name} {count}" for name, count in sorted(counts.items())) out.append(f"states: {summary}" if summary else "states: none recorded") + recorded = reading["rows_with_cycle"] + out.append( + f"reading cycles: {recorded} of {reading['row_count']} row(s) record the cycle that read " + f"them" + + ("" if recorded else + ' - none does, so "least recently visited" cannot be ordered from this bookmark ' + 'until the rows that are read carry one (§1.5)') + ) if reading["faults"]: out.append(f"coverage faults: {len(reading['faults'])}") out.extend(f" {fault}" for fault in reading["faults"]) diff --git a/tests/test_check_memory_rotation.py b/tests/test_check_memory_rotation.py index 7c74a6fd..ea135d73 100644 --- a/tests/test_check_memory_rotation.py +++ b/tests/test_check_memory_rotation.py @@ -21,6 +21,11 @@ stamp that does not parse, and a row that matches two projects all answer exit **2** or exit **1** with the reason named, and `0` is reserved for a reading that covered every project and every row; +* **the rotation's third key has a carrier or it is not a key** - a row's reading cycle is + found wherever the table puts it and printed beside its project, and a row that records + none is reported as recording none rather than left looking recently read: §1.5 orders the + rotation by "least recently visited", which no bookmark without that field can answer + (issue #2525); * **a note from an earlier day is re-measured, not repeated** - `no memory root` and `path missing` are readings, so a row carrying one the disk now contradicts is a fault rather than a verdict that hides a project for ever. @@ -728,6 +733,71 @@ def test_a_bookmark_truncated_at_its_section_is_diagnosed_as_truncation(mod, tmp assert "another shape" in str(excinfo.value) +# ------------------------------------------------------ the third key: who read this row + + +def _bookmark_with_read_by(path: Path, rows: list[tuple[str, str, str]]) -> Path: + """A bookmark in the four-cell shape another installation writes. + + Kept apart from `_bookmark` on purpose: that fixture is this host's three-cell table and + every other test in this file is written against it, so a second shape needs its own + writer rather than a changed one. + """ + lines = ["# Bookmark", "", + "| project | newest read | files | read by |", + "| --- | --- | --- | --- |"] + lines += [f"| {key} | {value} | 0 | {cycle} |" for key, value, cycle in rows] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + +def test_a_row_that_records_its_reading_cycle_is_reported_with_it(mod, tmp_path): + """A bookmark that records the cycle and a report that drops it is the same as neither. + + §1.5 orders the rotation by "least recently visited" once the changed projects are + taken, so the field is the only carrier that key has (issue #2525). + """ + root = _root(tmp_path, "cycled") + _write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod)) + register = _projects(tmp_path / "projects.yml", [{"name": "cycled", "path": root}]) + book = _bookmark_with_read_by(tmp_path / "book.md", [("`cycled`", BASE, "cyc20261020-141450")]) + reading = mod.read(register, book) + assert [s["read_by"] for s in reading["states"]] == ["cyc20261020-141450"] + assert reading["rows_with_cycle"] == 1 + report = mod.render(reading, True) + assert "read by cyc20261020-141450" in report, report + assert "reading cycles: 1 of 1 row(s) record the cycle that read them" in report, report + + +def test_a_row_without_a_cycle_says_so_instead_of_looking_recent(mod, tmp_path): + """This host's shape: three cells, no cycle. The absence is a reading, not a fault.""" + root = _root(tmp_path, "uncycled") + _write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod)) + reading = _reading(mod, tmp_path, [{"name": "uncycled", "path": root}], + [("`uncycled`", BASE)]) + assert reading["rows_with_cycle"] == 0 + report = mod.render(reading, True) + assert "read by no cycle recorded" in report, report + assert "reading cycles: 0 of 1 row(s) record the cycle that read them" in report, report + assert "cannot be ordered from this bookmark" in report, report + # The row answers every question it was asked, so it is not a coverage fault: a fault + # here would send a cycle to rewrite a row that is already correct for its own shape. + assert reading["faults"] == [] + + +def test_a_cell_that_only_resembles_a_cycle_is_not_read_as_one(mod, tmp_path): + """Shape decides, as `_value_of` already decides for the stamp: `cyc` and a stamp of the + right width, or nothing. A near miss must not become an ordering key.""" + root = _root(tmp_path, "almost") + _write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod)) + register = _projects(tmp_path / "projects.yml", [{"name": "almost", "path": root}]) + for near_miss in ("cyc2026102-14145", "cyc20261020-1414", "cyc20261020-1414500", + "cycle 20261020-141450", "2026-10-20 14:14:50"): + book = _bookmark_with_read_by(tmp_path / "book.md", [("`almost`", BASE, near_miss)]) + reading = mod.read(register, book) + assert [s["read_by"] for s in reading["states"]] == [None], near_miss + assert reading["rows_with_cycle"] == 0, near_miss + assert "read by no cycle recorded" in mod.render(reading, True), near_miss # ------------------------------------------------- the register's name, mid-move