diff --git a/scripts/check-memory-rotation.py b/scripts/check-memory-rotation.py index 86bc7ef9..e91717bc 100644 --- a/scripts/check-memory-rotation.py +++ b/scripts/check-memory-rotation.py @@ -326,7 +326,7 @@ def _read_by_of(cells: list[str]) -> str | None: #: answers "32 of 32" while the file it is embedded from grows every cycle. CYCLE_SECTION = re.compile(r"^##\s+Cycle\b") -#: The opening of a **per-cycle narration block** above the bookmark's table. +#: The opening of a **per-cycle narration block**, wherever it sits in the bookmark. #: #: A cycle's note is usually a paragraph, not a heading: it opens with its own id in a code #: span and the next cycle replaces it. `CYCLE_SECTION` cannot see that shape, and measured @@ -456,12 +456,19 @@ def cycle_sections(path: Path) -> list[str]: def narration_blocks(path: Path) -> list[str]: - """The per-cycle narration above the bookmark's table, one entry per block. + """The bookmark's per-cycle narration, one entry per block, above the table or below it. A block is a run of non-blank lines, and it counts as narration when its **first** line opens with a cycle id in a code span (:data:`CYCLE_NARRATION`) - the shape a cycle's own - note has taken. The reading stops at the table's first row, because below it the file is - rows and not prose. + note has taken. A table row ends a *block*; it does not end the reading, because the + bookmark is not obliged to keep its prose above its table. + + That boundary was once a `break`, on the assumption that below the table the file is + rows and not prose. Measured 2026-11-07 on this host's own bookmark, the assumption + failed and took the reading with it: **29** runs holding **39,411** characters of + per-cycle notes had accumulated below the table, and the structure reading answered + `0 cycle section(s) and 1 narration block(s) above the table` while exiting 0. Growth + that the file's own rule forbids had simply moved past the line the reader stopped at. This exists because :func:`cycle_sections` reads only `## Cycle` headings, and the ordinary note carries none: a cycle replaces the paragraph that came before it rather @@ -487,7 +494,10 @@ def narration_blocks(path: Path) -> list[str]: current: list[str] = [] for line in text.splitlines(): if _is_table_line(line): - break # the table is what the file exists for + if current: # a row ends a block; it does not end the reading + blocks.append(current) + current = [] + continue if line.strip(): current.append(line) elif current: @@ -499,7 +509,7 @@ def narration_blocks(path: Path) -> list[str]: def structure_faults(headings: list[str]) -> list[str]: - """The faults in the bookmark's structure **above** its table, not in its rows. + """The faults in the bookmark's structure, as against the faults in its rows. A separate list from the coverage faults because they are a different subject with a different remedy: a coverage fault is repaired by writing a row, this one by writing a @@ -532,10 +542,10 @@ def structure_faults(headings: list[str]) -> list[str]: def narration_faults(blocks: list[str]) -> list[str]: """The faults in the bookmark's per-cycle narration: it keeps **one**, replaced in place. - The same subject as :func:`structure_faults` - growth above the table - given the same - treatment and for the same reason: a fault, never a refusal, so every row is still - measured and the exit code carries the file-shape complaint without throwing away a - valid rotation reading. + The same subject as :func:`structure_faults` - growth of either shape, wherever in the + file it sits - given the same treatment and for the same reason: a fault, never a refusal, + so every row is still measured and the exit code carries the file-shape complaint without + throwing away a valid rotation reading. One block is the contract's shape (the current cycle's note) and none is a minimal or first-ever bookmark. Only a second block is the growth the file's own rule forbids, and @@ -548,7 +558,7 @@ def narration_faults(blocks: list[str]) -> list[str]: return [] named = ", ".join(blocks[:5]) + (f", and {len(blocks) - 5} more" if len(blocks) > 5 else "") return [ - f"the bookmark carries {len(blocks)} per-cycle narration blocks above its table " + f"the bookmark carries {len(blocks)} per-cycle narration blocks " f"({named}): it keeps one and replaces it in place - an appended note is the same " f"growth the cycle-section reading forbids, in the shape that reading cannot see " f"(measured 2026-11-05: 43 blocks over 729 lines and 94,482 characters, while the " @@ -864,12 +874,12 @@ def render(reading: dict, show_all: bool) -> str: if reading["structure"]: out.append(f"structure faults: {len(reading['structure'])}") out.extend(f" {fault}" for fault in reading["structure"]) - out.append("the rows above are still readable - this is the prose above them: keep one " + out.append("the rows above are still readable - this is the prose around them: keep one " "cycle section and one narration block, and replace them in place, never " "append a second") else: out.append(f"structure: {reading['cycle_sections']} cycle section(s) and " - f"{reading['narration_blocks']} narration block(s) above the table - " + f"{reading['narration_blocks']} narration block(s) in the file - " f"the file keeps at most one of each, replaced in place") return "\n".join(out) diff --git a/tests/test_check_memory_rotation.py b/tests/test_check_memory_rotation.py index dd26a9a7..18948673 100644 --- a/tests/test_check_memory_rotation.py +++ b/tests/test_check_memory_rotation.py @@ -1009,6 +1009,55 @@ def test_a_second_narration_block_is_a_fault_the_section_reading_cannot_show(mod assert mod.main(["--agents", str(register), "--bookmark", str(three)]) == 1 +def test_a_narration_block_below_the_table_is_read_too(mod, tmp_path): + """Measured 2026-11-07 on this host's own bookmark: 29 runs, 39,411 characters below it. + + The reading used to stop at the table's first row, on the assumption that below it the + file is rows and not prose. The assumption failed the moment a cycle appended its note + after the table instead of before it: the file reached 358 lines carrying 39,411 + characters of per-cycle notes while the structure reading answered `0 cycle section(s) + and 1 narration block(s) above the table` and exited 0. Growth the file's own rule + forbids had moved past the line the reader stopped at, so the boundary is a block + boundary and not the end of the reading. + + Both directions are asserted, because a reader that counts any prose after the table + would fault the paragraph a bookmark uses to state its own rule. + """ + root = _root(tmp_path, "belowtable") + _write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod)) + register = _agents(tmp_path / "agents.yml", [{"name": "belowtable", "path": root}]) + + appended = tmp_path / "appended.md" + appended.write_text( + "# Bookmark\n\n" + "`cyc20261102-120000` the note a cycle replaces.\n\n" + "| agent | newest read | files |\n| --- | --- | --- |\n" + f"| `belowtable` | {BASE} | 0 |\n" + "\n`cyc20261103-120000` a second note, appended after the table.\n", encoding="utf-8") + reading = mod.read(register, appended) + assert reading["faults"] == [], reading["faults"] + assert reading["narration_blocks"] == 2, ( + "a note below the table is not counted, so growth appended after the table is " + "invisible to the reading that exists to catch it") + assert len(reading["structure"]) == 1, reading["structure"] + assert mod.main(["--agents", str(register), "--bookmark", str(appended)]) == 1 + + rule = tmp_path / "rule.md" + rule.write_text( + "# Bookmark\n\n" + "`cyc20261102-120000` the note a cycle replaces.\n\n" + "| agent | newest read | files |\n| --- | --- | --- |\n" + f"| `belowtable` | {BASE} | 0 |\n" + "\nThe rows above are replaced in place; this paragraph states the rule.\n", + encoding="utf-8") + kept = mod.read(register, rule) + assert kept["narration_blocks"] == 1, ( + "prose after the table that no cycle id opens is the bookmark stating its own rule, " + "not a cycle's note") + assert kept["structure"] == [], kept["structure"] + assert mod.main(["--agents", str(register), "--bookmark", str(rule)]) == 0 + + def test_contract_prose_and_a_mid_sentence_cycle_id_are_not_narration(mod, tmp_path): """The other directions: only a block that **opens** on the code span is a cycle's note.