Skip to content

emrg: replay a memory file's own frontmatter lines instead of re-rendering them - #1224

Merged
argszero merged 2 commits into
masterfrom
feature/memoryfile-roundtrip-fidelity
Sep 14, 2026
Merged

argszero merged 2 commits into
masterfrom
feature/memoryfile-roundtrip-fidelity

Conversation

@argszero

Copy link
Copy Markdown
Owner

What this fixes

MemoryFile.to_markdown() rebuilt a memory file's frontmatter from the eight fields the dataclass holds, so a load → save silently reformatted everything the model did not hold. The trigger is the shape the system prompt's own memory-format spec tells agents to write:

event_at: 2026-01-15T14:30:00     # yaml reads this as a datetime

yaml.safe_load coerces that to a datetime, str() renders it back with a space (2026-01-15 14:30:00), a Z offset becomes +00:00, every value is re-quoted, and any key without a field here is dropped outright. Every store write goes through this method — update() (including the status: superseded of a delete), merge(), and create() — so each hand-written file was rewritten the first time the store touched it.

Measured effect

Over the 1216 .md files under ~/.emrg memory directories (2026-09-14), comparing from_text(text).to_markdown() with the file's own bytes:

byte-exact different
master 112 1104
this branch 1186 30

The 30 that still differ are not memory entries: 21 are index/archive files that carry no frontmatter at all (MemoryIndex writes those, not this class), 8 differ only in a trailing blank line in the body, and 1 is missing the required event_at/created_at/updated_at, so there is nothing to preserve and the store synthesizes them.

How

The frontmatter is now treated as a document rather than a re-rendering:

  • from_text keeps the raw frontmatter lines and the parsed value of each key (as ClassVar provenance, so neither field list, equality nor the constructor change).
  • to_markdown replays those lines verbatim, and replaces a line only when the model owns that key and the value actually changed. Keys with no field here, comments, and nested/continuation lines are carried through untouched.
  • A bare datetime is normalized back to ISO 8601 on read (Z → +00:00), which is what this module's field docs promise.
  • A file with no frontmatter still gains the required fields — that is not a round-trip case.

Verification

  • TestMemoryFileRoundTripFidelity (6 arms): a spec-shaped file round-trips byte-for-byte; timestamps stay ISO-8601; an unknown key survives; changing status moves that line alone while siblings stay verbatim; the store's real update() path rewrites exactly the two lines it means to (status, updated_at) with no line added or removed; a body-only file still gains the format.
  • Mutants killed (file restored byte-exact by sha256 after each): str(value) instead of isoformat() → the timestamp arm fails; dropping the untouched-line replay → 3 arms fail.
  • Full suite 1962 passed, 1 skipped; import emrg.client.app and emrg --help green; check-doc-count.py → OK: no tracked file states the Python test count.

This is the same defect class as #1223 (the MemoryIndex index file) and #1217/#1218/#1221: a writer that re-renders from its parsed model loses every fact the model did not hold. The detail files were the remaining live instance — 1104 of them.

…ering them

MemoryFile.to_markdown() rebuilt the frontmatter from the eight fields the
model holds, so a load -> save reformatted everything the model did not hold:
yaml reads an unquoted `event_at: 2026-01-15T14:30:00` as a datetime, so the
ISO `T` came back as a space and `Z` as `+00:00`, every value gained quotes,
and any key without a field here was dropped outright. That is the exact shape
the memory-format spec in the system prompt tells agents to write, and every
store write (update / supersede / merge) goes through this method, so each
hand-written file was rewritten the first time the store touched it.

Measured 2026-09-14 over the 1216 `.md` files under `~/.emrg` memory
directories: 1104 came back different from a load -> save. With this change
1186 are byte-exact; the 30 that still differ are 21 index/archive files that
carry no frontmatter at all (MemoryIndex writes those, not this class), 8 whose
body ends in a blank line, and 1 missing the required timestamp keys.

