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
65 changes: 52 additions & 13 deletions scripts/check-memory-rotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,13 +326,32 @@ 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")

#: How long the **label** before a cycle id may be, in characters, for the block to still be a
#: per-cycle note.
#:
#: A note does not have to *open* on its id: this installation wrote ``Rotation at `cyc…`: …``
#: for months, and an anchor demanding the id at character 0 read every one of those as ordinary
#: prose (issue #2770). Widening to "a cycle id anywhere in the first line" answers the other way
#: - the file's own contract prose quotes cycle ids mid-sentence as evidence, and faulting that
#: sends the writer to rewrite the paragraph that states the rule.
#:
#: So the label is bounded, and the bound is measured rather than round: the longest label that
#: must count is 12 characters (`Rotation at `) and the shortest that must not is 28
#: (`- the qualifier matters, as `, pinned in `tests/test_check_memory_rotation.py`). 20 sits
#: between two measured shapes instead of at a number that reads tidy.
NARRATION_LABEL_MAX_CHARS = 20

#: 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
#: 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}`")
#: A cycle's note is usually a paragraph, not a heading: it names its cycle in a code span - at
#: the line's start, or a short label in - 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.
#:
#: A **table row is not a note**, and it is not at risk from the wider anchor: `narration_blocks`
#: ends a block at a row rather than reading it, so the cycle id every bookmark row carries in its
#: "read by" column never reaches this pattern.
CYCLE_NARRATION = re.compile(r"^.{0,%d}?`cyc\d{8}-\d{6}`" % NARRATION_LABEL_MAX_CHARS)


def parse_bookmark(path: Path) -> list[dict]:
Expand Down Expand Up @@ -459,9 +478,10 @@ def narration_blocks(path: Path) -> list[str]:
"""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. 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.
names a cycle id in a code span - at the line's start, or after a label no longer than
:data:`NARRATION_LABEL_MAX_CHARS` characters (:data:`CYCLE_NARRATION`), which is the two
shapes a cycle's own 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
Expand Down Expand Up @@ -539,6 +559,24 @@ def structure_faults(headings: list[str]) -> list[str]:
]


def counted_shapes() -> str:
"""What a narration block **is**, in the words both readings use to report their number.

The reading says which shapes it counted, because a number whose extent is unstated cannot be
checked: `1 narration block(s)` was true of a file carrying 27 of them in the shape the anchor
did not recognise (issue #2770), and a reader of that line had no way to know.

One function, called by the clean line and by the fault, and built from
:data:`NARRATION_LABEL_MAX_CHARS` rather than spelling the number again - a bound in prose
beside a bound in code is two values that drift, and the test derives this sentence from the
constant to keep them one.
"""
return (
f"a narration block is a block whose first line names a cycle id in a code span, either "
f"at its start or after a label of at most {NARRATION_LABEL_MAX_CHARS} characters"
)


def narration_faults(blocks: list[str]) -> list[str]:
"""The faults in the bookmark's per-cycle narration: it keeps **one**, replaced in place.

Expand All @@ -559,10 +597,11 @@ def narration_faults(blocks: list[str]) -> list[str]:
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 "
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"
f"({named}): it keeps one and replaces it in place - {counted_shapes()}, so a note "
f"appended in either shape is the same growth the cycle-section reading forbids, in the "
f"shape that reading cannot see (measured 2026-11-05: 43 blocks over 729 lines and "
f"94,482 characters, while the structure reading answered 0), and every cycle that edits "
f"this file reads past them"
]


Expand Down Expand Up @@ -880,7 +919,7 @@ def render(reading: dict, show_all: bool) -> str:
else:
out.append(f"structure: {reading['cycle_sections']} cycle section(s) and "
f"{reading['narration_blocks']} narration block(s) in the file - "
f"the file keeps at most one of each, replaced in place")
f"{counted_shapes()}; the file keeps at most one of each, replaced in place")
return "\n".join(out)


