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
+6 -1
View File
@@ -889,8 +889,13 @@ public final class AppModel {
// await are reads of the store the composer is already diffing, which is why they are
// wired here rather than reached for: the engine holds the *policy* (when to wait, how
// long), the session supplies the two facts (`GitAutoCommitter.awaitCoveringSnapshot`).
//
// The generation the gate counts is `landedReloads` completed *walks* rather than
// applied snapshots because a value-equal reload skips the assignment and its
// counter since 2026-07-31, and a walk covers a flush's paths whether or not it found
// anything to change (`BoardStore.landedReloads`).
committer.awaitReloadQuiescence = { [weak store] in await store?.awaitQuiescence() }
committer.snapshotGeneration = { [weak store] in store?.snapshotGeneration }
committer.landedReloads = { [weak store] in store?.landedReloads }
// 02-architecture.md Write-failure surfacing, through the strip the board window
// already renders: a genuine commit failure means "your edits are saved, history has
// stopped advancing", which is exactly what the standing suspension row says. Lock
+11 -5
View File
@@ -484,13 +484,19 @@ struct CardWindowHost: View {
attachments.isEditable = !locked
session.comments.isEditable = !locked
}
// **The thread re-reads on every applied snapshot** (05 The comments column: "the pane
// **The thread re-reads on every landed reload** (05 The comments column: "the pane
// reloads its thread from the same FSEvents stream").
//
// *Any* reload, not a filtered one, and that is a deliberate choice worth stating: the
// store's observable surface publishes `snapshotGeneration` and a `BoardModel` it does
// not vend the changed paths, and comments are outside the snapshot entirely
// (01-storage-format.md § Enhanced schema), so there is nothing to filter *on* here.
// store's observable surface publishes counters and a `BoardModel` it does not vend the
// changed paths, and comments are outside the snapshot entirely (01-storage-format.md
// § Enhanced schema), so there is nothing to filter *on* here.
//
// **`landedReloads`, not `snapshotGeneration`**, and for that same sentence's reason: a
// comment arriving changes the tree and leaves the model value-equal, and a value-equal
// landing skips the snapshot assignment (blessed 2026-07-31). Watching the applied counter
// would mean the one kind of change this pane exists to notice is the one kind it would
// sleep through.
// Re-reading one card's thread is a handful of small files and happens only while a card
// window is open; filtering would mean either widening the store's surface to carry paths,
// or the pane keeping its own watcher a second stream over the same tree, which the
@@ -499,7 +505,7 @@ struct CardWindowHost: View {
// ledger's comment receipts through `CommentPath.classify` to tell a foreign arrival from
// its own echo (`CardComments.reload`). `initial:` is deliberately absent: `start()`
// already did the opening read, after the residue sweep that has to precede it.
.onChange(of: store.snapshotGeneration) { _, _ in
.onChange(of: store.landedReloads) { _, _ in
session.comments.reload()
}
} else {
+11 -4
View File
@@ -142,14 +142,21 @@ public final class GitAutoCommitter {
@ObservationIgnored
public var awaitReloadQuiescence: (@MainActor () async -> Void)?
/// **Which generation the board `currentSnapshot` answers with is at**
/// `BoardStore.snapshotGeneration`, incremented by every landed reload.
/// **How many tree walks the board has landed** `BoardStore.landedReloads`, incremented by
/// every reload that completed with a snapshot in hand.
///
/// **The walk, not the applied snapshot**, and the distinction is load-bearing since 2026-07-31:
/// a reload whose tree turned out to be value-equal skips the snapshot assignment and its counter
/// (02-architecture.md § Live-reload resilience), and a gate watching *that* counter would sit out
/// its whole deadline on a flush whose covering walk had already landed. What covers a flush is a
/// walk that started after its writes reached disk, and a
/// completed walk covers them whether or not it found anything different to show.
///
/// `nil` the closure absent, or answering `nil` because the store has gone means there is no
/// snapshot to be outrun by, and the covering await becomes the no-op it is on every storeless
/// committer.
@ObservationIgnored
public var snapshotGeneration: (@MainActor () -> Int?)?
public var landedReloads: (@MainActor () -> Int?)?
/// **How long an explicit flush waits for its covering reload** before composing from the snapshot
/// it already has.
@@ -563,7 +570,7 @@ public final class GitAutoCommitter {
/// after the later of them 06's own "practical cushion", doing the job it is enough for and
/// `noteWillWrite()` cannot await at all, being the synchronous flush-before-overwrite.
private func awaitCoveringSnapshot() async {
guard holdsUncoveredWrites, let read = snapshotGeneration else { return }
guard holdsUncoveredWrites, let read = landedReloads else { return }
await awaitReloadQuiescence?()
// Re-read the gate: the quiescence may itself have been the covering landing.
guard holdsUncoveredWrites, let base = read() else { return }
+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)
)
}
+6 -1
View File
@@ -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.
///
+229 -12
View File
@@ -1,4 +1,5 @@
import Foundation
import Synchronization
import os
/// Walks a board's folder tree and produces an immutable `BoardModel` snapshot a pure
@@ -193,6 +194,150 @@ public enum BoardLoader: Sendable {
return GitignoreRules(parsing: text)
}
// MARK: - The parse memo
/// **One `index.md`'s git-index heuristic record** (02-architecture.md § Live-reload resilience,
/// blessed 2026-07-31: "The walk memoizes its parse, never its result").
///
/// Modification date and byte count, and deliberately nothing else: "The mtime+size trust is the
/// git-index heuristic; a writer that defeats it content changed, mtime and size both
/// preserved is outside the app's care." No content hashing, because a hash is a read of the
/// whole file and reading the whole file is the cost the memo exists to avoid.
///
/// Stat'd through `FileManager.attributesOfItem`, **never** `URL.resourceValues`, which caches
/// its answers on the `URL` instance: a cached mtime would let the memo answer from a stamp taken
/// a reload ago, which is exactly the staleness the heuristic exists to detect.
public struct FileStamp: Sendable, Equatable {
public let modified: Date
public let size: Int
/// `nil` where the file cannot be stat'd at all read as "cannot tell", and therefore as a
/// memo miss: the walk parses, exactly as it did before the memo existed.
init?(of url: URL) {
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let modified = attributes[.modificationDate] as? Date,
let size = attributes[.size] as? NSNumber
else {
return nil
}
self.modified = modified
self.size = size.intValue
}
}
/// **The previous walk's parsed documents, indexed by root-relative path** the memo
/// (02-architecture.md § Live-reload resilience, blessed 2026-07-31: "the loader may reuse the
/// previous snapshot's parsed item for any `index.md` whose path, mtime, and size are unchanged
/// the previous snapshot *is* the memo").
///
/// **An input to `load`, never hidden state.** The loader is stateless statics and stays that
/// way: a caller that holds no memo gets a cold walk, and one that holds the last walk's memo
/// gets the same answer faster. That is the contract stated exactly "result-purity with cost
/// unspecified: same tree in, same snapshot out, and the memo can only change how fast".
///
/// ### Scope: the parse, and nothing else
///
/// A hit skips one thing opening and parsing one file. Everything *derived* from the document
/// (`schema`, `order`, the coercion trace, the identity dedupe, the trash's `kind`) is recomputed
/// from it on every walk, unchanged, which is what makes memoized and cold walks indistinguishable
/// in output rather than merely intended to be.
///
/// **Directory enumeration is never memoized**: folder discovery, attachment listings, loose-file
/// detection, the trash's entries and the noise gate are read fresh every walk, "because
/// attachment changes never touch `index.md`" a memo that covered them would go blind to
/// precisely the changes the snapshot is supposed to show.
///
/// ### A defect can never be answered from it
///
/// An entry is recorded only where the file parsed **and** its `schema` reading succeeded the
/// two steps that can produce a `BoardLoadError` at all. So a defective `index.md` is never in the
/// memo, which settles both halves of the collect-all walk's question: a file that is broken and
/// stays broken has nothing to hit and is re-read and re-collected every walk, and a file whose
/// defect was repaired moved its mtime and size and would miss anyway. The skip channel inherits
/// this by construction a skipped path *is* a defect path so a skip is recomputed from a fresh
/// parse every walk and can never be decided from a memo.
///
/// Withheld duplicate occurrences *are* recorded, and correctly so: their files parsed cleanly and
/// only the board-wide dedupe kept them out of the model, and that dedupe runs over the fresh walk
/// either way.
public struct ParseMemo: Sendable {
fileprivate struct Entry: Sendable {
let stamp: FileStamp
let document: FrontmatterDocument
}
fileprivate var entries: [String: Entry] = [:]
/// The empty memo a cold walk. The only one a caller ever constructs; every other comes
/// out of a `LoadResult`.
public init() {}
/// How many documents this memo can answer for. The walk never asks; the suites do.
public var count: Int { entries.count }
fileprivate func document(at path: String, stamp: FileStamp) -> FrontmatterDocument? {
guard let entry = entries[path], entry.stamp == stamp else { return nil }
return entry.document
}
fileprivate mutating func record(_ document: FrontmatterDocument, at path: String, stamp: FileStamp?) {
guard let stamp else { return }
entries[path] = Entry(stamp: stamp, document: document)
}
}
/// **What one walk actually read** the memo's whole claim, made assertable.
///
/// The loader's contract is result-purity with *cost unspecified*, and a cost nothing can observe
/// is a cost nothing can regress: this is the observation handle, so "a single-file echo re-parses
/// one file, not the tree" is a test rather than a hope.
///
/// Injected rather than a static tally, for `IdentityHistoryRanker`'s reason: `load` is stateless
/// statics called from several tasks at once, and a shared counter would be one mutable answer to
/// a per-walk question. `nil` every production call costs nothing at all.
public final class ParseCounter: Sendable {
/// One walk's tally: files opened and parsed, and documents answered from the memo.
public struct Counts: Sendable, Equatable {
public var parsed = 0
public var reused = 0
}
private let state = Mutex(Counts())
public init() {}
public var counts: Counts { state.withLock { $0 } }
fileprivate func noteParse() { state.withLock { $0.parsed += 1 } }
fileprivate func noteReuse() { state.withLock { $0.reused += 1 } }
}
/// One `index.md`, read through the memo the memo's only point of contact with the walk.
///
/// A hit is a document the previous walk parsed out of a file whose path, mtime and size have not
/// moved since; a miss is the ordinary `readDocument(at:path:)`, byte for byte the same call the
/// loader has always made. The stamp travels back out so the caller can record the document into
/// *this* walk's memo once its `schema` reading has succeeded `ParseMemo` states why that, and
/// not the read, is the recording point.
///
/// A file whose stamp cannot be read (`nil`) is always parsed and never recorded: "cannot tell"
/// reads as "not memoizable", which is the direction that costs a parse rather than correctness.
private static func memoizedDocument(
at url: URL,
path: String,
memo: ParseMemo?,
counter: ParseCounter?
) throws(BoardLoadError) -> (document: FrontmatterDocument, stamp: FileStamp?) {
let stamp = FileStamp(of: url)
if let stamp, let hit = memo?.document(at: path, stamp: stamp) {
counter?.noteReuse()
return (hit, stamp)
}
counter?.noteParse()
return (try readDocument(at: url, path: path), stamp)
}
// MARK: - Entry point
/// Walks the board and answers a snapshot or **every fail-fast defect the walk found**, as one
@@ -231,10 +376,20 @@ public enum BoardLoader: Sendable {
///
/// **Root paths are unskippable** (`unskippablePaths`) an entry naming the root's own
/// `index.md` is ignored and the defect collected anyway.
///
/// - Parameter memo: **the previous walk's parsed documents** (`ParseMemo`, blessed 2026-07-31).
/// `nil` a first load, a template read, a HEAD snapshot is a cold walk. Passing the last
/// walk's memo cannot change a single thing about the result, only how many files this one
/// opens; see `ParseMemo` for the scope and for why a defect can never be answered from it.
///
/// - Parameter counter: where this walk tallies what it read (`ParseCounter`). `nil` everywhere
/// but the suites.
public static func load(
boardRoot: URL,
skipping: Set<String> = [],
historyRanker: IdentityHistoryRanker? = nil
historyRanker: IdentityHistoryRanker? = nil,
memo: ParseMemo? = nil,
counter: ParseCounter? = nil
) throws(BoardLoadFailure) -> LoadResult {
// Environmental, so immediate: a root that cannot be listed has no walk to collect from.
do throws(BoardLoadError) {
@@ -253,6 +408,11 @@ public enum BoardLoader: Sendable {
// what `BoardLoadFailure` carries when it does not.
var failures: [BoardLoadError] = []
// **This walk's own memo, for the next one** (`ParseMemo`). Built as the walk goes and
// handed out on the `LoadResult`, so the loader keeps no state between calls: what the store
// passes back in is what came out of the walk before it.
var freshMemo = ParseMemo()
/// Records one fail-fast defect unless this open's user already consented to skipping that
/// exact path.
///
@@ -277,12 +437,16 @@ public enum BoardLoader: Sendable {
let boardIndexURL = boardRoot.appendingPathComponent(indexFileName)
if FileManager.default.fileExists(atPath: boardIndexURL.path) {
do throws(BoardLoadError) {
let document = try readDocument(at: boardIndexURL, path: indexFileName)
let read = try memoizedDocument(
at: boardIndexURL, path: indexFileName, memo: memo, counter: counter)
// **The root's own `schema` stays required** (01-storage-format.md § Malformed input,
// re-ruled 2026-07-31): it is the this-really-is-a-board gate, and the one `schema` on
// the board that does not read as 1 when absent.
boardSchema = try validatedRootSchema(in: document, path: indexFileName)
boardDocument = document
boardSchema = try validatedRootSchema(in: read.document, path: indexFileName)
boardDocument = read.document
// Recorded past the schema gate, never before it `ParseMemo`'s "a defect can never
// be answered from it".
freshMemo.record(read.document, at: indexFileName, stamp: read.stamp)
} catch {
record(error)
}
@@ -386,12 +550,19 @@ public enum BoardLoader: Sendable {
let lanePath = laneName + "/" + indexFileName
let laneDocument: FrontmatterDocument
let laneSchema: (schema: Int, coerced: CoercedField?)
let laneStamp: FileStamp?
// **A broken lane takes its subtree with it** (the collect-and-skip rule above): the
// defect is recorded, the lane's cards are not enumerated, and the repair's re-check is
// what surfaces whatever they were hiding.
do throws(BoardLoadError) {
laneDocument = try readDocument(
at: laneURL.appendingPathComponent(indexFileName), path: lanePath)
let read = try memoizedDocument(
at: laneURL.appendingPathComponent(indexFileName),
path: lanePath,
memo: memo,
counter: counter
)
laneDocument = read.document
laneStamp = read.stamp
// Below the root both keys are optional (re-ruled 2026-07-31): a missing `schema`
// reads as 1, a missing or unusable `order` as append-at-end. Both readings are
// coerce-tier recorded here, logged, and acted on by nothing until the file's next
@@ -401,6 +572,7 @@ public enum BoardLoader: Sendable {
record(error)
continue
}
freshMemo.record(laneDocument, at: lanePath, stamp: laneStamp)
let laneOrder = IntegrityRules.resolvedOrder(in: laneDocument)
noteCoercions(
in: laneDocument,
@@ -423,11 +595,14 @@ public enum BoardLoader: Sendable {
let card: WalkedCard
do throws(BoardLoadError) {
card = try parseCard(at: cardURL, path: cardRelPath)
card = try parseCard(at: cardURL, path: cardRelPath, memo: memo, counter: counter)
} catch {
record(error)
continue
}
// `parseCard` returning at all means the parse and the `schema` reading both
// succeeded, which is the recording point one level up spells out longhand.
freshMemo.record(card.document, at: cardRelPath + "/" + indexFileName, stamp: card.stamp)
noteCoercions(in: card.document, at: cardRelPath + "/" + indexFileName, plus: card.coercions)
// **The card-level claimed name** (01-storage-format.md § Fractal layout Rules,
@@ -539,17 +714,25 @@ public enum BoardLoader: Sendable {
let entryPath = entryRelPath + "/" + indexFileName
let document: FrontmatterDocument
let schema: (schema: Int, coerced: CoercedField?)
let stamp: FileStamp?
// Collected and skipped, the lane arm's rule one container over: a trash entry that will
// not parse leaves the trash rather than refusing the board, and its own subtree was
// never walked to begin with (the entry is opaque by design).
do throws(BoardLoadError) {
document = try readDocument(
at: entryURL.appendingPathComponent(indexFileName), path: entryPath)
let read = try memoizedDocument(
at: entryURL.appendingPathComponent(indexFileName),
path: entryPath,
memo: memo,
counter: counter
)
document = read.document
stamp = read.stamp
schema = try resolvedSchema(in: document, path: entryPath)
} catch {
record(error)
continue
}
freshMemo.record(document, at: entryPath, stamp: stamp)
let order = IntegrityRules.resolvedOrder(in: document)
noteCoercions(
in: document,
@@ -727,6 +910,7 @@ public enum BoardLoader: Sendable {
return LoadResult(
model: model,
warnings: warnings,
memo: freshMemo,
defects: defects,
// Keyed by identity, so a withheld entry's reading has to go with it: two folders sharing
// an id would otherwise leave a `kind` answer standing for the *other* one the exact
@@ -929,6 +1113,9 @@ public enum BoardLoader: Sendable {
/// This card's coerce-tier records for the strict fields, which only the rulebook can make
/// (a missing key leaves no trace in `document.coercedFields`).
let coercions: [CoercedField]
/// What this card's `index.md` looked like to `stat(2)` as the walk read it the key the
/// next walk's memo hit is decided by, `nil` where the file could not be stat'd at all.
let stamp: FileStamp?
var title: FieldValue<String> { document.title }
var isDeleted: Bool { !document.deleted.isMissing }
@@ -961,9 +1148,25 @@ public enum BoardLoader: Sendable {
///
/// `path` is root-relative and names the *folder*; the errors this throws name its `index.md`.
/// Callers guard `isUUIDShaped` and `hasIndex` first, exactly as the lane walk always has.
private static func parseCard(at cardURL: URL, path: String) throws(BoardLoadError) -> WalkedCard {
///
/// The **attachment listing stays fresh** here, memo or no memo (`ParseMemo` Scope): a hit
/// spares this card's `index.md` read and nothing else, because an attachment arriving in
/// `attachments/` never touches `index.md` and a card whose paperclip went stale would be the
/// memo lying about the tree.
private static func parseCard(
at cardURL: URL,
path: String,
memo: ParseMemo?,
counter: ParseCounter?
) throws(BoardLoadError) -> WalkedCard {
let cardPath = path + "/" + indexFileName
let document = try readDocument(at: cardURL.appendingPathComponent(indexFileName), path: cardPath)
let read = try memoizedDocument(
at: cardURL.appendingPathComponent(indexFileName),
path: cardPath,
memo: memo,
counter: counter
)
let document = read.document
let schema = try resolvedSchema(in: document, path: cardPath)
let order = IntegrityRules.resolvedOrder(in: document)
@@ -973,7 +1176,8 @@ public enum BoardLoader: Sendable {
storedOrder: order.order,
attachments: attachmentNames(in: cardURL),
document: document,
coercions: [schema.coerced, order.coerced].compactMap { $0 }
coercions: [schema.coerced, order.coerced].compactMap { $0 },
stamp: read.stamp
)
}
@@ -1331,6 +1535,19 @@ public struct LoadResult: Sendable {
public var model: BoardModel
public var warnings: [LoadWarning]
/// **What this walk parsed, ready to be the next walk's memo** (`BoardLoader.ParseMemo`, blessed
/// 2026-07-31).
///
/// It rides out here rather than being derived from `model` for two reasons. The stamps are not
/// in the snapshot and never will be mtime and size are facts about files, not about a board
/// and the documents that *are* in the snapshot would have to be re-indexed by path to be usable,
/// which is the walk's own knowledge being thrown away and re-derived. Carrying both together
/// keeps the loader a pure function whose caller holds the whole of what the next call may reuse.
///
/// A caller that ignores it gets a cold walk every time, which is exactly what
/// `TemplateEngine`, `GitHeadSnapshot` and every first load do.
public var memo: BoardLoader.ParseMemo = BoardLoader.ParseMemo()
/// **The typed defect stream** everything this walk found that is pending *work*
/// (02-architecture.md Components IntegrityRules, settled 2026-07-29). One channel, not
/// three: loose card files, legacy `deleted:` keys, and a claimed board-root name held by the
+7
View File
@@ -162,6 +162,13 @@ struct FileDropTarget: Equatable, Sendable {
/// app-mediated echo is normally next, and a foreign one that lands first re-grounds everything
/// anyway.
///
/// It watches `BoardStore.snapshotGeneration`, which since 2026-07-31 does not move for a reload whose
/// tree came back value-equal, and that is the right counter rather than a hazard: a hold is only ever
/// armed by a drop whose write actually rearranged something every drop path refuses a no-op
/// arrangement *before* it opens a write bracket (`BoardStore.moveCards`, `moveLanes`, `moveLane`)
/// so a drop that would land value-equal never writes and never reloads, and its hold is the
/// watchdog's to retire exactly as it was before the skip existed.
///
/// The `timeout` is the same guarantee the drag session's watchdog gives the drag itself: a write
/// that was refused outright (a read-only board) produces no reload at all, and an overlay with no
/// hand-off coming must still dissolve and let the snapshot be the authority again.