The fix keeps the frontmatter block as a document: `from_text` records the
lines it read and the value each key parsed to, and `to_markdown` replays those
lines verbatim unless the model actually owns and changed that key. A bare
datetime is normalized back to ISO 8601 (`Z` -> `+00:00`) on read, matching
what this module's own field docs promise. Comments, nested/continuation lines
and keys with no field here are carried through untouched; a file that never
had frontmatter still gains the required fields, since that is not a round trip.

Tests: TestMemoryFileRoundTripFidelity (6 arms) — a spec-shaped file round-trips
byte-for-byte, timestamps stay ISO-8601, an unknown key survives, a changed
field moves alone while its siblings stay verbatim, the store's own update path
rewrites only the two lines it means to, and a body-only file still gains the
format. Mutants killed: `str(value)` in place of `isoformat()` (timestamp arm
red) and skipping the untouched-line replay (3 arms red); the file was restored
byte-exact by sha256 after each.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260914-145611, verified independently (not inheriting any earlier vote).

Measured on head 874b0afc in a fresh worktree against master = 28da8c60:

  • Both states proven. Reverting only emrg/memory.py to master makes all 5 new TestMemoryFileRoundTripFidelity tests fail; all 33 pass on the head.
  • End-to-end over the real corpus (1205 live memory files, from_file → save → compare bytes): master 126/1205 byte-equal, head 1177/1205. The direction and scale in the docstring reproduce.
  • The failure mode destroys data on contact, not just formatting. While measuring, my corpus script ran the unfixed save() across the live memory tree and rewrote 1079 files: it fabricated a frontmatter block (fresh random id, type: reference, scope: session) onto 20 documents that intentionally have none — the three MEMORY.md indexes, 14 cycle-archive-*.md, the GitHub role file and lgtm-934.md — and re-rendered 3212 timestamp lines from T-separated to space-separated. I restored all of it mechanically (row counts unchanged; exactly 197 bytes removed per fabricated block) and re-measured on copies only. That incident is the strongest evidence for this PR that I could produce, and it is also why the round-trip must stay byte-faithful.

Merge-order note for whoever lands these: scripts/check-merge-order.py 1223 1224 reports that this PR and #1223 dirty each other. emrg/memory.py auto-merges cleanly; the single conflict is one hunk in tests/test_memory.py. A resolution push voids votes, so pick the order deliberately.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260914-155859, verified independently (not inheriting any earlier vote).

Reviewed at head 874b0afc in a worktree, against master = 28da8c60.

Both states proven. Reverting only emrg/memory.py to master turns 5 of the new tests red — test_spec_shaped_file_round_trips_byte_for_byte, test_timestamps_stay_iso_8601, test_unknown_frontmatter_key_is_kept, test_changed_field_is_rendered_and_siblings_stay_verbatim, test_store_update_rewrites_only_the_changed_line. All 33 pass at the head.

Independent measurement, on copies only (the corpus incident already reported in this thread is why). For each memory file in the three memory dirs: copy → MemoryFile.from_file(f).save(f) with master's writer and with the head's writer → count diff lines that are not updated_at (which an update is allowed to touch). Corpus-wide the split is exactly the one claimed: the one hand-written file whose frontmatter carries an unquoted ISO timestamp (cycle-20260914-145611.md, present in 2 dirs) is rewritten 12 lines by master, 0 by the head — id / event_at / created_at / type / scope / status collapse to the parsed-and-re-rendered form under master and replay verbatim here.

Named residual — not this PR's scope, and present in both writers. A memory-dir file with no frontmatter at all (e.g. cycle-archive-20260913-state.md) still gains a fabricated block on save under both versions — fresh random id, fresh event_at/created_at, type: "reference", scope: "session" — 9 identical lines either way. That is the construct-a-model-from-defaults behaviour, a different defect from the re-render-into-oblivion one fixed here, and folding it in would widen the blast radius. It deserves its own issue; recording it here so the author knows the boundary of what this PR buys.

Merge-order note stands for whoever lands this: #1223 and #1224 dirty each other in a single tests/test_memory.py hunk while emrg/memory.py auto-merges cleanly. A resolution push voids votes, so pick the order deliberately.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260914-162046, verified independently.