Expand Down
167 changes: 160 additions & 7 deletions tests/test_check_memory_rotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1059,13 +1059,16 @@ def test_a_narration_block_below_the_table_is_read_too(mod, tmp_path):


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.
"""The other direction: the file's own contract prose quotes cycle ids, and is not a 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 **and** bounded: both examples below put the id
well past the characters a short label may occupy, so both stay out. That bound is what a
widening of what counts has to keep, and it is measured at its own edge in
:func:`test_the_label_bound_is_read_in_both_directions`. A note indented under a heading is
still a note - the code span decides, not the heading - and a bookmark whose prose carries no
cycle id at all stays at zero.
"""
root = _root(tmp_path, "prosing")
_write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod))
Expand Down Expand Up @@ -1094,3 +1097,153 @@ def test_contract_prose_and_a_mid_sentence_cycle_id_are_not_narration(mod, tmp_p
nested_reading = mod.read(register, nested)
assert nested_reading["structure"] == [], nested_reading["structure"]
assert nested_reading["narration_blocks"] == 1


def test_a_note_that_names_its_cycle_after_a_short_label_is_narration_too(mod, tmp_path):
"""A note does not have to **open** on its cycle id: a short label before it is still a note.

Measured 2026-11-09 on this installation's own bookmark: the note shape it had written for
months - ``Rotation at `cyc…`: …`` - was appended to a copy of the file and the structure
reading answered `1 narration block(s)` and exited 0, unchanged from the copy without it. So
the file could accumulate the growth the reading exists to catch, one note per cycle, while
the reading reported the one note it recognised. The anchor was the whole cause: it demanded
the id at character 0 of the block's first line, and a note rarely starts with its own name.

The counter was never the problem, and that is what the other half of this test shows: the
label shape and the span shape are two spellings of one subject, so a file carrying one of
each is **one** fault and not two.
"""
root = _root(tmp_path, "labelled")
_write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod))
register = _agents(tmp_path / "agents.yml", [{"name": "labelled", "path": root}])

labelled = tmp_path / "labelled.md"
labelled.write_text(
"# Bookmark\n\n"
"Rotation at `cyc20991231-235959`: a note whose first line opens on prose.\n\n"
"| agent | newest read | files |\n| --- | --- | --- |\n"
f"| `labelled` | {BASE} | 0 |\n", encoding="utf-8")
reading = mod.read(register, labelled)
assert reading["narration_blocks"] == 1, (
"a note that names its cycle a few words in is a per-cycle note, so a reading that "
"misses it lets the bookmark grow one note per cycle while reporting the one note it "
f"recognises - the reading counted {reading['narration_blocks']}")
assert reading["structure"] == [], reading["structure"]
assert mod.main(["--agents", str(register), "--bookmark", str(labelled)]) == 0

plain = tmp_path / "plain.md"
plain.write_text(
"# Bookmark\n\n"
"The rows below are replaced in place; this paragraph states the rule.\n\n"
"| agent | newest read | files |\n| --- | --- | --- |\n"
f"| `labelled` | {BASE} | 0 |\n", encoding="utf-8")
plain_reading = mod.read(register, plain)
assert plain_reading["narration_blocks"] == 0, (
"a paragraph that names no cycle is the bookmark stating its own rule, and a reader "
"that counted it would fault the contract prose the file is supposed to carry")


def test_two_notes_of_either_shape_are_one_fault_naming_what_the_file_keeps(mod, tmp_path):
"""The two spellings of a note count as one subject, and the fault says what it counted.

Widening what is recognised must not widen what is *refused*: a file keeping its one note in
the label shape and gaining a second in the span shape is the growth the reading forbids, in
one fault rather than in two, and it is a fault rather than a refusal - every row still
parses, so the rotation reading survives and only the exit code carries the complaint.

The sentence names the shapes it counted, built from the bound rather than written beside it,
so a reader can tell what the number covers and the prose cannot drift from the predicate.
"""
root = _root(tmp_path, "mixed")
_write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod))
register = _agents(tmp_path / "agents.yml", [{"name": "mixed", "path": root}])

