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:
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,9 +61,14 @@ public struct CommentSearchTarget: Sendable, Equatable {
|
||||
/// 04 says "kept fresh by the same FSEvents stream while a query is active". The store has no
|
||||
/// changed-path channel — `FolderWatcher` reports only *that* the tree changed (02-architecture.md),
|
||||
/// and the reload seam turns that into two snapshots rather than a path list — so the freshness signal
|
||||
/// available today is `BoardStore.snapshotGeneration`: **while a query is active, a landed reload
|
||||
/// available today is `BoardStore.landedReloads`: **while a query is active, a landed reload
|
||||
/// re-sweeps**. That is coarser than the ruling asks for (it re-reads threads a reload may not have
|
||||
/// touched) and it is bounded by the same thing that bounds the reload itself, the watcher's debounce.
|
||||
///
|
||||
/// The counter is the *walk's*, not the applied snapshot's, and it has to be: comments are outside the
|
||||
/// snapshot, so a comment arriving leaves the model value-equal and a value-equal reload skips its
|
||||
/// assignment (blessed 2026-07-31). Keyed on the applied counter, the index would sleep through the
|
||||
/// one kind of change it exists to notice.
|
||||
/// A changed-path channel would narrow it to the cards whose `comments/` actually moved; until one
|
||||
/// exists, this is the accepted interim and is written down here rather than discovered later.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user