docs: move experiment records to the quark repo

This commit is contained in:
Kit Langton 2026-07-17 23:40:51 -04:00
commit c377b73995
11 changed files with 876 additions and 1565 deletions

View file

@ -1,306 +0,0 @@
# 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.
## The Stack in One Picture
Four small layers, each with one job. The reusable core remains about 200 lines.
```mermaid
flowchart TD
E[Server events] --> D[DataProvider<br/>Solid Store, unchanged]
D --> R[createSessionRows<br/>row reduction policy]
R -->|"set / update / insert / remove"| K[Keyed&lt;SessionRow, string&gt;<br/>packages/quark/src/keyed.ts]
K -->|"slots — structure channel"| FOR["Solid &lt;For&gt;"]
K -->|"slot — per-row value channel"| UV[useValue adapter<br/>one Solid signal per row]
K -->|"slots + immutable identity"| BM[boundaries memo]
UV --> SRV[SessionRowView]
FOR --> SRV
```
```tree
packages/quark/src
├── reactivity.ts # 59 lines — Readable/Writable/Computed/Transaction over alien-signals
├── keyed.ts # Keyed collection (the whole idea lives here)
├── solid.ts # 8 lines — useValue: one quark Readable → one Solid signal
└── index.ts # 2 lines — exports
```
The experimental boundary is disciplined: `DataProvider`, the event protocol,
rendered row policy, and `SessionRowView` are unchanged. Row ownership and the
incremental mutation mechanics changed; batch and live grouping now share one
policy helper. The boundary remains narrow enough to attribute regressions or
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:
```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:
```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... */ )
function partRow(ref: PartRef): SessionRow {
return { id: `p${segment(ref.messageID)}${segment(ref.partID)}`, type: "part", ref }
}
function segment(value: string) {
return `${value.length}:${value}` // unambiguous without trusting a delimiter
}
function rowKey(row: SessionRow) {
return row.id
}
```
`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
A Solid Store exposes one reactive graph; every consumer subscribes into the same proxy web. `Keyed` splits change into two independent surfaces:
```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
has(key: Key): boolean
get(key: Key): Readable<A> | undefined
set(values: readonly A[]): void
update(value: A): boolean
insert(value: A, position?: "end" | { before: Key } | { after: Key }): Readable<A>
remove(key: Key): boolean
move(key: Key, position?: "end" | { before: Key } | { after: Key }): boolean
}
```
```definitions
[
{ "term": "slots", "definition": "The structure channel. An array of stable per-row readables. Its identity changes only when membership or order changes. <For> subscribes here." },
{ "term": "slot", "definition": "The value channel. One writable signal per row identity. A streaming delta touches exactly one slot. The row's component subscribes here via useValue." },
{ "term": "values", "definition": "The aggregate channel. A lazy computed mapping slots to current values. The TUI uses it only for internal mutation snapshots and does not subscribe boundaries to it." }
]
```
This separation is what a proxy-based store cannot give you for free: **a value change and a structure change are different events**, published to different audiences.
## How It CAN Be Faster: One Unique Row Update, Step by Step
The highest-frequency reducer workload is a repeated streaming ordinal, which
the seen-part set rejects in expected `O(1)` before reading the aggregate. The
next useful comparison is a unique event that extends or completes an existing
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))
}),
)
// ...and on reconnect / revert / rebuild:
setRows(reconcile(reduce()))
// reconcile must re-derive identity from item references,
// walk every row's shape, and diff against the proxy graph
```
Solid's generality — arbitrary nested access, partial path writes, plain objects — is paid for on **every operation** with proxy traps, shape inspection, and dependency bookkeeping.
### After: Quark path
The whole collection is a closure over three pieces of state — the structure signal, the identity map, and the declared equivalence. `update` reads directly against them:
```typescript title="packages/quark/src/keyed.ts" caption="The closure state update() operates on, plus the whole hot path"
export function make<A, Key>(options: {
readonly key: (value: A) => Key
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 values = Computed.make<readonly A[]>((previous) => {
const next = slots().map((slot) => slot()) // aggregate channel, lazy until subscribed
return same(previous, next) ? previous! : next
})
return {
slots,
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
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
return true
},
// ...
}
}
```
Note what `update` never touches: `slots`. A value change writes one slot signal and the structure channel stays reference-identical — that single fact is the structural cutoff below.
Four operations. No proxies, no shape discovery, no graph diff. Then the adapter forwards the change to exactly one Solid signal:
```typescript title="packages/quark/src/solid.ts" caption="The entire Solid boundary — 8 lines"
export function useValue<A>(readable: Readable<A>): Accessor<A> {
const [value, setValue] = createSignal(readable())
onCleanup(readable.subscribe((next) => setValue(() => next)))
return value
}
```
### The structural cutoff — why `<For>` never even wakes up
Because `slots` only fires on structural change, a value-only update leaves the outer array **reference-identical**. The `<For>` signal never fires, so keyed-list diffing never runs and the component owner survives:
```typescript caption="Value-only transition: structure untouched, owner preserved"
const structure = rows.slots()
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
```
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
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
### Wiring the renderer: one `<For>`, two subscription levels
Before, every row component read its row through the store proxy, and any reconcile could disturb the list:
```tsx title="packages/tui/src/routes/session/index.tsx" caption="Before — one reactive graph for everything"
<For each={rows}>
{(row, index) => (
<SessionRowView row={row} ... boundaryID={boundaries()[index()]} />
)}
</For>
```
After, `KeyedFor` subscribes to the structure channel and each row component subscribes to exactly one slot:
```tsx title="packages/tui/src/routes/session/index.tsx" caption="After — structure and value subscriptions are separate"
<KeyedFor each={rows.slots}>
{(row, index) => (
<SessionRowView row={row()} ... boundaryID={boundaries()[index()]} />
)}
</KeyedFor>
```
### Appending a footer row
```typescript caption="Before — mutate a proxied draft; Solid infers what changed"
setRows(
produce((draft) => {
if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return
const index = queuedStart(draft)
completePrevious(draft, index)
draft.splice(index, 0, { type: "assistant-footer", messageID })
}),
)
```
```typescript caption="After — say exactly what happened: maybe one value update, then one insert"
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
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.
### Removing a row
```typescript caption="Before — linear search, then splice through the proxy"
setRows(
produce((draft) => {
const index = draft.findIndex((row) => row.type === "assistant-footer" && row.messageID === messageID)
if (index !== -1) draft.splice(index, 1)
}),
)
```
```typescript caption="After — remove by identity; the ownership law does the rest"
mutate(() => {
state.remove(footerRowID(messageID))
})
```
### Full rebuild (reconnect, revert, compaction)
```typescript caption="Before — reconcile re-derives identity from scratch"
setRows(reconcile(reduce()))
```
```typescript caption="After — set() reconciles with declared identity and equivalence"
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_.
## 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 |
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.
## Architecture Verdict
### What's genuinely good
- **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.
- **Falsifiable framing.** The docs predict where Quark should lose. That's rare and worth preserving.
### Where the architecture leaked — all fixed during review
Every leak identified in the first pass has since been closed:
- ~~**`JSON.stringify` keys.**~~ Keys are precomputed `id` fields built once at row construction with length-prefixed segments (`rows.ts` smart constructors); `rowKey` is a field read.
- ~~**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.
- ~~**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.
The remaining asymmetry — `complete` (immutable `state.update`) vs `completePrevious` (in-place mutation on a plain array under construction) — is inherent to the two contexts, three lines each, and shares the same policy. Not worth abstracting.
None of these are structural flaws — they are integration debts. The layering itself (laws → Keyed → thin adapter → unchanged renderer) is the right shape, and it's the shape worth keeping even if every number in the current doc had to be re-measured.
See `quark-review-recommendations.md` for the prioritized fix list.

View file

@ -1,94 +0,0 @@
# Quark-Owned Message Content Slots
Status: implemented; drive parity pending
## Summary
Assistant message content currently lives as unkeyed arrays inside the
DataProvider Solid store. Streaming deltas mutate those arrays through
`produce`, and every consumer that resolves a part re-scans `content`
(`findLast`, `filter(type)[ordinal]`), so per-delta work scales with message
size instead of delta size.
This experiment changes only content ownership: each assistant message's parts
become one Quark keyed collection with stable per-part slots. Streaming events
already name the slot on the wire — `ordinal` for text and reasoning, `callID`
for tools — so deltas address slots directly instead of being rediscovered.
Message metadata (finish, usage, retry, error), the event protocol, timeline
rows, and view components' rendering logic remain unchanged.
The experiment succeeds only if all checks pass:
1. Existing TUI data and row tests preserve their behavior.
2. Per-delta work is O(1) in message size: one slot publication per delta,
zero structural publications, one markdown recompute scoped to the part.
3. The drive script passes unchanged.
## Ownership Boundary
```
server events
|
v
DataProvider
├── Solid store: session/message metadata (unchanged)
└── SessionContent: Keyed<Part> per assistant message <- this experiment
| |
| slots (structure) | slot (value channel)
v v
part row resolution TextPart / ReasoningPart / ToolPart
```
- Part identity: `text:{ordinal}` and `reasoning:{ordinal}` from event
ordinals, tool parts by `callID`. These match the synthetic part IDs the
timeline already uses, so `resolvePart`'s positional bridge disappears.
- Deltas are value-channel publications: `content.modify(key, ...)`. The
parts `<For>`/group structure never re-runs on a delta.
- Part starts are structural insertions; `ended` events publish the
authoritative final value into the same slot.
- Full sync (`message.sync`) seeds each message's collection with `set(parts)`
derived from the fetched payload, assigning ordinals per type in order.
- Revert, session removal, and model-switch refetch drop or reseed collections.
- Cold consumers (transcript export, copy-last-response, message navigation,
permission lookup) read `values()` — plain arrays, same shapes as before.
## Part Layout
Tool identity fields are immutable; streamed fields are mutable. The field
diff mask means a tool status change skips text-driven work and vice versa.
```ts
const PartLayout = Layout.keyedUnion({
key: Layout.key("partID", Layout.string),
tag: "type",
variants: {
text: Layout.struct({ text: Layout.string }),
reasoning: Layout.struct({ text: Layout.string, state: ..., time: ... }),
tool: Layout.struct({
id: Layout.immutable(Layout.string),
name: Layout.immutable(Layout.string),
state: ..., time: ..., executed: ..., providerState: ...,
}),
},
})
```
Store values are the client `SessionMessageAssistant*` content shapes plus the
synthetic `partID`; `values()` strips nothing because consumers already ignore
unknown fields.
## What Is Not In Scope
- Message metadata stays in the Solid store.
- No changes to server events or the client package types.
- No lazy or pull-based consumers; slots publish eagerly as today.
- Timeline row ownership is the previous experiment and is unchanged.
## Measurement
Instrument with `Keyed.Metrics`: a streaming test drives N deltas into a
message with M existing parts and asserts slot publications == N and
structural publications == parts started, independent of M. Markdown
recompute counts are asserted through the part component render counters in
the existing test harness.

View file

@ -1,532 +0,0 @@
# Why Quark Can Be Faster Than Solid Store
Status: experimental performance model
## Conclusion
Quark can be faster than the current Solid Store timeline because it accepts a
stronger contract and uses that contract to do less runtime work.
The advantage does **not** come from TypeScript types by themselves. TypeScript
types disappear at runtime. The useful information comes from explicit runtime
inputs and API laws:
- A row key is unique and cannot change during its ownership lifetime.
- Row equivalence is supplied before updates begin.
- A keyed list has one stable slot per key.
- Value changes and structural changes publish through separate channels.
- A transaction publishes only settled graph state.
Those guarantees remove choices from the hot path. Quark does not need to
rediscover identity, shape, equivalence, index membership, or ownership on
every update. Less discovery means fewer comparisons, proxy traps,
allocations, writes, and downstream invalidations.
This is a conditional claim, not a claim that Quark is universally faster than
Solid. Solid can be as fast or faster when an application performs a precise
direct store write, when lists are tiny, or when Quark receives no useful
identity information. The relevant comparison is the current OpenCode path:
whole projected records and ordered rows are reconciled into a Solid Store.
## The Physical Argument Is “Do Less Work”
Software cannot escape the physical costs of instructions, memory reads,
allocations, pointer writes, cache misses, and notification fan-out. An
abstraction can only become faster by reducing those costs or arranging them
more favorably.
Quark's proposed advantage has four concrete sources:
1. **Declare repeated decisions once.** Identity and equivalence become
preconfigured functions rather than per-event discovery.
2. **Replace searches with addresses.** Stable keys resolve to persistent
slots through a `Map`.
3. **Separate unrelated change dimensions.** An item value can change without
publishing a new list structure.
4. **Stop propagation early.** Schema equivalence and slot equivalence suppress
writes before they reach Solid or the terminal renderer.
These are ordinary computer costs, not framework mythology. If profiling shows
that Quark executes the same work as Solid plus an adapter, the hypothesis is
false. If counters show fewer structural publications, fewer component-owner
reconciliations, and fewer proxy/store operations, the speedup has a mechanical
explanation.
## Solid Store Must Preserve Generality
The current timeline asks Solid Store to accept ordinary JavaScript objects and
reconcile new projections into a proxy-backed graph.
```mermaid
flowchart LR
A[Projected rows] --> B[Solid reconcile]
B --> C[Inspect keys and shape]
C --> D[Proxy-backed Store writes]
D --> E[Dependency invalidation]
E --> F[For reconciliation]
F --> G[SessionRowView]
```
Solid's generality is valuable. It can react to arbitrary nested property
access, partial path writes, and plain objects without requiring a schema-owned
model. That generality also means the runtime must maintain proxy metadata and
interpret each reconciliation against the current store graph.
Solid is not inherently inefficient. A precise call such as
`setStore(index, "value", next)` can avoid whole-list reconciliation. OpenCode's
timeline frequently receives whole projected messages or rebuilt row arrays,
however, so its current implementation uses `produce` and `reconcile` to
recover identity and preserve nested owners.
## Quark Makes Identity an Input
`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
const PartRefLayout = Layout.struct({
messageID: Layout.string,
partID: Layout.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
avoid delimiter collisions without invoking a serializer:
```typescript
function segment(value: string) {
return `${value.length}:${value}`
}
function groupRow(kind: "reasoning" | "exploration", origin: PartRef): SessionRow {
const id = `g${kind === "reasoning" ? "r" : "e"}${segment(origin.messageID)}${segment(origin.partID)}`
if (kind === "reasoning") return { id, type: "group", kind, origin, refs: [origin], completed: false }
return { id, type: "group", kind, origin, refs: [origin], pending: [], completed: false }
}
```
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
const SessionRows = Layout.collection(
SessionRowLayout,
({ members }) => ({
parts: members(partIDs),
}),
{ backend: "generated" },
)
const rows = SessionRows.make()
```
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:
```typescript
rows.slots // changes only when keys are inserted, removed, or reordered
rows.values // changes when any current value changes
```
Each slot is itself a readable value. Solid `<For>` receives `slots`, so a
group can grow or complete without replacing its component owner. Generic
aggregate consumers can subscribe to `values` when they need every value
change.
The TUI's message-boundary projection deliberately does not subscribe to
`values`. Boundary identity uses only immutable row fields, so it tracks the
structural `slots` array and reads each slot value untracked. Value-only deltas
therefore perform no boundary work; inserts, removals, reorders, or message
changes recompute boundaries.
Here is a complete value-only transition. The group changes, but its key and
position do not:
```typescript
const origin = { messageID: "assistant-1", partID: "call-read" }
const initial = groupRow("exploration", origin)
rows.set([initial])
const structure = rows.slots()
const groupSlot = structure[0]
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
groupSlot().completed === true // true: row value updated
```
A structural insertion changes the outer list but preserves every retained
slot:
```typescript
const footer: SessionRow = {
id: "f11:assistant-1",
type: "assistant-footer",
messageID: "assistant-1",
}
const footerSlot = rows.insert(footer)
rows.slots().length === 2 // true
rows.slots()[0] === groupSlot // true
rows.slots()[1] === footerSlot // true
```
Removing and later reinserting a key begins a new ownership lifetime:
```typescript
rows.remove(footer.id)
const nextFooterSlot = rows.insert(footer)
nextFooterSlot === footerSlot // false
```
```mermaid
flowchart LR
A[Next rows] --> B[Keyed.set]
B --> C{Same keys and order?}
C -->|yes| D[Keep slots array]
C -->|no| E[Publish slots array]
B --> F{Equivalent value?}
F -->|yes| G[No slot write]
F -->|no| H[Publish one slot]
D --> I[Solid For unchanged]
E --> J[Solid For reconciles structure]
H --> K[Existing SessionRowView updates]
```
The key distinction is that **row identity is not inferred from row value**.
The timeline gives every reasoning or exploration group an immutable creation
key. Permission partitioning may move refs between `refs` and `pending`, but it
cannot change the group slot or reset local expanded state.
## The `Keyed.set` Algorithm
The current algorithm is deliberately small:
```text
set(next):
1. Compute every next key and reject duplicates.
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. 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. 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
computed node, so it remains lazy without subscribers and publishes one settled
array when subscribed.
The algorithm preflights duplicate keys before any slot mutation. A rejected
set therefore cannot partially update the graph. `Map` and `Set` give keys
SameValueZero semantics, including consistent treatment of `0`/`-0` and
`NaN`.
## The Laws That Unlock the Optimizations
```definitions
[
{
"term": "Unique key law",
"definition": "No two current values have the same key. This permits one Map entry and one slot per identity."
},
{
"term": "Stable key law",
"definition": "An entity or logical row keeps its key for its lifetime. This permits component ownership and subscriptions to survive value changes."
},
{
"term": "Equivalence law",
"definition": "If equivalent(left, right) is true, publishing right cannot change any consumer-visible meaning. This permits early cutoff."
},
{
"term": "Structural cutoff law",
"definition": "Value-only updates preserve the exact outer slots-array identity. This prevents keyed-list reconciliation for non-structural changes."
},
{
"term": "Settled publication law",
"definition": "Subscribers observe the state after every slot and structural write in the operation, never a partial combination."
},
{
"term": "Ownership law",
"definition": "Removing a key removes its slot from the collection. Reinserting that key creates a fresh slot; old external references cannot attach to a new lifetime."
}
]
```
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.
## Layout Compiles Trusted Collection Metadata
The timeline now uses Quark's trusted in-memory `Layout`. It supports the scope
required by this experiment:
- 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.
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.
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.
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
For a whole next array of `N` keyed values, both Quark `Keyed.set` and a general
keyed reconciliation are `O(N)`. Quark does not break a lower bound: it must
read the next keys to validate and order them.
The observed win is currently a **constant-factor win at the same asymptotic
complexity**:
- one key extraction per next item; previous slots are already indexed;
- one `Map` lookup per next item;
- one explicit equivalence check;
- one slot write per changed value;
- one outer write only for changed structure;
- no general nested proxy reconciliation inside Quark.
For direct collection operations, stronger bounds are possible. An immutable
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
The benchmarks are checked into the fork and run directly:
```bash
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
after a discarded warmup. It reports median nanoseconds per operation, median
absolute deviation, machine-readable metrics, and a checksum.
The original whole-array benchmark established the initial direction:
| Workload | Run | Quark | Solid Store | Quark / Solid |
| -------------------------------------------- | --: | -------: | ----------: | ------------: |
| One value changes in a 1,000-item next array | 1 | 177.2 us | 863.7 us | 0.207x |
| One value changes in a 1,000-item next array | 2 | 158.8 us | 755.6 us | 0.213x |
| Reorder a 1,000-item list | 1 | 155.3 us | 1,020.8 us | 0.157x |
| Reorder a 1,000-item list | 2 | 147.1 us | 975.6 us | 0.152x |
Across these two invocations:
- keyed value publication was approximately **4.7x-4.8x faster**;
- keyed reorder was approximately **6.4x-6.6x faster**.
The checksum was stable, and both variants performed the same next-array
construction inside their timed workloads.
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 |
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`
in the same row order. The direct-path result reproduced exactly to three
decimal places; the other workloads retained the same direction and broad
magnitude despite normal timing variance.
The aggregate path is still `O(N)`, but it remained substantially cheaper than
the equivalent Solid Store projection. The precise direct-path case was added
specifically to favor Solid: it calls `setValues(index, "value", next)` while
Quark still constructs and compares a replacement object. The predicted Solid
win did not occur in this environment; Quark measured `0.170x`. This is a
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 |
| --------------------------------------- | ----------: | -------------------: |
| `JSON.stringify` tuple | 109.6 ns/op | 1.000x |
| Collision-safe length-prefixed string | 25.6 ns/op | 0.205x |
| Precomputed primitive row ID field read | 8.7 ns/op | 0.075x |
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, 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;
- all list sizes and mutation distributions;
- a complete OpenCode session under real provider and tool traffic.
The end-to-end `opencode-drive` runs establish behavioral parity, including
visible terminal completion and projected reasoning/text content. Their timing
varied too widely to support a speed claim because it includes provider
simulation, process scheduling, event transport, terminal rendering, and UI
polling.
## When Quark Should Lose
The design predicts smaller or negative gains when:
- lists are so small that setup and adapter costs dominate;
- keys are unstable or expensive to compute;
- equivalence is more expensive than simply publishing;
- almost every item and the structure change on every operation;
- there are no subscribers, so reactive precision has no downstream value;
- Solid receives a cheaper primitive update while Quark must perform a more
expensive projection or whole-array reconciliation;
- copying the next application-level projection dominates both runtimes;
- the Quark-to-Solid adapter duplicates work rather than cutting it off.
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.
## Deterministic Tests Measure Work, Not Just Time
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 through compiled membership index |
| Full unchanged two-row rebuild | +0 | +0 | Two equivalence suppressions, no publication |
`Keyed` exposes optional counters for slot publications, structural
publications, and equivalence suppressions. The focused TUI trace
`completes exploration when a queued prompt is promoted` records the first five
transitions and asserts the same slot identity across extension, repartition,
and completion. `does not publish timeline rows for duplicate streaming
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
the following:
1. Behavior and component ownership remain equivalent to the Solid baseline.
2. Value-only events publish no structural changes.
3. CPU or allocation cost improves on the real event distribution.
4. The Solid adapter remains a thin boundary rather than a second reactive
system doing duplicate work.
5. The reusable `Keyed` and layout laws contain the complexity; TUI code
does not grow its own reconciliation engine.
If those conditions fail, the standalone microbenchmarks are not sufficient
reason to replace Solid Store. If they hold, the speedup is not mysterious:
Quark is faster because OpenCode supplied stronger information and Quark used
that information to execute less work.
The current evidence satisfies ownership, structural cutoff, adapter
thinness, and containment in `Keyed`. Controlled workloads and the
deterministic TUI trace show lower CPU work for the exercised event shapes.
That is enough to continue and land the timeline experiment, not to claim a
universal framework win. Real-session soak remains the check on whether the
measured event distribution matches production usage.

View file

@ -1,137 +0,0 @@
# Quark Timeline Experiment: Review Recommendations
Status: point-in-time review feedback; accepted performance and correctness findings implemented
Reviewed: `docs/design/quark-performance-model.md`, `docs/design/quark-tui-timeline.md`, `packages/quark/src/*`, `packages/tui/src/routes/session/rows.ts`, `packages/tui/src/routes/session/index.tsx`
Goal: really fast and really beautiful. These are ordered recommendations for the next agent. Each item is independently actionable; the order reflects priority.
This document preserves the audit as written. The current implementation has
checked benchmarks and adverse workloads, structural-only boundaries,
precomputed IDs, shared grouping policy, filtered permission repartition,
optional publication counters, deterministic counter tests, and an `O(1)`
seen-part duplicate path. Current evidence and commands live in
`quark-performance-model.md` and `quark-tui-timeline.md`; claims below describe
the pre-fix state. Optional API polish and broader model/index/layout work are
deferred to avoid expanding the timeline landing.
## 1. Check in the microbenchmark
`quark-performance-model.md` cites concrete numbers (177.2µs vs 863.7µs, etc.) but the benchmark source is not in the tree. Unreproducible numbers are the weakest part of an otherwise careful doc.
- Add the benchmark under `packages/quark/bench/keyed.bench.ts` (or similar).
- Add the exact repro command to the "Controlled Benchmark Evidence" section of `quark-performance-model.md`.
- Add the adverse workloads promised in "When Quark Should Lose" (tiny lists, unstable keys, dense mutation, no subscribers, direct `setStore(index, "field", value)` on the Solid side). That section is currently a promise, not a suite.
## 2. Benchmark the actual hot path: incremental `update` with subscribed `values`
The checked-in benchmark story compares `Keyed.set(nextArray)` vs Solid `reconcile(nextArray)`. But after the migration, the streaming hot path is **incremental**: `state.update({...previous, refs: [...refs, ref]})` per delta (`packages/tui/src/routes/session/rows.ts`). Whole-array `set` only runs on reconnect/revert/compaction.
Add a workload: one `update` on a group row, with `values` subscribed, at 100 / 1,000 / 10,000 rows. This measures what the TUI actually does per streaming delta.
## 3. Fix the hidden O(N) per value change through `values` + `boundaries`
The performance-model doc presents `values` as "lazy without subscribers," but in the real integration it is **always subscribed**:
```ts
// packages/tui/src/routes/session/index.tsx (~line 190)
const boundaries = createMemo(() => messageBoundaryIDs([...rows.values()], messages()))
```
So every value-only change that survives `sameRow` pays:
- O(N) recompute of the `values` computed (N slot reads + dependency tracking, `packages/quark/src/keyed.ts:22-25`)
- O(N) `same` compare
- O(N) array spread in the memo
- a full `messageBoundaryIDs` recompute
The structural cutoff for `<For>` is real, but this is the remaining per-delta O(N) path. Options, in increasing ambition:
1. Change `messageBoundaryIDs` to accept `readonly SessionRow[]` so the spread copy dies (trivial).
2. Derive boundaries from `slots` plus per-slot reads so a group value change cannot invalidate them.
3. Maintain boundary info incrementally inside the same mutate operations that change structure.
At minimum, the doc should state the real cost with a measured number instead of implying laziness the integration doesn't have.
## 4. Replace `JSON.stringify` row keys with cheap concatenation
`rowKey` calls `JSON.stringify` per row per `set` (`packages/tui/src/routes/session/rows.ts:439-445`). On a 10,000-row `set` that's 10,000 serializer calls plus allocations, working directly against the "compile repeated decisions once" thesis.
Use a delimiter that cannot appear in IDs:
```ts
function rowKey(row: SessionRow) {
if (row.type === "message") return `message\x00${row.messageID}`
if (row.type === "compaction-queued") return `compaction-queued\x00${row.inputID}`
if (row.type === "part") return `part\x00${row.ref.messageID}\x00${row.ref.partID}`
if (row.type === "assistant-footer") return `assistant-footer\x00${row.messageID}`
return `group\x00${row.kind}\x00${row.key.messageID}\x00${row.key.partID}`
}
```
Re-measure after; expect a meaningful constant-factor win on key extraction. Update the code sample in `quark-performance-model.md` to match.
## 5. Unify the two grouping-policy engines
There are now two implementations of the row grouping rules:
- The batch rebuild path: `reduce()` builds a plain array via the old `append` / `completePrevious` helpers (`rows.ts:385+`), then calls `state.set`.
- The incremental path: `appendPart` / `appendMessage` / `complete` closures reimplement the same join/complete decisions inline (`rows.ts:147-215`).
The performance-model doc's own decision rule #5 says "TUI code does not grow its own reconciliation engine" — this duplication is exactly that risk. Extract the shared decisions as pure functions, e.g. `joinsPreviousGroup(previous, part)` and `newRowFor(part, ref)`, and have both paths consume them. Do not lose the incremental path's precision; only deduplicate the policy.
## 6. Filter before cloning in permission repartition
The pending-permissions effect clones **every** exploration group on every change before `sameRow` suppresses the no-ops (`rows.ts:83-91`):
```ts
state.values().forEach((row) => {
if (row.type !== "group" || row.kind !== "exploration") return
const next = { ...row, refs: [...row.refs], pending: [...row.pending] }
partitionPending([next], pending)
state.update(next)
})
```
Filter to groups whose refs actually intersect the pending set before cloning. The clone-then-compare pattern spends the allocations the equivalence law was supposed to save.
## 7. Add work counters to `Keyed`
The strongest section of the performance model is "The Next Tests Must Measure Work, Not Just Time" — but `Keyed` has no instrumentation, so none of that table can be produced today.
Add optional counters behind a debug flag in `packages/quark/src/keyed.ts`:
- slot publications (value writes)
- structural publications (outer `slots` writes)
- equivalence suppressions (writes avoided by `equivalent`)
Then run the deterministic trace via `script/quark-timeline-drive.ts` and fill in the expected-behavior table with real counts. This converts the decision rule from vibes to numbers.
## 8. Demonstrate the "beautiful" claim with a recording
The user-visible payoff — stable component ownership means no flicker, no reset of expanded reasoning groups, no scroll jumps during permission repartition — is asserted but never demonstrated.
Capture a terminal-control recording of an expanded group surviving a permission repartition on this branch vs untouched `origin/v2`. That artifact is more persuasive than any benchmark table and belongs alongside it in the doc.
## 9. Small API and code polish
- ~~`insert(value, { before? })` where `before: undefined` means append reads poorly at call sites.~~ Fixed with the shared `Position<Key>` vocabulary: `"end"`, `{ before: key }`, or `{ after: key }`.
- The duplicate-key error in `Keyed.set` should name the offending key (`packages/quark/src/keyed.ts:32`).
- `update` mixes contracts: throws on missing key, returns `false` on no-op. That's defensible (missing key is a programmer error) but should be documented as a law.
- Doc correction in `quark-performance-model.md`, "Asymptotics and Constants": it claims "one key extraction per previous and next item," but the implementation only extracts **next** keys; previous slots are reached through the `byKey` map (`keyed.ts:30-52`). Fix the claim.
## What is already good (do not churn)
- The two-channel `slots` / `values` surface plus verbs is a genuinely clean API. Keep it.
- The laws section and the honest "When Quark Should Lose" / "What the Benchmark Does Not Prove" framing are correct and rare. Preserve that tone.
- The experimental boundary (only the row owner changes; `DataProvider`, reduction rules, and `SessionRowView` untouched) is right. Do not widen it while acting on these items.
- Duplicate-key preflight before any mutation (no partial updates) is correct; keep it.
## Suggested execution order
1. Check in benchmark + adverse workloads (items 1, 2)
2. Cheap key extraction, re-measure (item 4)
3. Unify grouping engines (item 5)
4. Counters + deterministic drive trace (item 7)
5. Decide the `values`/boundaries O(N) question with data (item 3)
6. Repartition filtering (item 6)
7. Recording artifact + doc corrections + polish (items 8, 9)

View file

@ -1,447 +0,0 @@
# Quark-Owned TUI Timeline Rows
Status: experiment in progress
## Summary
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. 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:
1. Existing TUI timeline tests preserve their behavior.
2. One `opencode-drive` script passes unchanged against untouched `origin/v2`
and this branch.
3. The Quark path improves measured timeline work without increasing visible
latency or invalidation counts.
## The Row Owner Is the Experimental Boundary
`DataProvider` receives server events and projects them into its existing Solid
Store. `createSessionRows` reads that projection, subscribes to timeline events,
and maintains the ordered rows consumed by `SessionRowView`.
Before this branch, `createSessionRows` also used a Solid Store for its row
state:
```text
server events
|
v
DataProvider Solid Store
|
v
createSessionRows
|
v
SessionRow[] Solid Store
|
v
<For> -> SessionRowView
```
This branch replaces only the second store:
```text
server events
|
v
DataProvider Solid Store
|
v
createSessionRows adapter
|
v
SessionTimeline
|
v
Layout.collection<SessionRow>
|
v
KeyedFor outer + slot adapters
|
v
SessionRowView
```
The narrow boundary answers one question: does Quark improve the TUI timeline
when it owns the ordered render rows? Moving message storage at the same time
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` | 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
`State.make(initial)` wraps an `alien-signals` signal with three operations:
```ts
read() // track and return the current value
read.set(next) // publish one replacement value
read.update(f) // derive and publish from the current value
```
`subscribe` creates an `alien-signals` effect. The effect reads the signal once
to establish its dependency without calling the listener, because `useValue`
already reads the initial value. Later signal writes rerun the effect
synchronously and call the listener once with the published value.
Before invoking the listener, `subscribe` clears alien-signals' active
subscriber and restores it afterward. Reads performed by the listener or by a
synchronous Solid update therefore cannot become accidental dependencies of
the Quark subscription.
Transactions call `startBatch()` and `endBatch()`. Row reconciliation can
update several slots plus the outer slot array, so the row owner uses one
transaction for the complete publication.
## `useValue` Crosses the Reactive Boundary Once
`useValue(readable)` creates one Solid signal initialized from `readable()`. It
then subscribes to Quark and forwards each published value into that Solid
signal. The timeline uses this algorithm for the structural slot list and each
mounted row slot. Its aggregate `values` readable remains inside Quark and is
not mirrored into Solid:
```text
outer Quark State.set(nextSlots)
|
v
alien-signals subscription
|
v
Solid setSignal(nextSlots)
|
v
rowSlots() -> <For>
|
v
slot Quark State.set(nextRow)
|
v
row() -> SessionRowView
```
The adapter passes `() => nextRows` to Solid's setter. Solid treats a bare
function as an updater, so the wrapper is required when the reactive value
itself could be a function. The same generic adapter therefore works for both
arrays and callable values.
## Full Rebuilds Use a Deterministic Reducer
Connection, synchronization, revert, pending-compaction, and message-boundary
changes rebuild rows from the current `DataProvider` projection. The rebuild
algorithm is:
```text
messages = loaded messages before the revert boundary
inputs = admitted input message IDs
pending = running compaction IDs + input IDs
ordered messages =
completed messages
+ running compactions
+ admitted inputs
for each ordered message:
non-assistant -> append one message row
assistant -> append/group each non-empty content part
finished assistant -> append one footer row
partition exploration refs by pending permission call IDs
insert queued compactions before the first pending input row
```
The first partition puts completed transcript rows before work that has not
crossed its safe execution boundary. A non-pending message completes the group
immediately before it. This prevents reasoning or exploration groups from
visually spanning separate transcript messages.
Assistant text and reasoning parts use their ordinal among parts of the same
type as their stable part ID, such as `text:0` or `reasoning:1`. Tool parts use
their call ID. Empty text and reasoning parts do not create rows.
## Incremental Events Avoid Full Reduction
Most live events update the current row array directly:
| Event shape | Row operation |
| --------------------------------------------------------------- | --------------------------------------------------------------- |
| New user, synthetic, shell, model, agent, or compaction message | Insert a message row unless its ID already exists. |
| First non-empty text or reasoning delta | Insert or extend the corresponding part/group row. |
| Tool input started | Insert a tool row or extend an exploration group. |
| Step ended, failed, or retry scheduled | Insert an assistant footer. |
| Step started | Remove the previous retry footer. |
| Permission changed | Repartition exploration refs between visible and pending lists. |
`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:
```text
reasoning part:
extend the immediately preceding reasoning group
or complete the preceding group and create a reasoning group
read/glob/grep tool:
extend the immediately preceding exploration group
or complete the preceding group and create an exploration group
other text or tool part:
complete the preceding group and insert a standalone part row
```
The grouping decision depends on the tool name, not the call ID. This matters
because call IDs can look like generated text or reasoning IDs.
## Incremental Mutations Touch Only Affected Slots
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 calls domain operations backed by the compiled
collection:
```text
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
and writes its existing slot. It does not copy unrelated groups or publish
structure. Permission repartitioning first checks whether a group's visible or
pending membership would actually change, then allocates arrays only for those
groups.
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
A full rebuild creates fresh row values, but Solid `<For>` uses item identity
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 through its persistent key-to-slot
map:
```text
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. `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}` |
| Group | `g{kind}{origin message segment}{origin part segment}` |
Every group records the ref that created it as immutable `origin` metadata.
Appending refs, completing the group, and moving refs between permission
partitions cannot change its ID or boundary identity. Group equivalence compares
`completed`, every `refs` entry, and every exploration `pending` entry.
`Keyed` publishes two readables. `slots` changes only when key membership or
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 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
surrounding Solid batch holds adapter writes until every Quark listener has
flushed. Solid therefore observes one settled state rather than a row value
from one ordering and an outer slot array from another.
## Complexity Is Linear in Rows and Contained Refs
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 |
| 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. `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
`createSessionRows` runs inside the Solid owner for the session route.
`useValue` reads the initial Quark value, subscribes once, and registers the
unsubscribe function with Solid's `onCleanup`.
The existing event-bus subscriptions use the same owner cleanup. Leaving the
route therefore removes both the Quark-to-Solid subscription and all timeline
event subscriptions. Quark state does not outlive the route.
## Behavior Must Remain Equivalent
The experiment preserves these existing timeline rules:
- User, synthetic, compaction, shell, model, and agent messages retain their
current ordering.
- Pending inputs and queued compactions remain after active output.
- Reasoning and exploration parts retain their grouping behavior.
- Exploration tool calls move between pending and completed partitions when
permissions change.
- Retry footers appear and disappear on the same events.
- Duplicate message, part, and footer events remain idempotent.
- Revert boundaries and message synchronization still rebuild the complete row
list from `DataProvider`.
- `SessionRowView` continues to resolve message content from `DataProvider`;
Quark owns row structure, not message content.
Changing `createSessionRows` from an array-like Solid Store to structural
`slots` plus an internal lazy aggregate is an internal API change. The session
route and its data-test probes are the only consumers on this branch.
## The Comparison Uses One Drive Script
The baseline is a detached worktree at
`/Users/kit/code/open-source/opencode-quark-baseline`, pinned to the same
`origin/v2` commit as the fork. The fork is
`/Users/kit/code/open-source/opencode-quark-timeline`.
`script/quark-timeline-drive.ts` creates one isolated project, submits one
prompt, streams one long reasoning span and one long text span as many delta
events, and waits for the final `QUARK_TIMELINE_COMPLETE` marker. The script
prints elapsed wall time and uses marker visibility as its rendered correctness
assertion. This workload stresses duplicate-part handling after the first
delta; it does not create 120 distinct timeline rows.
After the marker renders, the script queries the same isolated service and
asserts that the projected assistant message contains the complete reasoning
span, complete text span, and `stop` finish. The API assertion covers content
that has scrolled outside the terminal viewport.
Run the same checked script against each worktree:
```sh
cd /Users/kit/code/open-source/opencode-drive
packages/drive/bin/opencode-drive check \
/Users/kit/code/open-source/opencode-quark-timeline/script/quark-timeline-drive.ts
packages/drive/bin/opencode-drive start \
--name quark-timeline-baseline \
--script /Users/kit/code/open-source/opencode-quark-timeline/script/quark-timeline-drive.ts \
--dev /Users/kit/code/open-source/opencode-quark-baseline
packages/drive/bin/opencode-drive start \
--name quark-timeline-fork \
--script /Users/kit/code/open-source/opencode-quark-timeline/script/quark-timeline-drive.ts \
--dev /Users/kit/code/open-source/opencode-quark-timeline
```
Wall time from one end-to-end drive run is supporting evidence, not a stable
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.
Seven baseline/fork behavior pairs completed successfully:
| Pair | Baseline | Quark fork | Fork / baseline |
| ---------------------: | --------: | ---------: | --------------: |
| 1 | 11,505 ms | 6,455 ms | 0.561x |
| 2 | 4,003 ms | 4,733 ms | 1.182x |
| 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 |
| 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
not establish that either implementation is faster.
## Validation Status
| 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 |
## Reactive Query Caches Remain Outside This Phase
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
collection ownership or subscription reference counting. That design is not a
prerequisite for the route-scoped row-state experiment.
## Rollback Is One Seam
Rollback removes the private `effect-quark` workspace package and restores the
Solid Store in `createSessionRows`. No protocol, persisted data, public client
API, or server projection changes need migration.
The experiment should not expand into `DataProvider` message storage until the
current boundary has behavior parity and repeatable performance evidence.

View file

@ -0,0 +1,33 @@
# Third-Party Notices
## alien-signals
The internal reactive kernel in `src/reactivity.ts` adapts the dependency
graph algorithm of [alien-signals](https://github.com/stackblitz/alien-signals)
3.2.1: the intrusive doubly-linked dependency and subscriber links with
versioned in-place reuse, and the iterative `propagate` and `checkDirty`
graph walks. alien-signals is not a runtime dependency of Quark.
```
MIT License
Copyright (c) 2024-present Johnson Chu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```

View file

@ -14,7 +14,6 @@
"test": "bun --conditions=browser test"
},
"dependencies": {
"alien-signals": "3.2.1",
"solid-js": "catalog:"
}
}

View file

@ -133,7 +133,7 @@ export namespace Keyed {
function publish(slot: Writable<A>, value: A) {
const current = slot()
// alien-signals uses SameValueZero-compatible identity for primitive writes.
// The reactive kernel uses strict identity for primitive writes.
if (current === value || equivalent(current, value)) {
if (options.metrics) options.metrics.equivalenceSuppressions++
return false

View file

@ -1,5 +1,3 @@
import { computed, effect, endBatch, setActiveSub, signal, startBatch } from "alien-signals"
export interface Readable<A> {
(): A
subscribe(listener: (value: A) => void): () => void
@ -10,57 +8,466 @@ export interface Writable<A> extends Readable<A> {
update(f: (value: A) => A): void
}
function subscribe<A>(read: () => A, listener: (value: A) => void) {
let initialized = false
return effect(() => {
const value = read()
if (!initialized) {
initialized = true
return
}
const active = setActiveSub()
try {
listener(value)
} finally {
setActiveSub(active)
}
})
}
export namespace State {
export function make<A>(initial: A): Writable<A> {
const state = signal(initial)
const read = (() => state()) as Writable<A>
read.set = (value) => state(value)
read.update = (f) => {
// Read untracked: calling update inside an effect must not make the
// effect depend on (and re-trigger from) this signal.
const active = setActiveSub()
const current = state()
setActiveSub(active)
state(f(current))
const node: StateNode<A> = {
flags: Flags.Mutable,
value: initial,
pending: initial,
deps: undefined,
depsTail: undefined,
subs: undefined,
subsTail: undefined,
}
read.subscribe = (listener) => subscribe(read, listener)
const read = (() => readState(node)) as Writable<A>
read.set = (value) => writeState(node, value)
read.update = (f) => {
// Read untracked: calling update inside a tracked evaluation must not
// make the caller depend on (and re-trigger from) this state.
const previous = swapActiveSub(undefined)
try {
writeState(node, f(readState(node)))
} finally {
activeSub = previous
}
}
read.subscribe = (listener) => subscribeNode(node, read, listener)
return read
}
}
export namespace Computed {
export function make<A>(evaluate: (previous: A | undefined) => A): Readable<A> {
const value = computed(evaluate)
const read = (() => value()) as Readable<A>
read.subscribe = (listener) => subscribe(read, listener)
const node: ComputedNode<A> = {
flags: Flags.None,
value: undefined,
evaluate,
deps: undefined,
depsTail: undefined,
subs: undefined,
subsTail: undefined,
}
const read = (() => readComputed(node)) as Readable<A>
read.subscribe = (listener) => subscribeNode(node, read, listener)
return read
}
}
export namespace Transaction {
export function run<A>(f: () => A): A {
startBatch()
batchDepth++
try {
return f()
} finally {
endBatch()
if (!--batchDepth) flush()
}
}
}
// Internal reactive kernel.
//
// The dependency graph representation (intrusive doubly-linked dependency and
// subscriber links with versioned in-place reuse) and the iterative
// `propagate`/`checkDirty` walks are adapted from alien-signals 3.2.1
// (https://github.com/stackblitz/alien-signals, MIT). See
// THIRD_PARTY_NOTICES.md. Quark departs from the reference in three ways:
// subscribers are fixed single-dependency watchers instead of general
// effects, orphaned computeds are detached with an iterative work queue
// instead of recursive unwatch callbacks, and reading a computed that is
// currently evaluating throws a stable cycle error instead of looping
// (stackblitz/alien-signals#118, #123).
const enum Flags {
None = 0,
/** The node can produce a new value: states always, computeds once evaluated. */
Mutable = 1,
/** The node is a subscription watcher delivering values to a listener. */
Watching = 2,
/** The computed is currently evaluating; reading it again is a cycle. */
Computing = 4,
/** The watcher is queued for the next flush. */
Queued = 8,
/** The node's value is known stale. */
Dirty = 16,
/** The node's value is possibly stale pending dependency revalidation. */
Pending = 32,
}
interface ReactiveNode {
flags: Flags
deps?: Link
depsTail?: Link
subs?: Link
subsTail?: Link
}
interface StateNode<A = unknown> extends ReactiveNode {
value: A
pending: A
}
interface ComputedNode<A = unknown> extends ReactiveNode {
value: A | undefined
evaluate: (previous: A | undefined) => A
}
interface WatcherNode extends ReactiveNode {
read: () => unknown
listener: (value: unknown) => void
}
interface Link {
version: number
dep: ReactiveNode
sub: ReactiveNode
prevSub: Link | undefined
nextSub: Link | undefined
prevDep: Link | undefined
nextDep: Link | undefined
}
interface Stack<A> {
value: A
prev: Stack<A> | undefined
}
let activeSub: ReactiveNode | undefined
let batchDepth = 0
let version = 0
let flushIndex = 0
let queueLength = 0
const queue: Array<WatcherNode | undefined> = []
const orphans: Array<ReactiveNode> = []
function swapActiveSub(sub: ReactiveNode | undefined): ReactiveNode | undefined {
const previous = activeSub
activeSub = sub
return previous
}
function readState<A>(node: StateNode<A>): A {
if (node.flags & Flags.Dirty && updateState(node) && node.subs !== undefined) {
shallowPropagate(node.subs)
}
if (activeSub !== undefined) link(node, activeSub, version)
return node.value
}
function writeState<A>(node: StateNode<A>, value: A): void {
if (node.pending === (node.pending = value)) return
node.flags = Flags.Mutable | Flags.Dirty
if (node.subs !== undefined) {
propagate(node.subs)
if (!batchDepth) flush()
}
}
function readComputed<A>(node: ComputedNode<A>): A {
const flags = node.flags
if (flags & Flags.Computing) {
throw new Error("Reactive cycle detected: a computed depends on its own value")
}
if (
flags & Flags.Dirty ||
(flags & Flags.Pending &&
(checkDirty(node.deps!, node) || ((node.flags = flags & ~Flags.Pending), false))) ||
!flags
) {
if (updateComputed(node) && node.subs !== undefined) {
shallowPropagate(node.subs)
}
}
if (activeSub !== undefined) link(node, activeSub, version)
return node.value!
}
function updateState(node: StateNode): boolean {
node.flags = Flags.Mutable
return node.value !== (node.value = node.pending)
}
function updateComputed<A>(node: ComputedNode<A>): boolean {
node.depsTail = undefined
node.flags = Flags.Mutable | Flags.Computing
const previous = swapActiveSub(node)
version++
let completed = false
try {
const oldValue = node.value
const changed = oldValue !== (node.value = node.evaluate(oldValue))
completed = true
return changed
} finally {
activeSub = previous
node.flags &= ~Flags.Computing
// A throwing evaluation stays dirty so the next read retries instead of
// serving a stale value with no dependency links.
if (!completed) node.flags |= Flags.Dirty
purgeDeps(node)
}
}
function subscribeNode<A>(node: ReactiveNode, read: () => A, listener: (value: A) => void): () => void {
// Evaluate untracked before linking so a throwing computed leaves no
// partially initialized watcher behind.
const previous = swapActiveSub(undefined)
try {
read()
} finally {
activeSub = previous
}
const watcher: WatcherNode = {
flags: Flags.Watching,
read,
listener: listener as (value: unknown) => void,
deps: undefined,
depsTail: undefined,
}
link(node, watcher, ++version)
return () => {
if (!(watcher.flags & Flags.Watching)) return
watcher.flags &= ~(Flags.Watching | Flags.Dirty | Flags.Pending)
unlink(watcher.deps!, watcher)
drainOrphans()
}
}
function runWatcher(watcher: WatcherNode): void {
const flags = watcher.flags
watcher.flags = flags & ~(Flags.Queued | Flags.Dirty | Flags.Pending)
if (!(flags & Flags.Watching)) return
const dirty =
!!(flags & Flags.Dirty) || (!!(flags & Flags.Pending) && checkDirty(watcher.deps!, watcher))
// Revalidation runs user code in computed evaluations, which may dispose
// this watcher; a disposed watcher must not deliver.
if (!dirty || !(watcher.flags & Flags.Watching)) return
// Listener reads stay untracked and listeners may dispose subscriptions,
// including their own.
const previous = swapActiveSub(undefined)
try {
watcher.listener(watcher.read())
} finally {
activeSub = previous
}
}
function flush(): void {
try {
while (flushIndex < queueLength) {
const watcher = queue[flushIndex]!
queue[flushIndex++] = undefined
runWatcher(watcher)
}
} finally {
// A throwing listener aborts this flush; the remaining watchers keep
// their Dirty/Pending flags and requeue on the next propagation.
while (flushIndex < queueLength) {
const watcher = queue[flushIndex]!
queue[flushIndex++] = undefined
watcher.flags &= ~Flags.Queued
}
flushIndex = 0
queueLength = 0
}
}
function enqueue(watcher: WatcherNode): void {
watcher.flags |= Flags.Queued
queue[queueLength++] = watcher
}
function link(dep: ReactiveNode, sub: ReactiveNode, linkVersion: number): void {
const prevDep = sub.depsTail
if (prevDep !== undefined && prevDep.dep === dep) return
const nextDep = prevDep !== undefined ? prevDep.nextDep : sub.deps
if (nextDep !== undefined && nextDep.dep === dep) {
nextDep.version = linkVersion
sub.depsTail = nextDep
return
}
const prevSub = dep.subsTail
if (prevSub !== undefined && prevSub.version === linkVersion && prevSub.sub === sub) return
const newLink: Link = {
version: linkVersion,
dep,
sub,
prevDep,
nextDep,
prevSub,
nextSub: undefined,
}
sub.depsTail = newLink
dep.subsTail = newLink
if (nextDep !== undefined) nextDep.prevDep = newLink
if (prevDep !== undefined) prevDep.nextDep = newLink
else sub.deps = newLink
if (prevSub !== undefined) prevSub.nextSub = newLink
else dep.subs = newLink
}
function unlink(current: Link, sub: ReactiveNode): Link | undefined {
const dep = current.dep
const prevDep = current.prevDep
const nextDep = current.nextDep
const nextSub = current.nextSub
const prevSub = current.prevSub
if (nextDep !== undefined) nextDep.prevDep = prevDep
else sub.depsTail = prevDep
if (prevDep !== undefined) prevDep.nextDep = nextDep
else sub.deps = nextDep
if (nextSub !== undefined) nextSub.prevSub = prevSub
else dep.subsTail = prevSub
if (prevSub !== undefined) prevSub.nextSub = nextSub
else if ((dep.subs = nextSub) === undefined && dep.deps !== undefined) orphans.push(dep)
return nextDep
}
// Detaches computeds that lost their last subscriber. Iterative on purpose:
// a recursive unwatch cascade overflows the stack on deep chains. Only nodes
// with dependencies enter the queue (states and dep-less computeds have
// nothing to detach). As in the reference, a computed that is read but never
// subscribed stays linked to its sources until they are collected; create
// long-lived computeds rather than per-operation ones.
function drainOrphans(): void {
while (orphans.length > 0) {
const node = orphans.pop()!
if (!("evaluate" in node) || node.depsTail === undefined) continue
node.flags = Flags.Mutable | Flags.Dirty
let current = node.depsTail as Link | undefined
while (current !== undefined) {
const prev = current.prevDep
unlink(current, node)
current = prev
}
}
}
function purgeDeps(sub: ReactiveNode): void {
const depsTail = sub.depsTail
let dep = depsTail !== undefined ? depsTail.nextDep : sub.deps
while (dep !== undefined) {
dep = unlink(dep, sub)
}
drainOrphans()
}
function propagate(current: Link): void {
let next = current.nextSub
let stack: Stack<Link | undefined> | undefined
top: do {
const sub = current.sub
const flags = sub.flags
const unmarked = !(flags & (Flags.Dirty | Flags.Pending))
if (unmarked) sub.flags = flags | Flags.Pending
if (flags & Flags.Watching && !(flags & Flags.Queued)) enqueue(sub as WatcherNode)
if (unmarked && flags & Flags.Mutable) {
const subSubs = sub.subs
if (subSubs !== undefined) {
current = subSubs
const nextSub = subSubs.nextSub
if (nextSub !== undefined) {
stack = { value: next, prev: stack }
next = nextSub
}
continue
}
}
if (next !== undefined) {
current = next
next = current.nextSub
continue
}
while (stack !== undefined) {
const continuation = stack.value
stack = stack.prev
if (continuation !== undefined) {
current = continuation
next = current.nextSub
continue top
}
}
break
} while (true)
}
function shallowPropagate(current: Link): void {
let iterator: Link | undefined = current
do {
const sub = iterator.sub
const flags = sub.flags
if ((flags & (Flags.Pending | Flags.Dirty)) === Flags.Pending) {
sub.flags = flags | Flags.Dirty
if (flags & Flags.Watching && !(flags & Flags.Queued)) enqueue(sub as WatcherNode)
}
iterator = iterator.nextSub
} while (iterator !== undefined)
}
function updateNode(node: ReactiveNode): boolean {
if ("evaluate" in node) return updateComputed(node as ComputedNode)
return updateState(node as StateNode)
}
function checkDirty(current: Link, sub: ReactiveNode): boolean {
let stack: Stack<Link> | undefined
let checkDepth = 0
let dirty = false
top: do {
const dep = current.dep
const flags = dep.flags
if (sub.flags & Flags.Dirty) {
dirty = true
} else if ((flags & (Flags.Mutable | Flags.Dirty)) === (Flags.Mutable | Flags.Dirty)) {
const subs = dep.subs!
if (updateNode(dep)) {
if (subs.nextSub !== undefined) shallowPropagate(subs)
dirty = true
}
} else if ((flags & (Flags.Mutable | Flags.Pending)) === (Flags.Mutable | Flags.Pending)) {
stack = { value: current, prev: stack }
current = dep.deps!
sub = dep
checkDepth++
continue
}
if (!dirty) {
const nextDep = current.nextDep
if (nextDep !== undefined) {
current = nextDep
continue
}
}
while (checkDepth--) {
current = stack!.value
stack = stack!.prev
if (dirty) {
const subs = sub.subs!
if (updateNode(sub)) {
if (subs.nextSub !== undefined) shallowPropagate(subs)
sub = current.sub
continue
}
dirty = false
} else {
sub.flags &= ~Flags.Pending
}
sub = current.sub
const nextDep = current.nextDep
if (nextDep !== undefined) {
current = nextDep
continue top
}
}
return dirty
} while (true)
}

View file

@ -1,13 +1,110 @@
import { describe, expect, it } from "bun:test"
import { createEffect, createRoot } from "solid-js"
import { State } from "../src"
import { useValue } from "../src/solid"
import { Computed, State, Transaction, type Readable } from "../src/reactivity"
describe("reactivity", () => {
it("keeps computed values lazy", () => {
const source = State.make(1)
let evaluations = 0
const doubled = Computed.make(() => {
evaluations++
return source() * 2
})
expect(evaluations).toBe(0)
expect(doubled()).toBe(2)
expect(doubled()).toBe(2)
expect(evaluations).toBe(1)
source.set(2)
expect(evaluations).toBe(1)
expect(doubled()).toBe(4)
expect(evaluations).toBe(2)
})
it("propagates a diamond without glitches", () => {
const source = State.make(1)
const left = Computed.make(() => source() * 2)
const right = Computed.make(() => source() * 3)
const total = Computed.make(() => left() + right())
const values: Array<number> = []
const dispose = total.subscribe((value) => values.push(value))
source.set(2)
expect(values).toEqual([10])
dispose()
})
it("tracks dynamic dependencies", () => {
const enabled = State.make(true)
const left = State.make(1)
const right = State.make(10)
const selected = Computed.make(() => (enabled() ? left() : right()))
const values: Array<number> = []
const dispose = selected.subscribe((value) => values.push(value))
left.set(2)
enabled.set(false)
left.set(3)
right.set(11)
expect(values).toEqual([2, 10, 11])
dispose()
})
it("batches transactions", () => {
const left = State.make(1)
const right = State.make(2)
const total = Computed.make(() => left() + right())
const values: Array<number> = []
const dispose = total.subscribe((value) => values.push(value))
Transaction.run(() => {
left.set(2)
right.set(3)
})
expect(values).toEqual([5])
dispose()
})
it("cuts off unchanged derived values", () => {
const source = State.make(1)
let parityEvaluations = 0
let labelEvaluations = 0
const parity = Computed.make(() => {
parityEvaluations++
return source() % 2
})
const label = Computed.make(() => {
labelEvaluations++
return parity() === 0 ? "even" : "odd"
})
const dispose = label.subscribe(() => {})
source.set(3)
expect(parityEvaluations).toBe(2)
expect(labelEvaluations).toBe(1)
dispose()
})
it("disposes subscriptions", () => {
const source = State.make(1)
const values: Array<number> = []
const dispose = source.subscribe((value) => values.push(value))
source.set(2)
dispose()
source.set(3)
expect(values).toEqual([2])
})
describe("Quark", () => {
it("does not track state read by subscription listeners", () => {
const source = State.make(1)
const unrelated = State.make(1)
const values: number[] = []
const values: Array<number> = []
const dispose = source.subscribe((value) => {
unrelated()
values.push(value)
@ -20,20 +117,311 @@ describe("Quark", () => {
dispose()
})
it("bridges values into a Solid owner and disposes with it", () => {
it("does not track the state read by update", () => {
const trigger = State.make(0)
const updated = State.make(0)
let runs = 0
const dispose = trigger.subscribe(() => {
runs++
updated.update((value) => value + 1)
})
trigger.set(1)
updated.set(10)
expect(runs).toBe(1)
expect(updated()).toBe(10)
dispose()
})
it("delivers the previous value to computed evaluation", () => {
const source = State.make(1)
const values: number[] = []
const previousValues: Array<number | undefined> = []
const running = Computed.make<number>((previous) => {
previousValues.push(previous)
return (previous ?? 0) + source()
})
expect(running()).toBe(1)
source.set(2)
expect(running()).toBe(3)
source.set(3)
expect(running()).toBe(6)
expect(previousValues).toEqual([undefined, 1, 3])
})
it("detaches and reattaches dynamic dependencies", () => {
const enabled = State.make(true)
const left = State.make(1)
const right = State.make(10)
let evaluations = 0
const selected = Computed.make(() => {
evaluations++
return enabled() ? left() : right()
})
const dispose = selected.subscribe(() => {})
expect(evaluations).toBe(1)
enabled.set(false)
expect(evaluations).toBe(2)
// Detached: left writes must not re-evaluate the computed.
left.set(2)
left.set(3)
expect(evaluations).toBe(2)
// Reattached: left writes re-evaluate, right writes no longer do.
enabled.set(true)
expect(evaluations).toBe(3)
expect(selected()).toBe(3)
right.set(11)
expect(evaluations).toBe(3)
left.set(4)
expect(evaluations).toBe(4)
expect(selected()).toBe(4)
dispose()
})
it("notifies a diamond subscriber exactly once per write", () => {
const source = State.make(1)
const left = Computed.make(() => source() * 2)
const right = Computed.make(() => source() * 3)
const total = Computed.make(() => left() + right())
let notifications = 0
const dispose = total.subscribe(() => notifications++)
source.set(2)
source.set(3)
expect(notifications).toBe(2)
dispose()
})
it("batches nested transactions until the outermost ends", () => {
const left = State.make(1)
const right = State.make(2)
const total = Computed.make(() => left() + right())
const values: Array<number> = []
const dispose = total.subscribe((value) => values.push(value))
Transaction.run(() => {
left.set(2)
Transaction.run(() => {
right.set(3)
})
expect(values).toEqual([])
left.set(3)
})
expect(values).toEqual([6])
dispose()
})
it("restores batch state when a transaction throws", () => {
const source = State.make(1)
const values: Array<number> = []
const dispose = source.subscribe((value) => values.push(value))
expect(() =>
Transaction.run(() => {
source.set(2)
throw new Error("boom")
}),
).toThrow("boom")
// The write that happened before the throw still flushes once the
// transaction unwinds, and later writes are not batched.
expect(values).toEqual([2])
source.set(3)
expect(values).toEqual([2, 3])
dispose()
})
it("supports disposing another subscription during notification", () => {
const source = State.make(1)
const first: Array<number> = []
const second: Array<number> = []
let disposeSecond = () => {}
const disposeFirst = source.subscribe((value) => {
first.push(value)
disposeSecond()
})
disposeSecond = source.subscribe((value) => second.push(value))
source.set(2)
source.set(3)
expect(first).toEqual([2, 3])
expect(second).toEqual([])
disposeFirst()
})
it("does not deliver to a watcher disposed during revalidation", () => {
const source = State.make(0)
let dispose = () => {}
createRoot((rootDispose) => {
dispose = rootDispose
const value = useValue(source)
createEffect(() => values.push(value()))
const gate = Computed.make(() => {
const value = source()
if (value > 0) dispose()
return value
})
const values: Array<number> = []
dispose = gate.subscribe((value) => values.push(value))
// Revalidating the watcher re-evaluates gate, whose evaluation disposes
// the subscription before the value could be delivered.
source.set(1)
source.set(2)
expect(values).toEqual([])
})
it("supports a subscription disposing itself during notification", () => {
const source = State.make(1)
const values: Array<number> = []
let dispose = () => {}
dispose = source.subscribe((value) => {
values.push(value)
dispose()
})
source.set(2)
dispose()
source.set(3)
expect(values).toEqual([1, 2])
expect(values).toEqual([2])
})
it("does not track State.update reading its own value", () => {
const counter = State.make(0)
const source = State.make(1)
let evaluations = 0
const tracked = Computed.make(() => {
evaluations++
counter.update((value) => value)
return source()
})
const dispose = tracked.subscribe(() => {})
expect(evaluations).toBe(1)
counter.set(5)
expect(evaluations).toBe(1)
source.set(2)
expect(evaluations).toBe(2)
dispose()
})
it("recovers tracking after a computed evaluation throws", () => {
const shouldThrow = State.make(true)
const source = State.make(1)
const throwing = Computed.make(() => {
if (shouldThrow()) throw new Error("computed boom")
return source()
})
expect(() => throwing()).toThrow("computed boom")
// The failed evaluation must restore the active observer so unrelated
// graphs keep tracking correctly afterwards.
const other = State.make(1)
const doubled = Computed.make(() => other() * 2)
const values: Array<number> = []
const dispose = doubled.subscribe((value) => values.push(value))
other.set(2)
expect(values).toEqual([4])
dispose()
shouldThrow.set(false)
expect(throwing()).toBe(1)
source.set(2)
expect(throwing()).toBe(2)
})
it("keeps notifying other subscribers after a listener throws", () => {
const source = State.make(1)
const values: Array<number> = []
const disposeThrowing = source.subscribe(() => {
throw new Error("listener boom")
})
const dispose = source.subscribe((value) => values.push(value))
expect(() => source.set(2)).toThrow("listener boom")
disposeThrowing()
source.set(3)
expect(values).toContain(3)
dispose()
})
it("leaves no dependency links behind when subscription initialization fails", () => {
const source = State.make(1)
const throwing = Computed.make(() => {
if (source() === 1) throw new Error("init boom")
return source()
})
const values: Array<number> = []
expect(() => throwing.subscribe((value) => values.push(value))).toThrow("init boom")
// The failed subscription must not stay linked to the graph.
source.set(2)
source.set(3)
expect(values).toEqual([])
// The computed itself remains usable.
expect(throwing()).toBe(3)
})
it("propagates deep chains without recursive stack growth", () => {
// A cold pull of an unevaluated chain necessarily nests user getter
// frames, so each layer is evaluated as it is built. The kernel-owned
// paths under test are dirty propagation and revalidation, which must
// walk the full depth iteratively.
const depth = 100_000
const source = State.make(0)
const chain = Array.from({ length: depth }).reduce<Readable<number>>((current) => {
const next = Computed.make(() => current() + 1)
next()
return next
}, source)
expect(chain()).toBe(depth)
const values: Array<number> = []
const dispose = chain.subscribe((value) => values.push(value))
source.set(1)
expect(values).toEqual([depth + 1])
dispose()
})
it("propagates wide fan-out without recursive stack growth", () => {
const width = 50_000
const source = State.make(0)
const nodes = Array.from({ length: width }, () => Computed.make(() => source() + 1))
const total = Computed.make(() => nodes.reduce((sum, node) => sum + node(), 0))
let sink = 0
const dispose = total.subscribe((value) => {
sink = value
})
source.set(1)
expect(sink).toBe(width * 2)
dispose()
})
it("fails deterministically on cyclic computed dependencies", () => {
// Regression for stackblitz/alien-signals#123: mutually dependent
// computed graphs must throw a stable error instead of looping or
// exhausting memory.
const fieldA = State.make(false)
const fieldB = State.make(false)
const a: Readable<boolean | null> = Computed.make(() => (b() !== true ? fieldA() : null))
const b: Readable<boolean | null> = Computed.make(() => (a() !== true ? fieldB() : null))
// Every read that reaches the cycle fails the same way; a failed
// evaluation stays dirty and retries instead of serving a stale value.
expect(() => a()).toThrow(/cycle/i)
expect(() => b()).toThrow(/cycle/i)
fieldA.set(true)
expect(() => a()).toThrow(/cycle/i)
})
})

View file

@ -7,7 +7,7 @@ import { Keyed, Layout } from "effect-quark"
* Streaming events name their part on the wire (text/reasoning by ordinal,
* tools by callID); each assistant message owns one keyed collection so a
* delta publishes exactly one slot instead of reconciling a content array.
* See docs/design/quark-message-content.md.
* Design record: quark repo, docs/experiments/quark-message-content.md.
*/
export namespace SessionContent {
type ContentPart = SessionMessageAssistant["content"][number]