# Incremental Transcript Projection (iOS)

Status: **implemented.** `IncrementalTranscriptProjection`
(`ios/NucleicRemote/NucleicRemote/Views/Transcript/IncrementalTranscriptProjection.swift`)
replaces `ProjectionCache` in `TranscriptList`; `TranscriptProjection.buildSegment` is the
segment-aware fold. The equivalence safety net lives in
`Tests/NucleicRemoteProjectionTests` (a `NucleicRemoteProjection` SwiftPM target compiles the
projection sources host-side, since the Xcode project has no test target) — run with
`swift test --filter NucleicRemoteProjectionTests`.

The macOS twin (`Sources/NucleicCore/IncrementalTranscriptProjection.swift`) has since grown
past this spec: a second lazily-primed consumer serving the store's summary pass
(`summarizableUnits`), a pinned-watermark fast path, a cached tail partition, and per-scope
child sealers that keep a long-running subagent from refolding its whole scope per tick — see
that file's header (and `docs/MAIN_THREAD_PERFORMANCE_PLAN.md`, Item 4) for the invariants.

Two implementation findings that refined the spec (see the code for detail):

- **Lock `released` notes are NOT within-turn** — they land when the file lands in the parent,
  potentially many turns after the edit (AppStore's release-on-land reconcile). A finite lag `L`
  can't absorb that, so cross-seam folding is handled *exactly* instead: sealed edit-class calls
  stay reachable through a registry (`PriorEdit`), and a late note patches the sealed card just
  where a whole-stream fold would put it.
- **"Superseded by a later item" is not a safe seal rule for messages** — Claude's whole-message
  decode re-emits a messageID's authoritative non-partial text *after* that message's tool items.
  Sealing instead uses an exact id-interval check over the current stream plus closure fences
  (result arrived / next message started in scope / turn completed), and any premise violation —
  a tail event referencing a sealed id — is detected and answered with a full refold, so
  correctness never rests on decoder behavior.

## Problem

The iOS session view folds the raw event stream into render rows via
`TranscriptProjection.build(events)` (`ios/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift`).
`TranscriptList.items` (in `SessionDetailView.swift`) calls it, and today every appended
streaming delta re-runs `build` over the **entire** stream:

- `partition` — O(N events): builds the tool→parent map and splits top-level vs. subagent scopes
- `flatItems` — O(N events): the coalescing fold
- `foldLockNotes` — O(items × edits) worst case (backward scans)
- `coalesceToolRuns` — O(items)
- `attachSubagentChildren` / `project` — re-projects each subagent's events

A turn emits K deltas and K grows with transcript length, so a session costs ~**O(N²)**.

A memoization pass has already been landed in `TranscriptList` (`ProjectionCache`) that
collapses *redundant* folds when the stream is unchanged (e.g. the many `body`
re-evaluations during the open/settle window, where `transcriptContentHeight` is bumped on
every settling-layout frame). That fixed the opening stutter but does **not** help live
streaming: a real delta still forces a full-stream re-fold. This doc specifies the
incremental projector that removes the O(N²).

## Key insight: the stream is immutable except at the tail

Streaming deltas only ever mutate the **current turn**. Everything before the last
completed turn is frozen — its `messageID`s and `toolCallID`s never recur, so those items
never change again. Fold each turn once, cache it, and re-fold only the live tail per delta.

A **turn boundary** is a clean seam (not an arbitrary index) because:

- **Text/thinking/tool coalescing** keys on `messageID`/`toolCallID`, which don't cross
  turns → no item straddles the seam.
- **Tool-run coalescing** flushes on any non-tool item, and a `.turnBoundary` item is
  non-tool → a run can't span a boundary.
- **Subagent partitioning** is co-turn: a `Task` runs to completion (its result arrives)
  before its parent turn's `turnCompleted`, so its `byParent` events sit in the same segment.
- **`worktreeRoot`** comes from the `sessionStarted` event at seq 0 (always sealed) → carry
  it forward as one cached constant.

Given all that, `fold(prefix) ++ fold(tail)` is **identical** to `fold(whole)`.

## Design: stable-prefix incremental projector

Decision (agreed): **zero-lag seal at every turn boundary, plus intra-turn sealing.** The
two unify into one rule: **seal the longest provably-stable prefix of the folded item list;
re-fold only the unstable suffix per delta.** A turn boundary is just the strongest stable
point, so both granularities are the same mechanism.

### Stability rule (what may enter the sealed prefix)

An item is sealable once it can no longer change:

- **message** — a later item exists after it (assistant has moved on)
- **thinking** — superseded by a later item
- **tool** — `finished == true` (result arrived); for an orchestration (`Task`/`Agent`)
  item, `finished` means the subagent returned, so its `children` snapshot is frozen too
- **turnBoundary / usage / rateLimit / error / note / sessionStarted** — terminal on arrival

Plus two seam constraints so the sealed/tail split is byte-identical to a full `build`:

1. **Never seal mid tool-run.** `coalesceToolRuns` merges adjacent `.tool` items into a
   `.toolBlock`. The seam must fall *after* a non-tool item (or a fully-closed run), never
   between two tool calls a future call could extend.
2. **Lag `L` behind the tail.** `foldLockNotes` folds a lock note backward onto its edit.
   Keep the newest `L` items unsealed so a late-arriving lock note still lands in the tail
   and folds correctly. This item-lag is orthogonal to the zero *turn*-lag decision; it
   exists only to keep lock folding exact. With strictly within-turn locking, `L` is small.

### State (replaces the current `ProjectionCache` in `TranscriptList`)

- `sealedItems: [TranscriptItem]` — folded output of the stable prefix
- `sealedEventCount: Int` — watermark into the raw stream
- `worktreeRoot: String?` — carried constant (from seq-0 `sessionStarted`), injected into
  tail lock-normalization
- `streamID` guard — reset all state on session switch (stream shrank / `firstSeq` changed)

### Per-read algorithm

1. Reset if the stream identity changed (shrank / `firstSeq` differs).
2. **Advance the watermark** to the furthest sealable point (per the rule above), folding
   each newly-stable segment **once** and appending to `sealedItems`. O(segment); never
   re-walks sealed history.
3. **Re-fold the unstable tail** (`events[sealedEventCount...]`) fresh — re-partition,
   `flatItems`, `foldLockNotes`, `coalesceToolRuns`, `attachSubagentChildren` over the tail
   only. Bounded by the live region, independent of N.
4. Return `sealedItems + tailItems`.

**Result:** per-delta cost drops from O(N) to O(live-tail). For a normal multi-turn
transcript the tail is the current turn; intra-turn sealing keeps it small even inside one
long autonomous run. Whole-session **O(N²) → O(N)**.

### Refactor in `TranscriptProjection`

- Add `build(segment:worktreeRoot:)` that folds an isolated contiguous slice with an
  injected root (today `build` rescans for `sessionStarted`, which a tail slice lacks).
- Keep `build(events:)` as a thin wrapper for one-shot callers (history load, etc.).

## Safety net (required)

An equivalence test: replay a recorded event stream and assert
`incrementalFold(prefix) == TranscriptProjection.build(prefix)` for **every** prefix. This
exhaustively proves the seam never reorders, orphans a lock note, or splits a run — the
whole correctness argument, checked mechanically. Treat as a required deliverable, not
optional. It is also what makes the aggressive zero-lag + intra-turn choice trustworthy.

## Verification step folded into implementation

Confirm lock notes are strictly within-turn (waiting → acquired → edit → released) against
the host emission (`docs/LOCKING.md` §4 / the note stream). If confirmed, `L` stays minimal;
if a cross-boundary case exists, `L` absorbs it — either way the equivalence test catches a
wrong choice.

## Related follow-up

- **Markdown pre-warm** (shipped): short macOS transcripts load the complete per-session, versioned
  disk snapshot of renderer-neutral inline runs before eager layout. Long virtualized transcripts
  instead parse only the visible tail before reveal (no whole-sidecar decode on the first-paint
  path), then load and refresh the complete snapshot off-main from newest to oldest. The in-memory
  `NSCache` remains the render hot path; completed turns refresh the disk cache for the next open.
  This addresses first-paint parsing cost while the incremental projector addresses per-delta
  streaming cost; the two are complementary.