two = tmp_path / "two.md"
two.write_text(
"# Bookmark\n\n"
"Rotation at `cyc20991231-235959`: the note this cycle replaces.\n\n"
"`cyc20260101-120000`: a second note, appended in the span shape.\n\n"
"| agent | newest read | files |\n| --- | --- | --- |\n"
f"| `mixed` | {BASE} | 0 |\n", encoding="utf-8")
reading = mod.read(register, two)
assert reading["faults"] == [], reading["faults"] # every row still parses...
assert reading["narration_blocks"] == 2, (
f"two notes, one of each shape: counted {reading['narration_blocks']}")
assert len(reading["structure"]) == 1, reading["structure"] # ...and the prose was appended to
fault = reading["structure"][0]
assert "2 per-cycle narration blocks" in fault, fault
assert "it keeps one and replaces it in place" in fault, fault
assert str(mod.NARRATION_LABEL_MAX_CHARS) in fault, (
"the fault has to name the shapes it counted, and the label bound is what decides them: "
f"the sentence must be built from the constant, not written beside it - {fault}")
assert "structure faults: 1" in mod.render(reading, False)
assert mod.main(["--agents", str(register), "--bookmark", str(two)]) == 1


def test_the_label_bound_is_read_in_both_directions(mod, tmp_path):
"""The bound is the discriminator, so it is measured at its own edge and not only far from it.

A label of at most the bound is a note; one character more is the file's contract prose
quoting a cycle id as evidence, which must stay out. Asserting only the far side would leave
the boundary unpinned, and a later widening would pass every arm this file carries.
"""
root = _root(tmp_path, "bounded")
_write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod))
register = _agents(tmp_path / "agents.yml", [{"name": "bounded", "path": root}])
limit = mod.NARRATION_LABEL_MAX_CHARS

def counts(label_chars: int) -> int:
path = tmp_path / f"label-{label_chars}.md"
path.write_text(
"# Bookmark\n\n"
f"{'x' * label_chars}`cyc20991231-235959`: a note on a label of {label_chars}.\n\n"
"| agent | newest read | files |\n| --- | --- | --- |\n"
f"| `bounded` | {BASE} | 0 |\n", encoding="utf-8")
return mod.read(register, path)["narration_blocks"]

assert counts(limit) == 1, f"a label of exactly {limit} characters is still a note"
assert counts(limit + 1) == 0, (
f"a label of {limit + 1} characters is prose carrying a cycle id, not a note: the bound "
f"is {limit} and a reader that passed this row would count the contract prose too")


def test_a_moved_note_belongs_to_the_file_it_was_moved_to(mod, tmp_path):
"""The repair the bookmark actually received: its notes were moved to a neighbour file.

Two readings, because one direction is not the question. The bookmark's own count must not
change when an archive file appears beside it - a reader that walked the directory would
report the archive's notes as the bookmark's narration, which is the growth error in the
other direction - and the archive, read as the subject, reports **its own** notes. So the
moved notes stay measurable where they went, which is what makes moving them a repair rather
than a way to hide them.
"""
folder = tmp_path / "moved"
folder.mkdir()
root = _root(tmp_path, "moving")
_write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod))
register = _agents(tmp_path / "agents.yml", [{"name": "moving", "path": root}])

bookmark = folder / "bookmark.md"
bookmark.write_text(
"# Bookmark\n\n"
"`cyc20260901-120000`: the one note this file keeps.\n\n"
"| agent | newest read | files |\n| --- | --- | --- |\n"
f"| `moving` | {BASE} | 0 |\n", encoding="utf-8")
assert mod.read(register, bookmark)["narration_blocks"] == 1

archive = folder / "narration-archive.md"
archive.write_text(
"# Archive\n\n"
+ "\n\n".join(f"Rotation at `cyc2026080{i}-120000`: moved note {i}." for i in range(1, 4))
+ "\n", encoding="utf-8")
after = mod.read(register, bookmark)
assert after["narration_blocks"] == 1, (
"the archive sits beside the bookmark, not in it: a reading that counted the neighbour's "
f"notes would report {after['narration_blocks']} blocks for a file that kept one")
assert after["structure"] == [], after["structure"]

# The archive is asked the narration question and not the bookmark question: an archive is a
# file of notes, so it carries no table, and `read()` refuses it as unmeasurable by design.
assert len(mod.narration_blocks(archive)) == 3, (
"the moved notes are still notes, of the archive file - if they read as zero there, "
"moving them out of the bookmark lost them instead of storing them")
Loading