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
90 changes: 86 additions & 4 deletions scripts/check-memory-rotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,14 @@ 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.
#:
#: 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
#: 2026-11-05 the gap was 43 blocks over 729 lines and 94,482 characters while the structure
#: reading answered `0 cycle section(s)` and exited 0.
CYCLE_NARRATION = re.compile(r"^`cyc\d{8}-\d{6}`")


def parse_bookmark(path: Path) -> list[dict]:
"""Every body row of the bookmark's table, in file order, as key/value pairs.
Expand Down Expand Up @@ -447,6 +455,49 @@ def cycle_sections(path: Path) -> list[str]:
return [line.strip() for line in text.splitlines() if CYCLE_SECTION.match(line)]


def narration_blocks(path: Path) -> list[str]:
"""The per-cycle narration above the bookmark's table, one entry per block.

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.

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
than opening a section. Measured 2026-11-05, this file carried **43** such blocks over
729 lines and 94,482 characters while the structure reading answered `0 cycle
section(s)` and exited 0 - growth the guard was written to catch, in the shape it could
not see.

An absent or unreadable bookmark answers `[]` for the reason :func:`cycle_sections`
gives: `parse_bookmark` owns that complaint, and a second reading that raised would
replace a precise sentence with a vaguer one.

:param path: the bookmark to read.
:returns: the first line of each narration block, stripped, in file order.
"""
if not path.is_file():
return []
try:
text = path.read_text(encoding="utf-8")
except (OSError, ValueError):
return []
blocks: list[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 line.strip():
current.append(line)
elif current:
blocks.append(current)
current = []
if current:
blocks.append(current)
return [block[0].strip() for block in blocks if CYCLE_NARRATION.match(block[0].strip())]


def structure_faults(headings: list[str]) -> list[str]:
"""The faults in the bookmark's structure **above** its table, not in its rows.

Expand Down Expand Up @@ -478,6 +529,33 @@ 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.

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
it is the shape the section reading cannot see.

:param blocks: the block first lines, from :func:`narration_blocks`.
:returns: one sentence per fault, empty when the file keeps one note and no more.
"""
if len(blocks) < 2:
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"({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 "
f"structure reading answered 0), and every cycle that edits this file reads past them"
]


def _segments(path: str) -> list[str]:
"""A path's segments, host and platform separators alike, empty ones dropped."""
return [s for s in re.split(r"[\\/]+", path) if s]
Expand Down Expand Up @@ -698,7 +776,8 @@ def read(agents_path: Path, bookmark_path: Path) -> dict:
for p in agents]
faults = []
sections = cycle_sections(bookmark_path)
structure = structure_faults(sections)
notes = narration_blocks(bookmark_path)
structure = structure_faults(sections) + narration_faults(notes)
for state in states:
if state["state"] == "no bookmark row":
faults.append(f"{state['name']} ({state['path']}) has a line in the register and none in "
Expand Down Expand Up @@ -726,6 +805,7 @@ def read(agents_path: Path, bookmark_path: Path) -> dict:
"states": states,
"faults": faults,
"cycle_sections": len(sections),
"narration_blocks": len(notes),
"structure": structure,
}

Expand Down Expand Up @@ -785,10 +865,12 @@ def render(reading: dict, show_all: bool) -> str:
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 "
"cycle section and replace it in place, never append a second")
"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) above the table - "
f"the file keeps at most one, replaced in place")
out.append(f"structure: {reading['cycle_sections']} cycle section(s) and "
f"{reading['narration_blocks']} narration block(s) above the table - "
f"the file keeps at most one of each, replaced in place")
return "\n".join(out)


Expand Down
79 changes: 79 additions & 0 deletions tests/test_check_memory_rotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -966,3 +966,82 @@ def test_the_bookmark_default_names_the_file_this_installation_writes(mod):
"renamed constant points at nothing and step 0 answers `not measurable` every cycle"
)



def _noted(path: Path, rows: list[tuple[str, str]], count: int) -> Path:
"""A bookmark carrying `count` per-cycle notes above its table, one cycle id each."""
notes = "".join(f"`cyc2026102{i}-120000` read two projects at index level: the note.\n\n"
for i in range(count))
lines = ["# Bookmark", "", notes.rstrip("\n"), "",
"| agent | newest read | files |", "| --- | --- | --- |"]
lines += [f"| {key} | {value} | 0 |" for key, value in rows]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return path


def test_a_second_narration_block_is_a_fault_the_section_reading_cannot_show(mod, tmp_path):
"""Measured 2026-11-05 on this host's own bookmark: 43 of them, structure reading 0.

A cycle's note carries no heading - each cycle replaces the paragraph before it - so
`cycle_sections()` was blind to all 43 while the file reached 729 lines and 94,482
characters. One subject, two shapes: this asserts the fault **beside** an empty `faults`
list, because every row still parses and a fix for one reading must not be read as a fix
for the other. Exit 1 is the file-shape complaint, never 2 - the rows were measured.
"""
root = _root(tmp_path, "narrated")
_write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod))
register = _agents(tmp_path / "agents.yml", [{"name": "narrated", "path": root}])
one = _noted(tmp_path / "one.md", [("`narrated`", BASE)], 1)
three = _noted(tmp_path / "three.md", [("`narrated`", BASE)], 3)

single = mod.read(register, one)
assert single["structure"] == [], single["structure"]
assert single["narration_blocks"] == 1
assert "1 narration block(s)" in mod.render(single, False)
assert mod.main(["--agents", str(register), "--bookmark", str(one)]) == 0

many = mod.read(register, three)
assert many["faults"] == [], many["faults"] # every row still parses...
assert many["narration_blocks"] == 3
assert len(many["structure"]) == 1, many["structure"] # ...and the prose was appended to
assert "3 per-cycle narration blocks" in many["structure"][0]
assert "structure faults: 1" in mod.render(many, False)
assert mod.main(["--agents", str(register), "--bookmark", str(three)]) == 1


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.

The file keeps rules of its own above the table, and those rules quote cycle ids as
evidence mid-sentence. Reading them as narration would fault the prose that states the
contract, so the match is anchored to the block's first line. A note indented under a
heading is still a note - the code span decides, not the heading - and a bookmark whose
prose carries none of them stays at zero.
"""
root = _root(tmp_path, "prosing")
_write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod))
register = _agents(tmp_path / "agents.yml", [{"name": "prosing", "path": root}])

contract = tmp_path / "contract.md"
contract.write_text(
"# Bookmark\n\n"
"**Two agents can carry one name.** Measured `cyc20261103-183416`: the register\n"
"lists it twice.\n\n"
"## Notes\n\n"
"- the qualifier matters, as `cyc20261020-120000` found\n\n"
"| agent | newest read | files |\n| --- | --- | --- |\n"
f"| `prosing` | {BASE} | 0 |\n", encoding="utf-8")
reading = mod.read(register, contract)
assert reading["structure"] == [], reading["structure"]
assert reading["narration_blocks"] == 0
assert mod.main(["--agents", str(register), "--bookmark", str(contract)]) == 0

nested = tmp_path / "nested.md"
nested.write_text(
"# Bookmark\n\n## Notes\n\n"
"`cyc20261021-120000` a note under a heading is still a per-cycle note.\n\n"
"| agent | newest read | files |\n| --- | --- | --- |\n"
f"| `prosing` | {BASE} | 0 |\n", encoding="utf-8")
nested_reading = mod.read(register, nested)
assert nested_reading["structure"] == [], nested_reading["structure"]
assert nested_reading["narration_blocks"] == 1
Loading