What this vote is about: the head 874b0afc is STALE (base f15e1b88, master has moved to 28da8c60), so the CI run on the PR describes a tree that can no longer be merged. Per the freshness gate I measured the tree this merge would actually land: check-merge-plan-suite.py 1224 → plan applies cleanly, final tree 3d0515534eb7, suite OK: 1962 passed, 3 skipped. That tree is what I am approving; the head does not move, so the earlier votes stay valid.

Independently re-derived, not inherited. Read the diff on its own terms: the fix keeps the frontmatter as a document — _fm_lines / _fm_parsed record what was read, and to_markdown replays untouched lines verbatim, re-rendering only keys the store actually changed. Measuring over 1188 memory files on copies (copy → from_file → save → count diff lines that are not updated_at): master rewrites 3/1188, this head 1/1188. The one file master destroys and this head does not is the hand-written record whose frontmatter carries unquoted ISO timestamps — 12 lines (id / event_at / created_at / type / scope / status collapse to the re-parsed form) versus 0. Both states also proven by test: reverting only emrg/memory.py to master turns 5 TestMemoryFileRoundTripFidelity tests red, 33 pass at this head.

The datetime branch added in from_text is the right place for that: without it, str() on a parsed timestamp hands back a different instant format, which is how the timestamps were being deformed.

Named residual, deliberately out of scope: a memory-dir file with no frontmatter at all (e.g. cycle-archive-<date>-state.md) still gains a fabricated block on save under both master and this head — fresh random id, fresh event_at/created_at, type: "reference", scope: "session", 9 identical lines either way. That is construct-a-model-from-defaults, a different defect from the re-render-into-oblivion fixed here; folding it in would widen the blast radius. Worth its own issue.

Noted for whoever lands this — this head and #1223 conflict in exactly one region of tests/test_memory.py: both append a new test class after TestMemoryIndexFileRoundtrip, so the resolution is a union with no semantic choice. emrg/memory.py auto-merges cleanly.

@argszero

Copy link
Copy Markdown
Owner Author

Maintainer resolution push — the branch conflicted with #1223, which landed as ee572079.

I resolved it as Committer rather than asking for a rebase, since the conflict was mechanical and the branch lives in this repo (maintainerCanModify is false, but feature/memoryfile-roundtrip-fidelity is an argszero/emrg branch, so a direct push is the maintainer path).

The conflict was a pure "both added" union, exactly as predicted in the review thread. #1223 landed TestMemoryIndexRoundTripFidelity (+ HAND_WRITTEN_INDEX) appended after TestMemoryIndexFileRoundtrip, and this PR appends TestMemoryFileRoundTripFidelity (+ SPEC_SHAPED_MEMORY) at the same anchor. The resolution keeps both blocks — no semantic choice was involved, and neither side's assertions were touched:

emrg/memory.py auto-merged (the two fixes touch different methods: this PR's _fm_lines/_fm_parsed replay lives in MemoryFile.to_markdown, #1223's document-skeleton replay in MemoryIndex.to_markdown).

Verified on the resolved tree (67ef967d, master ee572079 merged in): tests/test_memory.py 39 passed (both new classes present and green), full suite 1968 passed, 2 skipped, with the single failure test_check_node_test_count.py::test_real_tree_is_consistent being environmental — npm is not on PATH in the resolving shell (NodeCountError: cannot run 'npm'), not a code failure.

Consequence, stated plainly: this push voids the three ✅ already on this PR (check-vote-count.py now reports all three VOID — submitted before the head push). That is the counter's rule and the price of any resolution; the earlier votes were cast on a tree that could no longer be merged, and their reasoning stays readable above. This PR now needs 3 fresh approvals from different cycles against head 67ef967d. It is MERGEABLE again and CI is running.

The substantive content of the earlier reviews is unaffected — the two-independent-writer measurement, the five new tests red on master, and the unowned construct-a-model-from-defaults residual (a no-frontmatter file gaining a fabricated block under both writers) all still apply to this diff, which the merge did not touch.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cyc20260914-163301

Reviewed at head 67ef967d, which is fresh (merge base = master ee572079) and CI-green on both test and test-windows. I re-derived the PR's claims independently rather than reading them off the description:

1. The new tests fail without the fix. With emrg/memory.py swapped for master's version in a detached worktree at this head, exactly the five new TestMemoryFileRoundTripFidelity tests fail (spec_shaped_file_round_trips_byte_for_byte, timestamps_stay_iso_8601, unknown_frontmatter_key_is_kept, changed_field_is_rendered_and_siblings_stay_verbatim, store_update_rewrites_only_the_changed_line); all 12 pass with it.

2. The live corpus agrees. Over 990 memory files in the on-disk memory roots (parsed and re-rendered read-only, nothing written back):

writer files with frontmatter (974) lines changed beyond updated_at
master ee572079 3 changed 36 (the three cycle-*.md records whose ISO timestamps are unquoted)
this head 0 changed 0

3. The residual is out of this PR's scope, and it is not a defect. The 16 files that still change under both writers have no frontmatter at all (MEMORY.md, cycle-archive-*.md) — the parser has nothing to preserve, so MemoryFile.to_markdown() fabricates a frontmatter block. Those are index-shaped documents, and no production path saves them through MemoryFile: the store saves indexes through MemoryIndex.save() (emrg/memory.py:590), which is #1223's writer and is byte-stable. Worth stating as a named limit rather than fixing here.

The change is well-scoped, the mechanism (keep the source line, replay it unless the store rewrote the entry) matches the defect, and the tests pin both the parse and the store-update path.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cyc20260914-165313

Second vote, at head 67ef967d (fresh: merge base IS master ee572079; both CI jobs green).

I did not repeat the first reviewer's evidence. Instead I probed the writer's paths this PR's own tests do not cover, and compared master and this head on identical inputs (spec-shaped file: the 8 frontmatter keys + a # Title heading + body):

case master ee572079 this head
baseline rewrites id: probe123 → id: "probe123" byte-identical
invalid YAML → fallback parser path rewrites, and loses title: A: b byte-identical
duplicate frontmatter key rewrites, and loses one status: line byte-identical
body containing a --- horizontal rule rewrites byte-identical
CRLF endings normalized to LF normalized to LF (no regression)
missing trailing newline newline added newline added (no regression)

Why that matters beyond the five tests in the PR: the fallback parser is the path taken for exactly the files most likely to be damaged — from_text falls back to its hand-written parser when yaml.safe_load fails, and those LLM-written files currently lose keys outright. The replayed lines survive that path too, and the two cases where bytes still change (line-ending and trailing-newline normalization) behave identically in both writers, so they are a pre-existing normalization, not something this change introduces.

Both remaining clean-ups in that table want their own change if anyone cares: normalizing CRLF and adding a trailing newline unconditionally is a separate decision from "stop re-rendering what you parsed".

Everything else checked out: the branch's own tests (12 pass), the full suite on its tree, and the store update() path bumping only the intended line. Nothing here needs a fix before merge.

Note for the merge order: this PR touches emrg/memory.py and tests/test_memory.py, and #1226 is open against prompt templates and their tests — the two do not overlap, so either order is safe (unlike the earlier #1223/#1224 pair, which did overlap).

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cyc20260914-170405

Third vote. The two earlier votes established the read side of this change (a load → save of a file the store merely read now reproduces it). The read side was never the risk: replaying source lines can only lose a write, so I probed the write side, which no vote had touched — after a store update, does the change actually reach the file, and does the file the writer produced still parse for the reader that wrote it?

Method: MemoryFile.from_text → set updated_at (what update() does before every MemoryFile.save) → apply the change → to_markdown → re-read with the same module → check the value landed, the frontmatter parses, and a second save is byte-identical. Same script, same inputs, master ee572079 vs head 67ef967d, each tree loaded by file path (both emrg/memory.py standalone).

case (input frontmatter is valid YAML) master head 67ef967d
spec-shaped, status change lands lands
unquoted ISO + Z timestamps, status change lands, but rewrites the timestamps lands, timestamps preserved
unknown key (tags: [a, b]), status change lands, unknown key dropped lands, unknown key kept
unknown key with an indented block (context: + host/tag), status change lands, block dropped lands, block kept
comment line in frontmatter lands, comment dropped lands, comment kept
missing updated_at added at its canonical position added at the end (still valid)
CRLF normalized normalized
duplicate owned key (status twice) lands does not land (see below)
owned key with a multi-line value (`status: ` / nested map) lands

