refactor(tui): extract session timeline state

This commit is contained in:
Kit Langton 2026-07-17 21:47:34 -04:00
commit a653744d78
15 changed files with 1897 additions and 586 deletions

View file

@ -1,6 +1,6 @@
# Quark Timeline Architecture — and How It Can Be Faster
An architecture review of the `opencode-quark-timeline` experiment: what the layers are, why the design is sound, and the *mechanical* reason it can beat the Solid Store timeline — with the actual code.
An architecture review of the `opencode-quark-timeline` experiment: what the layers are, why the design is sound, and the _mechanical_ reason it can beat the Solid Store timeline — with the actual code.
## The Stack in One Picture
@ -34,13 +34,13 @@ improvements.
## The Core Idea: Identity and Equivalence Are Inputs, Not Discoveries
This is the entire architectural bet. Solid Store must *discover* what changed; Quark is *told* what identity and sameness mean, once, at construction:
This is the entire architectural bet. Solid Store must _discover_ what changed; Quark is _told_ what identity and sameness mean, once, at construction:
```typescript title="packages/tui/src/routes/session/rows.ts" caption="The complete reconciliation policy is two functions"
const state = Keyed.make({ key: rowKey, equivalent: sameRow })
```
`rowKey` answers *"which slot is this?"* — including the subtle case where a group's key is the ref that **created** it, so refs moving to `pending` can never change identity. Keys are precomputed once at row construction with length-prefixed segments, so reconciliation pays zero key-extraction work:
`rowKey` answers _"which slot is this?"_ — including the subtle case where a group's key is the ref that **created** it, so refs moving to `pending` can never change identity. Keys are precomputed once at row construction with length-prefixed segments, so reconciliation pays zero key-extraction work:
```typescript title="packages/tui/src/routes/session/rows.ts" caption="Keys are built once, at construction — rowKey is a field read"
export type SessionRow = { readonly id: string } & ( /* ...variants... */ )
@ -58,7 +58,7 @@ function rowKey(row: SessionRow) {
}
```
`sameRow` answers a different question: *"can any consumer tell these two values apart?"* It compares only render-relevant fields. If it says yes-they're-the-same, **nothing downstream runs at all**.
`sameRow` answers a different question: _"can any consumer tell these two values apart?"_ It compares only render-relevant fields. If it says yes-they're-the-same, **nothing downstream runs at all**.
## Two Channels Instead of One
@ -66,8 +66,8 @@ A Solid Store exposes one reactive graph; every consumer subscribes into the sam
```typescript title="packages/quark/src/keyed.ts" start=4
export interface Keyed<A, Key> {
readonly slots: Readable<readonly Readable<A>[]> // fires ONLY on insert/remove/reorder
readonly values: Readable<readonly A[]> // fires on any current value change
readonly slots: Readable<readonly Readable<A>[]> // fires ONLY on insert/remove/reorder
readonly values: Readable<readonly A[]> // fires on any current value change
has(key: Key): boolean
get(key: Key): Readable<A> | undefined
set(values: readonly A[]): void
@ -98,13 +98,15 @@ group row. Trace that value update through both systems.
### Before: Solid Store path
```typescript caption="Old hot path — every access and write crosses a proxy"
setRows(produce((draft) => {
// 1. draft is a proxy — every property read is a trap
// 2. finding the group row walks proxied array elements
// 3. the mutation writes through proxy machinery
// 4. Solid records fine-grained dependencies per touched path
append(draft, ref, part, queuedStart(draft))
}))
setRows(
produce((draft) => {
// 1. draft is a proxy — every property read is a trap
// 2. finding the group row walks proxied array elements
// 3. the mutation writes through proxy machinery
// 4. Solid records fine-grained dependencies per touched path
append(draft, ref, part, queuedStart(draft))
}),
)
// ...and on reconnect / revert / rebuild:
setRows(reconcile(reduce()))
// reconcile must re-derive identity from item references,
@ -123,10 +125,10 @@ export function make<A, Key>(options: {
readonly equivalent?: (left: A, right: A) => boolean
}): Keyed<A, Key> {
const slots = State.make<readonly Writable<A>[]>([]) // structure channel: one signal holding the slot array
const byKey = new Map<Key, Writable<A>>() // identity → slot address, maintained by set/insert/remove
const equivalent = options.equivalent ?? Object.is // declared sameness (sameRow in the TUI)
const byKey = new Map<Key, Writable<A>>() // identity → slot address, maintained by set/insert/remove
const equivalent = options.equivalent ?? Object.is // declared sameness (sameRow in the TUI)
const values = Computed.make<readonly A[]>((previous) => {
const next = slots().map((slot) => slot()) // aggregate channel, lazy until subscribed
const next = slots().map((slot) => slot()) // aggregate channel, lazy until subscribed
return same(previous, next) ? previous! : next
})
@ -135,11 +137,11 @@ export function make<A, Key>(options: {
values,
// ...
update(value) {
const key = options.key(value) // 1. one key extraction (a field read: row.id)
const slot = byKey.get(key) // 2. one Map lookup — an address, not a search
const key = options.key(value) // 1. one key extraction (a field read: row.id)
const slot = byKey.get(key) // 2. one Map lookup — an address, not a search
if (!slot) throw new Error(`Keyed value does not exist: ${String(key)}`)
if (equivalent(slot(), value)) return false // 3. one equivalence check — early cutoff
slot.set(value) // 4. one signal write; slots is untouched
slot.set(value) // 4. one signal write; slots is untouched
return true
},
// ...
@ -169,20 +171,21 @@ const groupSlot = structure[0]
rows.update({ ...initial, completed: true })
rows.slots() === structure // true — <For> receives no change at all
rows.slots()[0] === groupSlot // true — component owner survives
groupSlot().completed === true // true — the one subscribed row re-renders
rows.slots() === structure // true — <For> receives no change at all
rows.slots()[0] === groupSlot // true — component owner survives
groupSlot().completed === true // true — the one subscribed row re-renders
```
This is also the **beauty** mechanism, not just speed: an expanded reasoning group keeps its local state through permission repartitioning, because refs moving between `refs` and `pending` is a value change on a stable identity — never a remount.
### The membership cutoff — duplicate deltas avoid the aggregate entirely
A route-scoped set contains every visible part identity, including refs nested
inside groups. A duplicate streaming delta dies at `seenParts.has(id)` before
reading `state.values()`. Duplicate message and footer events use `Keyed.has`
against the collection's existing key map. For a replacement that reaches
`update`, declared equivalence remains the final publication cutoff.
The compiled `parts` members index contains every visible part identity,
including refs nested inside groups. A duplicate streaming delta dies at
`state.hasMember("parts", id)` before reading `state.values()`. Duplicate
message and footer events use `Keyed.has` against the collection's existing key
map. For a replacement that reaches `modify`, generated equivalence remains the
final publication cutoff.
## More Before / After, From the Actual Diff
@ -226,12 +229,12 @@ mutate(() => {
if (state.has(footerRowID(messageID))) return
const current = state.values()
const index = queuedStart(current)
complete(current, index) // one state.update on the previous group, if open
complete(current, index) // one state.update on the previous group, if open
insert(current, index, footerRow(messageID)) // one new slot + one structural publication
})
```
Same policy, but the after version *names* its effects: at most one slot value change plus one structural change, flushed together by `Transaction.run`. Nothing has to diff anything to figure that out afterward.
Same policy, but the after version _names_ its effects: at most one slot value change plus one structural change, flushed together by `Transaction.run`. Nothing has to diff anything to figure that out afterward.
### Removing a row
@ -260,18 +263,18 @@ setRows(reconcile(reduce()))
setRows(reduce()) // → state.set(next): Map lookups + sameRow checks; unchanged rows publish nothing
```
This is the one place the two systems do comparable O(N) work — and it's exactly the workload the checked benchmark measured. Every path above it is where Quark does structurally *less*.
This is the one place the two systems do comparable O(N) work — and it's exactly the workload the checked benchmark measured. Every path above it is where Quark does structurally _less_.
## The Cost Ledger
| Per operation | Solid Store (`produce`/`reconcile`) | Quark `Keyed` |
| --- | --- | --- |
| Find the row | proxied array walk / identity re-derivation | one `Map.get` |
| Detect "no change" | proxy-graph diff per touched path | one `equivalent()` call |
| Value change | proxy writes + per-path invalidation | one signal write → one Solid signal |
| Structure unchanged | reconcile still walks the list | outer array identity preserved; `<For>` silent |
| Structure changed | full reconcile | one new array; retained slots reused by reference |
| Batch of writes | store batching | one `Transaction.run` — settled publication |
| Per operation | Solid Store (`produce`/`reconcile`) | Quark `Keyed` |
| ------------------- | ------------------------------------------- | ------------------------------------------------- |
| Find the row | proxied array walk / identity re-derivation | one `Map.get` |
| Detect "no change" | proxy-graph diff per touched path | one `equivalent()` call |
| Value change | proxy writes + per-path invalidation | one signal write → one Solid signal |
| Structure unchanged | reconcile still walks the list | outer array identity preserved; `<For>` silent |
| Structure changed | full reconcile | one new array; retained slots reused by reference |
| Batch of writes | store batching | one `Transaction.run` — settled publication |
Same asymptotics — `O(N)` for a whole-array `set` — but far fewer instructions, allocations, and invalidations per unit of change. **The speedup is a constant-factor win purchased with a stronger contract** (unique stable keys, declared equivalence), not framework magic. The checked-in claim: ~4.7× on sparse value publication, ~6.4× on reorder, at 1,000 rows.
@ -281,7 +284,7 @@ Same asymptotics — `O(N)` for a whole-array `set` — but far fewer instructio
- **Deep module, tiny surface.** One small `keyed.ts` module carries the whole idea; the laws (unique key, stable key, equivalence, structural cutoff, settled publication, ownership) are explicit and testable.
- **The adapter is honest.** `useValue` is 8 lines and creates no second reconciliation system — the failure mode the design doc itself warns about.
- **Identity-as-input is the right call** for this domain: OpenCode *has* real identities (messageID, partID) and was previously throwing that information away for Solid to rediscover.
- **Identity-as-input is the right call** for this domain: OpenCode _has_ real identities (messageID, partID) and was previously throwing that information away for Solid to rediscover.
- **Falsifiable framing.** The docs predict where Quark should lose. That's rare and worth preserving.
### Where the architecture leaked — all fixed during review
@ -292,7 +295,7 @@ Every leak identified in the first pass has since been closed:
- ~~**The aggregate channel reintroduces O(N) per delta.**~~ `boundaries` now derives from the structure channel (`rows.slots()`) and reads slot values untracked (quark reads are invisible to Solid's tracker); the raw `values` readable is no longer mirrored into Solid. Per-delta boundary cost is zero; the O(N) recompute fires only on structural change or tracked `messages()` field changes.
- ~~**Boundary staleness as an implicit invariant.**~~ The untracked-read trick is now type-enforced: `messageBoundaryIDs` accepts `readonly BoundaryRow[]`, a projection of `Pick`ed identity-immutable fields, so a future dependence on a mutable row field is a compile error.
- ~~**Two grouping engines.**~~ Join-vs-insert policy is unified in `appendDecision` + `groupKind`; both the batch `reduce()` path and the incremental `appendPart` path consume it, and row construction goes through shared smart constructors.
- ~~**Benchmark not checked in / wrong hot path.**~~ `packages/quark/bench/` now exists and covers incremental `keyed.update` — including against Solid's *direct path write*, the adversarial case — plus dense updates and reorders.
- ~~**Benchmark not checked in / wrong hot path.**~~ `packages/quark/bench/` now exists and covers incremental `keyed.update` — including against Solid's _direct path write_, the adversarial case — plus dense updates and reorders.
- ~~**No work counters.**~~ `Keyed` accepts an optional `Metrics` sink; `OPENCODE_QUARK_METRICS=1` reports timeline counters on cleanup.
- ~~**Opaque positioning and adapter boilerplate.**~~ `insert` and `move` now share an explicit `Position` vocabulary (`"end"`, `before`, or `after`), `get` exposes stable slot addresses, and `KeyedFor` encapsulates the two-level Solid subscription.

View file

@ -80,37 +80,45 @@ recover identity and preserve nested owners.
## Quark Makes Identity an Input
`Keyed` receives the identity and equivalence functions when the collection is
created. In the TUI, every row carries a collision-safe primitive `id`. Groups
`Layout.collection` compiles identity, equivalence, and indexes once when the
timeline is created. Every row carries a collision-safe primitive `id`. Groups
also retain their immutable origin for message-boundary projection:
```typescript
type PartRef = {
messageID: string
partID: string
}
const PartRefLayout = Layout.struct({
messageID: Layout.string,
partID: Layout.string,
})
type SessionRow = { readonly id: string } & (
| { type: "message"; messageID: string }
| { type: "compaction-queued"; inputID: string }
| { type: "part"; ref: PartRef }
| {
type: "group"
kind: "reasoning"
origin: PartRef
refs: PartRef[]
completed: boolean
}
| {
type: "group"
kind: "exploration"
origin: PartRef
refs: PartRef[]
pending: PartRef[]
completed: boolean
}
| { type: "assistant-footer"; messageID: string }
)
const SessionRowLayout = Layout.keyedUnion({
key: Layout.key("id", Layout.string),
tag: "type",
variants: {
message: Layout.struct({ messageID: Layout.string }),
"compaction-queued": Layout.struct({ inputID: Layout.string }),
part: Layout.struct({ ref: PartRefLayout }),
group: Layout.union({
tag: "kind",
variants: {
reasoning: Layout.struct({
origin: Layout.immutable(PartRefLayout),
refs: Layout.array(PartRefLayout),
completed: Layout.boolean,
}),
exploration: Layout.struct({
origin: Layout.immutable(PartRefLayout),
refs: Layout.array(PartRefLayout),
pending: Layout.array(PartRefLayout),
completed: Layout.boolean,
}),
},
}),
"assistant-footer": Layout.struct({ messageID: Layout.string }),
},
})
type PartRef = Layout.Type<typeof PartRefLayout>
type SessionRow = Layout.Type<typeof SessionRowLayout>
```
Factories build the ID once with length-prefixed segments. Length prefixes
@ -126,56 +134,28 @@ function groupRow(kind: "reasoning" | "exploration", origin: PartRef): SessionRo
if (kind === "reasoning") return { id, type: "group", kind, origin, refs: [origin], completed: false }
return { id, type: "group", kind, origin, refs: [origin], pending: [], completed: false }
}
function rowKey(row: SessionRow) {
return row.id
}
```
`sameRow` answers a different question: can consumers distinguish these two
values for the same identity? It compares only fields that affect row
rendering.
Generated equivalence answers a different question: can consumers distinguish
these two values for the same identity? The layout compares rendered fields,
dispatches through the row and group tags, and ignores immutable `origin`.
```typescript
function sameRow(left: SessionRow, right: SessionRow) {
if (left.type !== right.type) return false
const SessionRows = Layout.collection(
SessionRowLayout,
({ members }) => ({
parts: members(partIDs),
}),
{ backend: "generated" },
)
if (left.type === "message" && right.type === "message") return left.messageID === right.messageID
if (left.type === "compaction-queued" && right.type === "compaction-queued") return left.inputID === right.inputID
if (left.type === "part" && right.type === "part") return sameRef(left.ref, right.ref)
if (left.type === "assistant-footer" && right.type === "assistant-footer") return left.messageID === right.messageID
if (left.type !== "group" || right.type !== "group") return false
if (left.kind !== right.kind) return false
if (left.completed !== right.completed) return false
if (!sameRefs(left.refs, right.refs)) return false
if (left.kind === "reasoning" || right.kind === "reasoning") return true
return sameRefs(left.pending, right.pending)
}
function sameRefs(left: PartRef[], right: PartRef[]) {
return left.length === right.length && left.every((ref, index) => sameRef(ref, right[index]))
}
function sameRef(left: PartRef, right: PartRef) {
return left.messageID === right.messageID && left.partID === right.partID
}
const rows = SessionRows.make()
```
Those functions form the complete reconciliation policy:
```typescript
const rows = Keyed.make<SessionRow, string>({
key: rowKey,
equivalent: sameRow,
})
rows.set(nextRows)
```
The `parts` index contains standalone and grouped part IDs. Duplicate stream
deltas return through `hasMember` before aggregate projection. Arbitrary
replacements derive membership automatically; the hot group-append path applies
the exact one-member delta already known from the event.
It exposes two reactive surfaces:
@ -207,10 +187,7 @@ rows.set([initial])
const structure = rows.slots()
const groupSlot = structure[0]
rows.update({
...initial,
completed: true,
})
rows.modify(initial.id, (group) => ({ ...group, completed: true }))
rows.slots() === structure // true: <For> receives no structural change
rows.slots()[0] === groupSlot // true: component owner survives
@ -269,13 +246,14 @@ The current algorithm is deliberately small:
```text
set(next):
1. Compute every next key and reject duplicates.
2. Build Map<key, previousSlot>.
2. Look up retained slots in the persistent key map.
3. For each next value:
a. Reuse the previous slot for its key, or create one.
b. Compare previous and next values.
b. Run generated equivalence over previous and next values.
c. Write only a changed slot.
4. Publish the outer slot array only if membership or order changed.
5. Flush slot and structural writes in one transaction.
5. Synchronize declared collection indexes.
6. Flush slot, index, and structural writes in one transaction.
```
The aggregate `values` readable maps the current slots to their values. It is a
@ -322,54 +300,31 @@ These laws are stronger than a plain `Array<object>` contract. They are also
testable. Quark should reject a violated unique-key law and should have direct
tests for every other law.
## Fixed Layout Research Is Deferred
## Layout Compiles Trusted Collection Metadata
The standalone laboratory applies the same idea to records. The initial prototype
used Effect Schema, but its derived equivalence measured `1.621x` the hand
comparator's direct-update cost. Quark now experiments with a smaller trusted
in-memory `Layout` that compiles:
The timeline now uses Quark's trusted in-memory `Layout`. It supports the scope
required by this experiment:
- field names into numeric positions;
- field changes into a bit mask;
- field equivalence into precomputed functions;
- entity keys and indexed fields into fixed metadata.
- primitive, array, struct, discriminated-union, and keyed-union fields;
- immutable fields excluded from rendered equivalence;
- compiled key extraction and closure or generated equivalence;
- imperative multi-value membership and ordered first-match indexes;
- automatic index derivation for arbitrary replacements;
- typed member deltas for event-native mutations that know exact changes.
The minimal API marks the key in the shape itself:
Generated nested union comparison measured `1.06x-1.10x` handwritten cost,
versus `1.19x-1.22x` for closure Layout across three runs. Generation occurs
once when `SessionRows` is declared. The older `1.322x` closure result motivated
this backend; it no longer describes the production comparator.
```typescript
const ItemLayout = Layout.struct({
id: Layout.key(Layout.number),
value: Layout.number,
})
Automatic membership derivation is linear in an affected row's members. That
is the safe default, but it made repeated append to one growing group roughly
`27x-30x` the handwritten index path. The event already identifies the new
part, so `SessionTimeline.appendPart` supplies a typed one-member delta. Generic
completion and permission repartition retain automatic derivation.
const ItemPlan = Layout.compile(ItemLayout)
const items = ItemPlan.make([
{ id: 1, value: 10 },
{ id: 2, value: 20 },
])
items.update({ id: 2, value: 21 })
```
`ItemPlan` exposes the original field metadata, compiled key field, compiled
equivalence, and collection factory. Simple struct comparison has measured
near handwritten speed, but a representative nested session-row union measured
`1.322x` the handwritten keyed-update cost after closure specialization.
Generated comparison can remove more indirection, but it is optional research,
not a requirement for this timeline experiment.
A one-field record replacement can first compute the change mask, then write
only changed field slots. An indexed-field write knows exactly which index
buckets to remove from and add to. An entity upsert resolves directly through
its immutable key.
The static information is therefore not “the compiler knows the type.” It is
“the runtime has already compiled the layout and the application has agreed to
obey its laws.” Effect Schema can still validate or transform data at an
admission boundary without participating in collection updates. None of this
`Layout`, model, mask, index, or columnar-storage machinery is vendored into the
TUI fork. The current landing remains the small `Keyed` abstraction plus the
route reducer; broader promotion requires separate evidence.
Numeric field positions, change masks, per-field reactive slots, models, and
columnar storage remain future research. They are not required by the TUI.
## Asymptotics and Constants
@ -388,11 +343,12 @@ complexity**:
- no general nested proxy reconciliation inside Quark.
For direct collection operations, stronger bounds are possible. An immutable
key lets `Keyed.update` or `Collection.upsert` find an entity in expected `O(1)`
time, after which work is proportional to changed fields and affected declared
indexes rather than total collection size. The TUI now uses event-native
`update`, `insert`, and `remove` operations for live events; full synchronization
and revert rebuilds still use linear `set`.
key lets `has`, `get`, `hasMember`, and `modify` find their target in expected
`O(1)` time. Work is then proportional to the changed row, affected declared
indexes, and any ordered structural array copy. `SessionTimeline` caches the
active group and queued boundary IDs; inserts and removals remain `O(N)` because
the ordered slots array must be copied. Full synchronization and revert rebuilds
remain linear `set` operations.
## Controlled Benchmark Evidence
@ -402,6 +358,9 @@ The benchmarks are checked into the fork and run directly:
cd packages/quark
bun run bench:keyed
bun run bench:row-key
cd ../tui
bun run bench:timeline
```
Each invocation interleaves and rotates variants over nine measured samples
@ -425,20 +384,21 @@ Across these two invocations:
The checksum was stable, and both variants performed the same next-array
construction inside their timed workloads.
The refreshed integration benchmark measures event-native updates with the
aggregate `values` channel subscribed, plus adverse workloads. The July 17,
2026 evidence run produced:
The refreshed lower-level `Keyed` benchmark measures event-native updates with
the aggregate `values` channel subscribed, plus adverse workloads. It does not
include `SessionTimeline`, generated row equivalence, or compiled index
maintenance. The July 17, 2026 evidence run produced:
| Workload | Quark | Solid | Quark / Solid |
| ----------------------------------------- | ----------: | ----------: | ------------: |
| Direct update, no subscribers, 1,000 | 46.8 ns | 761.6 ns | 0.068x |
| Precise Solid path write, 1,000 | 54.3 ns | 327.5 ns | 0.170x |
| Subscribed aggregate, 10 rows | 608.0 ns | 7.4 us | 0.085x |
| Subscribed aggregate, 100 rows | 4.4 us | 80.0 us | 0.055x |
| Subscribed aggregate, 1,000 rows | 37.4 us | 703.1 us | 0.052x |
| Subscribed aggregate, 10,000 rows | 388.5 us | 8.1 ms | 0.044x |
| Dense 1,000-row update | 270.2 us | 2.3 ms | 0.132x |
| Unstable keys, 100 rows | 68.0 us | 245.2 us | 0.282x |
| Workload | Quark | Solid | Quark / Solid |
| ------------------------------------ | -------: | -------: | ------------: |
| Direct update, no subscribers, 1,000 | 46.8 ns | 761.6 ns | 0.068x |
| Precise Solid path write, 1,000 | 54.3 ns | 327.5 ns | 0.170x |
| Subscribed aggregate, 10 rows | 608.0 ns | 7.4 us | 0.085x |
| Subscribed aggregate, 100 rows | 4.4 us | 80.0 us | 0.055x |
| Subscribed aggregate, 1,000 rows | 37.4 us | 703.1 us | 0.052x |
| Subscribed aggregate, 10,000 rows | 388.5 us | 8.1 ms | 0.044x |
| Dense 1,000-row update | 270.2 us | 2.3 ms | 0.132x |
| Unstable keys, 100 rows | 68.0 us | 245.2 us | 0.282x |
A second independent invocation produced paired ratios of `0.084x`,
`0.170x`, `0.077x`, `0.059x`, `0.061x`, `0.042x`, `0.130x`, and `0.266x`
@ -455,6 +415,24 @@ falsified prediction for this setup, not evidence that Solid can never win a
direct-write comparison. The production TUI has no aggregate subscriber; it
tracks structural slots and immutable boundary metadata instead.
The checked-in `SessionTimeline` benchmark compares the final domain module to
the previous handwritten `Keyed + Set` reducer mechanics and the original Solid
Store `produce` path. Across three runs:
| Workload | SessionTimeline result |
| ----------------------------------------------------- | ----------------------: |
| Growing reasoning-group append / handwritten Quark | about `1.3x-1.5x` |
| Growing reasoning-group append / Solid `produce` | about `0.013x` |
| Duplicate part in a 1,000-ref group / Solid `produce` | about `0.0004x-0.0005x` |
The final module pays roughly 30%-50% over the minimal handwritten Quark path
for compiled layout dispatch, domain policy, and maintained index ownership.
It remains roughly 74x-79x faster than the baseline Solid append in this
growing-group stress workload. Duplicate lookup is expected `O(1)` through the
compiled members index; the Solid baseline scans 1,000 refs, producing an
approximately 1,960x-2,500x stress-case difference. These are state-operation
results, not claims about total OpenCode response latency.
Key extraction was measured independently over 10,000 representative rows:
| Key strategy | Median | Paired ratio to JSON |
@ -468,8 +446,8 @@ The TUI now uses the precomputed primitive ID strategy.
## What the Benchmark Does Not Prove
The suite compares whole-array reconciliation, direct point updates, subscribed
aggregate projection, dense changes, unstable keys, and key extraction. It
does not prove that Quark beats:
aggregate projection, dense changes, unstable keys, key extraction, growing
timeline groups, and duplicate deltas. It does not prove that Quark beats:
- every Solid keyed-list primitive;
- rendering, terminal layout, or paint;
@ -500,19 +478,18 @@ These are falsifiable predictions. The current adverse suite did not find a
Solid win, including the newly added precise path write, but it preserves these
cases so future changes cannot optimize only the favorable sparse path.
## The Next Tests Must Measure Work, Not Just Time
## Deterministic Tests Measure Work, Not Just Time
The strongest next experiment is a single-binary A/B implementation driven by
one deterministic event trace. It should count:
The single-binary deterministic event trace counts:
| Deterministic transition | Slot delta | Structure delta | Observed ownership behavior |
| ------------------------------ | ---------: | --------------: | ------------------------------------------- |
| First exploration part | +0 | +1 | New group slot |
| Extend exploration group | +1 | +0 | Existing group slot retained |
| Permission repartition | +1 | +0 | Existing group slot retained |
| Insert queued user row | +0 | +1 | Group remains mounted and incomplete |
| Complete promoted-input group | +1 | +0 | Existing group slot retained |
| Duplicate text delta | +0 | +0 | Returns before aggregate materialization |
| Deterministic transition | Slot delta | Structure delta | Observed ownership behavior |
| ------------------------------ | ---------: | --------------: | -------------------------------------------- |
| First exploration part | +0 | +1 | New group slot |
| Extend exploration group | +1 | +0 | Existing group slot retained |
| Permission repartition | +1 | +0 | Existing group slot retained |
| Insert queued user row | +0 | +1 | Group remains mounted and incomplete |
| Complete promoted-input group | +1 | +0 | Existing group slot retained |
| Duplicate text delta | +0 | +0 | Returns through compiled membership index |
| Full unchanged two-row rebuild | +0 | +0 | Two equivalence suppressions, no publication |
`Keyed` exposes optional counters for slot publications, structural
@ -524,6 +501,11 @@ deltas` records the duplicate path. The direct Keyed law test records the full
unchanged rebuild. Boundary tests independently assert that value-only changes
do not invalidate structural boundary projection.
Pure timeline tests additionally cover cached queue advancement, out-of-order
promotion, duplicate message/footer idempotence, stable group ownership, and
parity between event-native operations and snapshot reduction. Unique append
uses cached IDs and never reads `state.values()`.
## Decision Rule
Continue the Quark timeline experiment if deterministic replay confirms all of

View file

@ -4,15 +4,17 @@ Status: experiment in progress
## Summary
The V2 TUI currently stores its rendered session-row list in a Solid Store.
This experiment changes only that owner: `createSessionRows` stores an ordered
array of stable row slots in Quark state. Each slot owns one `SessionRow`, and
an owner-aware adapter exposes both levels to the existing Solid renderer.
The V2 TUI previously stored its rendered session-row list in a Solid Store.
This experiment changes only that owner: `SessionTimeline` stores an ordered
array of stable row slots in a compiled Quark collection. Each slot owns one
`SessionRow`, and an owner-aware adapter exposes both levels to the existing
Solid renderer.
The event protocol, durable session data, `DataProvider`, row reduction rules,
and `SessionRowView` remain unchanged. This boundary makes the experiment easy
to compare and easy to remove. It does not yet move messages or secondary
indexes into Quark collections.
to compare and easy to remove. Message content remains in `DataProvider`;
`SessionTimeline` owns a finite imperative index of part IDs for its current
rows.
The experiment succeeds only if all three checks pass:
@ -56,10 +58,13 @@ server events
DataProvider Solid Store
|
v
createSessionRows
createSessionRows adapter
|
v
Quark State<RowSlot[]>
SessionTimeline
|
v
Layout.collection<SessionRow>
|
v
KeyedFor outer + slot adapters
@ -74,15 +79,17 @@ would make a regression or improvement impossible to attribute.
## Components and Responsibilities
| Component | Responsibility |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `packages/tui/src/context/data.tsx` | Preserve the existing server-event projection and message lookup API. |
| `packages/tui/src/routes/session/rows.ts` | Reduce loaded messages, apply incremental timeline events, and own ordered rows. |
| `packages/quark/src/reactivity.ts` | Provide synchronous state updates, subscriptions, and transaction batching. |
| `packages/quark/src/keyed.ts` | Reuse per-key slots and separate value changes from structural changes. |
| `packages/quark/src/solid.ts` | Bridge Quark readables into Solid ownership and compose stable slots with `KeyedFor`. |
| `packages/tui/src/routes/session/index.tsx` | Render stable row accessors through `KeyedFor` and the existing `SessionRowView` components. |
| `script/quark-timeline-drive.ts` | Exercise the same streamed timeline scenario against baseline and fork binaries. |
| Component | Responsibility |
| --------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `packages/tui/src/context/data.tsx` | Preserve the existing server-event projection and message lookup API. |
| `packages/tui/src/routes/session/rows.ts` | Translate DataProvider state and events into timeline operations; own the Solid adapter lifecycle. |
| `packages/tui/src/routes/session/timeline.ts` | Declare row layout, reduce snapshots, cache cursors, and implement timeline domain mutations. |
| `packages/quark/src/layout.ts` | Compile row keys/equivalence and maintain declared imperative collection indexes. |
| `packages/quark/src/reactivity.ts` | Provide synchronous state updates, subscriptions, and transaction batching. |
| `packages/quark/src/keyed.ts` | Reuse per-key slots and separate value changes from structural changes. |
| `packages/quark/src/solid.ts` | Bridge Quark readables into Solid ownership and compose stable slots with `KeyedFor`. |
| `packages/tui/src/routes/session/index.tsx` | Render stable row accessors through `KeyedFor` and the existing `SessionRowView` components. |
| `script/quark-timeline-drive.ts` | Exercise the same streamed timeline scenario against baseline and fork binaries. |
## Quark State Publishes One Synchronous Value
@ -187,10 +194,11 @@ Most live events update the current row array directly:
| Step started | Remove the previous retry footer. |
| Permission changed | Repartition exploration refs between visible and pending lists. |
`queuedStart(rows)` finds the first queued compaction or pending message. New
assistant parts and completed messages insert before that boundary. Pending
user inputs append after queued work. A running compaction inserts at the start
of the queued region.
`SessionTimeline` caches the first queued row ID and the active incomplete group
ID. Full replacement derives both once. Live operations maintain them as work
is queued or promoted. New assistant parts and completed messages insert before
the cached queue boundary; pending user inputs append after queued work; a
running compaction becomes the new boundary.
`append(rows, ref, part, index)` implements grouping:
@ -214,13 +222,14 @@ because call IDs can look like generated text or reasoning IDs.
The old Solid `produce` path created a mutable draft of the row list. The first
Quark implementation also rebuilt a complete next array, then reconciled it.
The event-native path now uses the information already present in each event:
The event-native path now calls domain operations backed by the compiled
collection:
```text
value change -> Keyed.update(row)
new row -> Keyed.insert(row, { before })
removed footer -> Keyed.remove(row.id)
full sync / revert -> Keyed.set(nextRows)
group change -> state.modify(groupID, update)
new row -> state.insert(row, { before })
removed footer -> state.remove(row.id)
full sync / revert -> state.set(nextRows)
```
Extending or completing a group creates one new group value with copied refs
@ -229,9 +238,11 @@ structure. Permission repartitioning first checks whether a group's visible or
pending membership would actually change, then allocates arrays only for those
groups.
Duplicate message, part, or footer events return before a Keyed operation.
Same-ordinal streaming deltas therefore produce no slot or structural
publication after the first part.
Message and footer duplicates use the keyed map. Part duplicates use the
compiled `parts` members index and return before aggregate projection. Unique
group appends use cached IDs and do not read `state.values()`. The append event
also supplies the exact one-member index delta, avoiding a scan of the growing
group; arbitrary replacements retain automatic index derivation.
## `Keyed` Preserves Row Identity
@ -240,24 +251,28 @@ to preserve child ownership. A changed group value must not remount its
`SessionRowView`, because that would reset the group's local expanded and hover
state. `<For>` therefore receives stable Quark slots rather than row values.
`Keyed.set(nextRows)` preserves ownership in four passes:
`Keyed.set(nextRows)` preserves ownership through its persistent key-to-slot
map:
```text
1. Index previous slots by the semantic key of their current row.
2. For each next row, find the previous slot with that key.
3. Update that slot only when the rendered row fields changed.
1. Validate the next keys.
2. Find each retained slot through the persistent map.
3. Update that slot only when generated row equivalence reports a change.
4. Reuse the previous outer slot array when membership and order are unchanged.
```
Every row receives one collision-safe length-prefixed primitive ID when it is
created. `Keyed` reads `row.id`; reconciliation performs no serialization:
created. `SessionRowLayout` declares that key and generates discriminated-union
equivalence. Group `origin` is immutable metadata; `refs`, `pending`, and
`completed` participate in equivalence. Reconciliation performs no
serialization:
| Row | ID shape |
| ----------------- | ----------------------------------------------------- |
| Message | `m{length}:{messageID}` |
| Queued compaction | `c{length}:{inputID}` |
| Part | `p{message segment}{part segment}` |
| Assistant footer | `f{length}:{messageID}` |
| Row | ID shape |
| ----------------- | ------------------------------------------------------ |
| Message | `m{length}:{messageID}` |
| Queued compaction | `c{length}:{inputID}` |
| Part | `p{message segment}{part segment}` |
| Assistant footer | `f{length}:{messageID}` |
| Group | `g{kind}{origin message segment}{origin part segment}` |
Every group records the ref that created it as immutable `origin` metadata.
@ -269,10 +284,13 @@ partitions cannot change its ID or boundary identity. Group equivalence compares
order changes, so Solid `<For>` does no structural work for a group-value
update. `values` depends on both the outer list and every slot for consumers
that require aggregate values. The TUI has no reactive `values` subscriber; it
uses the lazy aggregate only as an internal snapshot for unique mutation paths.
uses the lazy aggregate for bulk permission repartitioning and snapshot/test
consumers.
Message boundaries track structural slots and messages, then read immutable
row identity fields untracked. A changed group publishes through its existing
slot and preserves the mounted Solid owner without recalculating boundaries.
The lazy aggregate is read by permission repartitioning and snapshot/test
consumers, not by unique append paths.
Slot updates and the outer-array update run inside both a Quark transaction and
a Solid batch. The Quark transaction settles the alien-signals graph; the
@ -285,23 +303,25 @@ from one ordering and an outer slot array from another.
Let `R` be visible rows, `M` loaded messages, `P` assistant content parts, and
`C` the total refs contained by visible groups.
| Operation | Time | Allocation |
| -------------------------- | -----------------------: | ----------------------------------------------: |
| Full rebuild | `O(M + P + R)` | New reduction plus reconciled slot array |
| Unique incremental append | `O(R + C)` | One structural array or one changed group array |
| Duplicate part append | `O(1)` | None |
| Duplicate message/footer | `O(1)` | None |
| Footer removal | `O(R)` | One structural slot array |
| Permission repartition | `O(R + C)` | Arrays only for groups whose split moves |
| Quark-to-Solid publication | `O(1)` per changed level | One slot write and, when required, outer write |
| Operation | Time | Allocation |
| ---------------------------- | -----------------------: | ---------------------------------------------: |
| Full rebuild | `O(M + P + R)` | New reduction plus reconciled slot array |
| Active group / queue lookup | Expected `O(1)` | None |
| Queue boundary advancement | `O(R)` | None |
| Group extension | `O(G)` | One copied group array; `O(1)` index delta |
| Structural insertion/removal | `O(R)` | One structural slots array |
| Duplicate part append | `O(1)` | One key string |
| Duplicate message/footer | `O(1)` | One key string |
| Footer removal | `O(R)` | One structural slot array |
| Permission repartition | `O(R + C)` | Arrays only for groups whose split moves |
| Quark-to-Solid publication | `O(1)` per changed level | One slot write and, when required, outer write |
`Keyed.has` handles message and footer membership through the collection's
existing key map. A route-scoped `Set` contains every visible part ID, including
refs nested inside groups. Full rebuilds repopulate it and unique appends add to
it once. Duplicate streaming deltas therefore return before reading the lazy
aggregate or scanning rows. Unique appends still materialize the current row
snapshot and scan for the queued boundary; that lower-frequency path remains
linear deliberately rather than introducing a secondary-index layer.
existing key map. `Layout.collection` maintains every visible part ID,
including refs nested inside groups. Full replacement rebuilds the finite index;
inserts and arbitrary modifications derive membership automatically; the hot
group append applies one typed member delta. Duplicate streaming deltas return
before reading the lazy aggregate or scanning rows.
## Solid Owns the Adapter Lifecycle
@ -377,7 +397,7 @@ microbenchmark. Provider simulation, terminal polling, and process scheduling
also contribute to it. A performance decision requires repeated paired runs
and row-update or invalidation counters in addition to this behavioral check.
Three alternating smoke pairs completed successfully:
Seven baseline/fork behavior pairs completed successfully:
| Pair | Baseline | Quark fork | Fork / baseline |
| ---------------------: | --------: | ---------: | --------------: |
@ -386,7 +406,8 @@ Three alternating smoke pairs completed successfully:
| 3, fork first | 9,296 ms | 7,563 ms | 0.813x |
| 4, API assertions | 4,283 ms | 3,114 ms | 0.727x |
| 5, current `origin/v2` | 3,636 ms | 2,074 ms | 0.570x |
| 6, current Drive API | 2,233 ms | 1,413 ms | 0.633x |
| 6, current Drive API | 2,233 ms | 1,413 ms | 0.633x |
| 7, timeline extraction | 973 ms | 825 ms | 0.848x |
The range is too wide to support a speed claim. These runs establish that the
same streamed scenario renders and terminates on both implementations. They do
@ -394,22 +415,22 @@ not establish that either implementation is faster.
## Validation Status
| Check | Current result |
| ------------------------------------ | -------------------------------------------------------------------------- |
| TUI package typecheck | Pass |
| Full TUI test suite | 267 pass, 1 skip |
| Focused data and row tests | 48 pass |
| Vendored Quark tests | 13 pass |
| Shared drive scripts typecheck | Pass |
| Shared Drive behavior | Six baseline/fork pairs pass; latest pair verifies projected content |
| Publication-counter trace | Pass; exact slot/structure deltas recorded in performance model |
| Paired performance evidence | Pass for controlled workloads; end-to-end wall time remains non-conclusive |
| Check | Current result |
| ------------------------------ | -------------------------------------------------------------------------- |
| TUI package typecheck | Pass |
| Full TUI test suite | 274 pass, 1 skip |
| Focused data and row tests | 55 pass |
| Vendored Quark tests | 24 pass |
| Shared drive scripts typecheck | Pass |
| Shared Drive behavior | Seven baseline/fork pairs pass; latest pair verifies projected content |
| Publication-counter trace | Pass; exact slot/structure deltas recorded in performance model |
| Paired performance evidence | Pass for controlled workloads; end-to-end wall time remains non-conclusive |
## Collection Cache Growth Is Outside This Phase
## Reactive Query Caches Remain Outside This Phase
The standalone `effect-quark` prototype includes reactive missing-key and
secondary-index query caches without eviction. This branch vendors only Quark
state and the Solid adapter; it does not create those caches.
The timeline's imperative members index is finite: entries follow current rows
and are removed synchronously. It is not a reactive query cache and renderers
do not subscribe to it.
A later message-collection phase must not delete empty cache entries while a
live readable still references them. Safe eviction therefore needs explicit