Memoize the reload parse and short-circuit value-equal snapshots

The loader gains a ParseMemo — the previous walk's parsed documents keyed
by root-relative path, trusted on the git-index heuristic (mtime + size,
no hashing) and passed as an input so the loader stays stateless. A hit
skips exactly one file read; schema, order, coercions, dedupe, and every
directory listing run fresh, so memoized and cold walks are output-
identical (golden-corpus equivalence suite). Entries record only past the
schema gate, so a defect can never be answered from the memo.

The store skips the snapshot assignment wholesale when the fresh model is
value-equal — no @Observable churn, no render pass, no snapshotGeneration
bump — and a new landedReloads counter carries walk-completion for the
three consumers whose subject is the walk, not the snapshot: the card
window's comment thread, the comment search index, and the auto-committer's
covering gate (which now counts a completed walk as covering even when
nothing changed). Warnings and defects move on their own equality; failed
reloads bump neither counter. An injectable ParseCounter makes the
single-file-echo claim a test.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 11:38:25 -04:00
parent 5e6417e749
commit 988a7245a3
11 changed files with 1085 additions and 70 deletions
+170 -46
View File
@@ -230,15 +230,47 @@ public final class BoardStore: HealHost {
/// path see the type's doc comment for why. A failed reload leaves it exactly as it was.
public private(set) var snapshot: BoardModel
/// How many snapshots this store has applied, ever a counter, not a version.
/// How many snapshots this store has **applied**, ever a counter, not a version.
///
/// It exists for **the committed-overlay hold** (DRAG-REORDER.md § The committed-overlay hold):
/// a drop's overlay stands until "the next snapshot application on that store", and *application*
/// is the event, not change. A reload that produced an identical model still ends the round trip
/// the overlay was covering comparing `snapshot` values would leave the overlay standing
/// exactly when the write turned out to be a no-op.
/// a drop's overlay stands until the next snapshot application on that store, and the whole point
/// of the counter is that it is an *event* the overlay can watch rather than a value it would have
/// to diff.
///
/// **A value-equal reload does not bump it** (02-architecture.md § Live-reload resilience,
/// blessed 2026-07-31): "the store skips the assignment entirely when the fresh snapshot equals
/// the current one so 'costs nothing visible' becomes *costs nothing*". Bumping
/// an observed counter is itself a render pass, so the skip has to cover this line too or it
/// covers nothing.
///
/// The hold tolerates that, and provably: a hold is only ever armed by a drop whose write actually
/// rearranged something `moveCards`/`moveLane` refuse a no-op arrangement *before* they open a
/// bracket, so a drop that would land value-equal never writes, never reloads, and its hold is
/// already the watchdog's to retire (`CommittedHold.timeout`) exactly as it was before the skip
/// existed. Anything whose subject is the *walk* rather than the snapshot reads `landedReloads`
/// instead.
public private(set) var snapshotGeneration: Int = 0
/// How many tree walks this store has **landed with a snapshot in hand**, ever bumped by every
/// successful reload, whether or not the snapshot it produced was different enough to assign.
///
/// The counter for everything whose subject is the *walk*, which is everything that lives outside
/// the snapshot and therefore cannot be inferred from it:
///
/// - **Comments.** They are not in `BoardModel` at all (01-storage-format.md § Enhanced schema),
/// so a foreign comment arriving changes the tree, changes nothing in the snapshot, and must
/// still refresh the card window's thread and board search's comment index. Keying either on
/// `snapshotGeneration` would make the value-equal skip a freshness bug rather than an
/// optimization.
/// - **The auto-committer's covering gate** (`GitAutoCommitter.awaitCoveringSnapshot`): what
/// covers a flush is a *walk* that started after its writes hit disk, and a completed walk
/// covers them whether or not the tree turned out to differ. A gate waiting on the applied
/// counter would spin out its whole deadline on any value-equal landing.
///
/// A failed reload bumps neither counter: it produced no snapshot, so it covers nothing and
/// refreshes nothing the pre-skip behaviour of `snapshotGeneration` exactly, kept exactly.
public private(set) var landedReloads: Int = 0
/// Tolerated anomalies from the load that produced `snapshot` (stray folders, an indexless
/// UUID-shaped folder, a board-level `deleted:`). Replaced with the snapshot, so they always
/// describe the tree currently on screen.
@@ -488,6 +520,41 @@ public final class BoardStore: HealHost {
@ObservationIgnored
private var reloadInFlight = false
/// **The last walk's parsed documents** (`BoardLoader.ParseMemo`, blessed 2026-07-31
/// 02-architecture.md § Live-reload resilience): what the next reload hands the loader so an
/// unchanged `index.md` is not re-read and re-parsed for the hundredth time.
///
/// **The store is where it lives because the store is what has a *previous* walk.** The loader is
/// stateless statics and the memo is an argument to `load`, never hidden state inside it; the
/// first load of a board has no memo and walks cold, and every reload after it carries the one
/// the walk before produced.
///
/// **Bookkeeping, not observable state** `@ObservationIgnored`, and updated by every successful
/// landing whether or not the snapshot was assigned. A value-equal reload skips the assignment
/// precisely *because* nothing moved, and the memo it produced is the freshest set of stamps
/// there is; dropping it there would make every second reload cold.
///
/// A failed reload leaves it alone: there is no `LoadResult` to take one from, and a memo keyed by
/// path plus mtime plus size is safe to hold indefinitely every entry it can no longer answer
/// for simply misses.
///
/// Internal rather than `private` for `reloadGeneration`'s reason: what the memo holds is the
/// claim, and a suite that could not read it would be asserting the effect of something it had to
/// take on faith.
@ObservationIgnored
private(set) var parseMemo = BoardLoader.ParseMemo()
/// **What each reload's walk actually read** (`BoardLoader.ParseCounter`) the seam that makes
/// "an unchanged tree re-parses nothing" a test rather than an intention.
///
/// `nil` in production, and the walk pays nothing for it there; a suite hangs a counter here and
/// reads it after `awaitQuiescence()`. The third seam this type keeps, on `loadBarrier`'s and
/// `announce`'s terms exactly: the *rules* are pure functions elsewhere, and what only a seam can
/// make assertable is the wiring here, that the store hands the previous walk's memo to the
/// next walk at all.
@ObservationIgnored
var parseCounter: BoardLoader.ParseCounter?
/// A reload owed but not started, because one was already running when the signal arrived a
/// **flag, not a queue**: any number of signals during one walk coalesce into exactly one
/// follow-up, because the follow-up is a full tree walk that covers all of them. The origin is
@@ -646,6 +713,8 @@ public final class BoardStore: HealHost {
self.snapshot = result.model
self.loadWarnings = result.warnings
self.defects = result.defects
// The opening walk was cold by definition; what it parsed is the first reload's memo.
self.parseMemo = result.memo
self.skippedPaths = skipping
self.reloadFailure = nil
self.readOnlyLock = nil
@@ -720,6 +789,12 @@ public final class BoardStore: HealHost {
// **This session's consented skips, on every walk it runs** (`skippedPaths`): the open's
// decision stands for the session, so a reload sees the board the user chose to open.
let skipping = skippedPaths
// **The previous walk's parse, offered to this one** (`parseMemo`). Read on the main actor
// and carried into the walk as a value, exactly like `skipping` and the ranker: the loader
// stays stateless statics and this reload's memo is a fact about a walk that already
// finished, not a channel back into one that has not.
let memo = parseMemo
let counter = parseCounter
reloadInFlight = true
Self.logger.debug("reload \(generation, privacy: .public) started (\(origin.rawValue, privacy: .public))")
@@ -729,7 +804,12 @@ public final class BoardStore: HealHost {
let outcome: Result<LoadResult, BoardLoadFailure>
do throws(BoardLoadFailure) {
outcome = .success(try BoardLoader.load(
boardRoot: root, skipping: skipping, historyRanker: historyRanker))
boardRoot: root,
skipping: skipping,
historyRanker: historyRanker,
memo: memo,
counter: counter
))
} catch {
outcome = .failure(error)
}
@@ -841,53 +921,92 @@ public final class BoardStore: HealHost {
}
facts.vanishedFocus = focus.vanished
// **The motion language's one decision point** (03-board-ui.md § Motion, via `Motion`).
// "User-initiated structural changes animate; foreign changes snap" cannot live at the
// call sites here the way it did in the pathfinder the one-way flow means the user's
// own delete arrives back through the watcher exactly like an agent's edit so it lives
// at the *reload*, whose origin is already classified. `Motion` owns which origins
// perform and in which voice; this store owns only the assignment they wrap.
// **The memo rides the reload, not the snapshot** (`parseMemo`): bookkeeping, so it is
// taken from every landing the value-equal ones most of all, since those are exactly
// the walks whose stamps are freshest and whose successor would otherwise go cold.
parseMemo = result.memo
// **The value-equal reconcile skips the assignment entirely** (02-architecture.md
// § Live-reload resilience, blessed 2026-07-31): "assigning an equal tree into an
// `@Observable` property still costs a render pass, so 'costs nothing visible' becomes
// *costs nothing*".
//
// A `nil` animation is not a special case: `withAnimation(nil)` is the bare assignment,
// which is what the snapping origins and the Reduce Motion variant both want.
withAnimation(Motion.reloadAnimation(
origin: origin,
endsBracketedOperation: endsWholesaleOperation,
reduced: Motion.prefersReducedMotion
)) {
snapshot = result.model
snapshotGeneration += 1
loadWarnings = result.warnings
// The one place transient state is re-grounded. It goes last, after `snapshot` is
// the new one, because a view woken by the snapshot's change must never observe a
// selection still pointing at the old tree.
// It is a skip of the **observable assignment and nothing else**. Everything a landing
// owes the memo above, the landing count, the warnings and defects (which describe the
// *tree*, not the model, and can move while the model does not: a new stray folder, a
// loose file appearing beside an untouched `index.md`), the breakage clearing, the
// comment index, the lock reconciliation, the display write-through, the heals, the
// announcement and the commit seam happens below on every landing, equal or not.
//
// What the guard covers is precisely what an equal model makes vacuous: `transient
// .resolve(against:)` re-grounds state against a tree that did not move, and
// `recoverFocus` is handed a `.survived` outcome by construction (`focusOutcome` over two
// equal models can find nothing vanished), so skipping them changes no state a caller
// could observe only the render pass they would have cost.
if result.model != snapshot {
// **The motion language's one decision point** (03-board-ui.md § Motion, via
// `Motion`). "User-initiated structural changes animate; foreign changes snap" cannot
// live at the call sites here the way it did in the pathfinder the one-way flow
// means the user's own delete arrives back through the watcher exactly like an
// agent's edit so it lives at the *reload*, whose origin is already classified.
// `Motion` owns which origins perform and in which voice; this store owns only the
// assignment they wrap.
//
// Inside the transaction deliberately: 03 § Motion has the selection highlight
// "ride whatever transaction is active rather than easing on its own", and a
// re-grounding that landed outside this one would be exactly the independent ease
// that rules out.
//
// The comment index' current answer rides along, because the filter half of the
// re-grounding is the *whole* filter (04 Search, re-ruled 2026-07-29) a card
// matching only through its comments must not be evicted from the selection by a
// reload that asked a narrower question. The index is re-swept just below, outside
// the transaction, and its landing re-runs this constraint through `onRefine`.
transient.resolve(against: result.model, commentMatches: commentIndex.matchingCards)
// And *then* the recovery, on top of the set rule rather than instead of it: the
// resolution leaves an emptied selection wherever the focused item used to be, and
// this is 10-accessibility.md's answer to the hole ("focus recovers to the card's
// lane walks up then sideways"). Inside the same transaction for the highlight's
// sake, exactly like the resolution it follows.
recoverFocus(focus.recovery)
// A `nil` animation is not a special case: `withAnimation(nil)` is the bare
// assignment, which is what the snapping origins and the Reduce Motion variant both
// want.
withAnimation(Motion.reloadAnimation(
origin: origin,
endsBracketedOperation: endsWholesaleOperation,
reduced: Motion.prefersReducedMotion
)) {
snapshot = result.model
snapshotGeneration += 1
// The one place transient state is re-grounded. It goes last, after `snapshot` is
// the new one, because a view woken by the snapshot's change must never observe a
// selection still pointing at the old tree.
//
// Inside the transaction deliberately: 03 § Motion has the selection highlight
// "ride whatever transaction is active rather than easing on its own", and a
// re-grounding that landed outside this one would be exactly the independent ease
// that rules out.
//
// The comment index' current answer rides along, because the filter half of the
// re-grounding is the *whole* filter (04 Search, re-ruled 2026-07-29) a card
// matching only through its comments must not be evicted from the selection by a
// reload that asked a narrower question. The index is re-swept just below,
// outside the transaction, and its landing re-runs this constraint through
// `onRefine`.
transient.resolve(against: result.model, commentMatches: commentIndex.matchingCards)
// And *then* the recovery, on top of the set rule rather than instead of it: the
// resolution leaves an emptied selection wherever the focused item used to be,
// and this is 10-accessibility.md's answer to the hole ("focus recovers to the
// card's lane walks up then sideways"). Inside the same transaction for the
// highlight's sake, exactly like the resolution it follows.
recoverFocus(focus.recovery)
}
}
// Outside it, equally deliberately 03 keys transactions narrowly, and the banner
// **The walk landed, assignment or not** the counter everything outside the snapshot
// watches (`landedReloads`). Bumped here rather than beside `snapshotGeneration` above
// precisely because it is not about the snapshot.
landedReloads += 1
// Outside the transaction, deliberately 03 keys transactions narrowly, and the banner
// conditions are not board structure. A lock clearing is not a thing the strip should
// slide out on the back of a card being deleted.
//
// Each guarded by its own equality rather than by the snapshot's, because each answers a
// different question about the tree: a stray folder appearing changes `loadWarnings` with
// the model untouched, and a loose file appearing beside an unedited `index.md` changes
// `defects` the same way. An unguarded assignment of an equal array is the render pass
// the skip above exists to avoid, one property further down.
//
// Breakage always heals on a success it *is* the claim "the last reload failed", and
// this one did not.
reloadFailure = nil
defects = result.defects
if loadWarnings != result.warnings { loadWarnings = result.warnings }
if reloadFailure != nil { reloadFailure = nil }
if defects != result.defects { defects = result.defects }
// **The comment index' freshness signal, and its stated interim** (04-interactions.md
// Search: "kept fresh by the same FSEvents stream while a query is active"). The store has
// no changed-path channel the watcher reports only *that* the tree changed
@@ -4268,9 +4387,14 @@ public final class BoardStore: HealHost {
guard let self else { return }
transient.constrainToSearch(in: snapshot, commentMatches: commentIndex.matchingCards)
}
// **`landedReloads`, not `snapshotGeneration`**: comments are outside the snapshot entirely
// (01-storage-format.md § Enhanced schema), so a comment arriving changes the tree and leaves
// the model value-equal and a value-equal landing skips the snapshot assignment. The
// freshness signal an index of window-scoped content can use is therefore the *walk*, which
// is what this counter is.
commentIndex.update(
query: transient.searchQuery,
generation: snapshotGeneration,
generation: landedReloads,
targets: CommentSearchIndex.targets(in: snapshot)
)
}