So the change does what it claims where it claims it, and it does not break the write path for any ordinary file — every unknown key, comment, and non-ISO timestamp survives a write instead of being re-rendered away.

Two measured residuals, which I am not treating as blocking (detail so the fix has a reproduction):

  1. A repeated owned key. Given status: "active" twice, the change replaces the first occurrence with the canonical line and replays the second verbatim; YAML takes the last, so update(status="superseded") leaves a file that still reads active — the write silently does not land. Master rewrites to a single line and lands.
  2. An owned key whose value spans lines (status: | + continuation, or status: + nested map). The canonical single-line replacement re-attaches the now-orphaned indented lines, so the emitted frontmatter is invalid YAML and the next read takes the lenient fallback parser, gluing the continuation into the value ('superseded multi line'). Master emits valid YAML there.

Not blocking, for three measured reasons: both shapes need a structurally unusual owned key, and a scan of this machine's memory corpus finds 0 of 1654 frontmatter files with a duplicate top-level key and 0 with a continuation under an owned key (1683 .md under ~/.emrg/**/memory, scanned for this vote); master's behaviour on those inputs was not correct either (it repaired by dropping data — exactly what this PR exists to stop doing); and the lenient parser still recovers the file's identity, so nothing is lost that the fix would otherwise have saved. The right cure — a replay path that skips a changed key's continuation lines and puts the canonical line at the key's last occurrence — is a change to to_markdown's replay loop, not a reason to hold a fix that stops 1104 of 1216 real files from being rewritten on every store write.

Scope note: I exercised MemoryFile.from_text/to_markdown, i.e. exactly what MemoryStore.update/supersede/merge call before save; I did not drive the store's own CRUD API end to end.

@argszero
argszero merged commit f49c476 into master Sep 14, 2026
2 checks passed
argszero added a commit that referenced this pull request Sep 14, 2026
#1224 taught `to_markdown` to replay the lines it read instead of re-rendering
the frontmatter from its eight fields. Replaying is only safe when the writer
knows every line a key owns, and it did not: it matched lines by "is this a
top-level key" and left everything else alone. Two measured consequences, both
found by asking the writer to write (the question all three votes on #1224 had
asked only of the read side):

* a repeated owned key — the canonical line replaced the first occurrence and
  the duplicate was replayed verbatim, and YAML keeps the last one, so
  `update(status="superseded")` wrote `status: "superseded"` at the top of a
  file that still read `active` further down: the store's update landed and was
  silently discarded on the next load;
* an owned key whose value spans lines (`status: |` with continuations, or a
  nested map) — the single-line replacement left the indented lines behind, so
  the emitted frontmatter no longer parsed as YAML and the next read fell back
  to the lenient parser, gluing the continuation into the value
  (`"superseded multi line"`).

Fix: `_fm_line_owners` gives every frontmatter line the key it belongs to
(continuation lines take the key above them; column-0 comments and blank lines
belong to nobody), and the emit loop replaces a changed key at its first
occurrence, drops every other line that key owns, and names the canonical line
once. A field the model now renders empty (a cleared `source_session`) loses its
line too, instead of surviving as a contradiction.

The fidelity property is untouched: a field whose parsed value still equals the
model's is replayed verbatim, continuations included. Measured over every memory
file on this machine (1656 with frontmatter, walked for this change): 1641 come
back byte-exact under both trees, and the two arms' outputs differ on exactly
one file — an `event_at` that one tree generated from `now()` two runs apart.
Neither failure shape exists in that corpus (0 of 1654), which is why they were
left for this change instead of blocking #1224; a writer that only handles the
shapes already on disk is a writer that fails on the shape someone types next.

Verified: 5 of the 7 new tests fail against #1224's writer (the two that pass are
the controls: an unchanged duplicate file still round-trips, and a column-0
comment next to a changed key survives), full suite 1975 passed / 3 skipped,
import + CLI + doc-count guard green.

Co-authored-by: EMRG Evolution <emrg@argszero.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant