Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 58 additions & 4 deletions scripts/check-memory-rotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -147,6 +156,14 @@ def now() -> datetime:
#: visited.
KEY_CELL = re.compile(r"^`(?P<key>[^`]+)`\s*(?:\((?P<hint>[^)]*)\))?$")

#: 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -653,13 +688,23 @@ 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),
"structure": structure,
}


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 = [
Expand All @@ -674,17 +719,26 @@ 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:
others = [s for s in reading["states"] if s["state"] != "changed"]
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"])
Expand Down
70 changes: 70 additions & 0 deletions tests/test_check_memory_rotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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


Expand Down
Loading