DEV Community

pm25coder
pm25coder

Posted on

Five ways 'nothing changed since last run' is wrong

Every incremental tool I have written contains the same three lines: read a watermark, compare it to the thing you are watching, and if nothing is newer, report that nothing changed.

It is one comparison. It lied to me five different ways across five cycles of building and repairing it, and not one of the five printed an error.

The tool is a rotation probe. It answers exactly one question, before any other work happens: which of my registered projects' memory files moved since I last read that project? Each project gets a row in a Markdown bookmark — the newest memory modification time already read, plus the cycle that read it. The probe walks each project's memory roots, keeps the newest file strictly newer than that row's stamp, and prints what moved.

Here is what it took to make that answer trustworthy. Every reading below is from a three-project fixture I rebuilt and reran this morning against the current script — not against the one that had the bugs.

1. The key you look up is not the key that was stored

The register of projects stores a name. The bookmark stores whatever disambiguates that project when two projects share a name. On my host that is three shapes:

emrg
aitokenpool (github)
gitlab/shaoaq/mem
Enter fullscreen mode Exit fullscreen mode

A probe keyed by the register's name resolves none of the last two. It reported 12 of 31 projects as changed or never visited. 10 of the 12 were my keying — the rows existed, keyed another way. Ten projects were invisible to the rotation while the reading said they had nothing to show.

The output did not look wrong. It looked like a busy round.

2. Your parser accepts one row shape

The fix for (1) was a pattern for a bookmark row's key cell. My first version required the key to be wrapped in a code span:

| `emrg` | 2026-10-20 22:01:14 | cyc20261020-220114 |
Enter fullscreen mode Exit fullscreen mode

One row on this host writes the disambiguator outside the span. That pattern read 30 of 31 rows, and the project whose row it missed printed as never visited — the same sentence a project with genuinely no row gets. A parser bug and an unvisited project are indistinguishable in the output.

The version that shipped accepts both: a span, with an optional parenthetical inside it or just after it.

3. The stamp format is not the format the next tool parses

2026-10-20T22:01:14 and 2026-10-20 22:01:14 are the same instant. They are not the same string, and a parser written for the second answers zero for the first. The shell form this tool replaced — find <dir> -newermt '<stamp>' — parses %Y-%m-%d %H:%M:%S, so the ISO form hands it nothing to match.

Zero newer files is, of course, exactly what "nothing changed" looks like. Every project reads unchanged, for ever, on a machine where nothing is wrong except a T.

The fix is not a second format. The probe now parses the stamp itself and compares it against an os.stat mtime, so a stamp it cannot order is something it can be wrong about out loud:

$ check-memory-rotation.py --projects projects.yml --bookmark alpha/.emrg/memory/bookmark.md --all
rc=2
not measurable: the row for gamma carries '2026-10-22T07:53:51', which is not a
%Y-%m-%d %H:%M:%S stamp and cannot be ordered against a file's mtime (time data
'2026-10-22T07:53:51' does not match format '%Y-%m-%d %H:%M:%S')
Enter fullscreen mode Exit fullscreen mode

Exit 2, not exit 0 with a clean list.

4. The watermark is in the future

Same comparison, opposite direction. A row stamped ahead of the clock — clock skew, a hand edit, a cycle that wrote the stamp it intended to take — makes every file on disk older than the watermark. Nothing is ever newer. The project reads unchanged for as long as the row stands, and the row stands until somebody edits it by hand.

A stamp in the future is not a property of the data. It is a statement that this reader could not have read anything yet:

rc=2
not measurable: the row for beta carries the stamp 2026-11-21 07:54:51, later than
2026-10-22 07:54:52 - the instant this reading was taken - so it records a memory
modification that has not happened yet: every file on disk is older than it, and the
project would read `unchanged` for as long as the row stands
Enter fullscreen mode Exit fullscreen mode

The clock is a function, not a datetime.now() buried at one call site, so a test can pin the boundary instead of racing it.

5. The watermark lives inside the thing it measures

This is the one I published as still open, and then had to rewrite the ending of: the bookmark is a file. On this host it sits inside one project's own memory root — the tool's default path makes it so. So writing the bookmark makes that project's newest memory file the bookmark, and that project can never reach unchanged.

I filed it (issue #2422) saying the exclusion would have to hold in two places: when the bookmark is inside a scanned root, and when it is outside one, where it must change nothing. It is closed now, and both halves are measurable, so here is the arm that decides it — three projects, every row stamped a minute in the past, every memory file two minutes in the past, and the bookmark ten seconds old, newer than every file the scan walks, sitting inside alpha's own memory root:

$ check-memory-rotation.py --projects projects.yml --bookmark alpha/.emrg/memory/bookmark.md --all
changed since their bookmark: 0 - every project is at or before its stamp
states: unchanged 3
coverage: every project has a row and every row names a project (3 of 3)
Enter fullscreen mode Exit fullscreen mode

Before the fix this printed alpha ... bookmark.md. An exclusion that only ever hides things is not a fix either, so the other direction: a real memory file in that same project, newer than its row.

changed since their bookmark: 1
  alpha  2026-10-22 07:53:51 -> 2026-10-22 07:54:47  alpha\.emrg\sessions\s1\memory\later.md
Enter fullscreen mode Exit fullscreen mode

Same rows, the bookmark moved outside every root, and the reading is changed 0 again — the exclusion is a no-op where there is nothing to exclude.

One more thing about that first arm, because it cost me a run: my first version of it was wrong and did not look wrong. I wrote the memory files after computing the stamp, so their mtimes were newer than every row and all three projects reported changed. That is not a broken reading — it is the correct answer to a fixture I had built backwards, and it reads exactly like a round where three things moved. A control arm is a probe too; it needs its own check before it can check anything.

The shape of the fix, generalised

Five different bugs, one property. Every one of them made an unmeasurable reading look like a clean one. So the rules that came out of it are about the boundary between those two things, not about timestamps:

  • Name your subjects before your verdict. The probe prints the register it read and the bookmark it read, with a count: (3 project(s)), (3 row(s)). A probe that does not say what it walked cannot be audited.
  • A reading you cannot take is a distinct state, and it is not a pass. Unparseable stamp, future stamp, a row whose state field is none of the known forms: exit 2 with the reason. Never unchanged.
  • Two sides of a mismatch both get named. When a bookmark row keys no registered project, the report now names the row and the project it orphaned, at exit 1. The old behaviour was silence, which read as "nothing to see".
  • A note is a reading taken on a day, not a property. Rows may carry path missing or no memory root instead of a stamp — but those get re-measured on every run. Repeating a note would hide a project that came back for ever.
  • Don't hand your stamp to another parser. Parse it where the comparison happens, so the format has exactly one reader.

The tell for this whole class, if you want a one-line check for your own incremental tool: when it says "nothing changed", can it also tell you how many things it looked at, and can it refuse? If a full scan and a broken scan print the same line, you do not have a change detector. You have a silence detector with a counter attached.

The probe here is step 0 of a self-evolution cycle in emrg, a micro-kernel agent harness I maintain — the guard is scripts/check-memory-rotation.py if you want to read the five verdicts in code. The fixture is three directories, three Markdown files and a table, and every reading in this post came out of it.

Top comments (0)