packages/agent/docs/mobile-handoff/01-harness/01-delta/FINDINGS.md
Everything here was found after delta.md and the prototype implementation beside it were written. None of it is applied to the code in this directory. Production now lives in packages/chord/src/delta/index.ts: flush-time dirty tracking resolves D1, and production remeasurement closes D2 without a new API. See append-decision.md. Each item below remains the original problem, evidence, and candidate-fix record.
Every number was measured with node --experimental-strip-types, not tsx.
That matters: tsx transpiles through esbuild and inflated the same benchmark by
2.6x (183 µs vs 63 µs per write). Do not benchmark this module through a
transpiler.
Severity: high. This is the one to fix first.
flush() coalesces only adjacent ops. A producer that alternates between two
paths therefore never coalesces anything, and the ops array grows with the number
of writes rather than with the size of the value.
This is not hypothetical. It is exactly what bash does: it updates the output text and a byte counter on every chunk.
const next = t.state.content[0].text + chunk;
t.state.content[0].text = next.length > CAP ? next.slice(next.length - CAP) : next;
t.state.truncation.totalBytes += chunk.length; // <- the other path
50 KB window, 200-byte chunks, no flush in between:
| held-back writes | ops | bytes |
|---|---|---|
| 1 | 3 | 0.3 KB |
| 100 | 201 | 26 KB |
| 1000 | 2001 | 264 KB |
Without interleaving the same workload gives 1 op and 51 KB. The window is bounded at 50 KB; the ops describing it are not.
Any design where the sink holds back — rate limiting, backpressure, a slow
consumer — depends on held-back writes costing nothing. They currently cost
everything. See ../../04-tool-output/rate-limiting.md.
Fold at record time, keyed by path rather than by adjacency: one slot per
path holding at most two ops ([s], [d], [t], [a], or [t, a]), emitted in
first-touch order.
A prototype reached 2 ops / 51 KB on the same workload. It is not included here because it was not verified to the standard the rest of this unit meets.
Four folding rules, each a specific claim about the vocabulary:
s supersedes an earlier s on the same path — but not an earlier
d, which must survive or the key changes position on reinsertion (§5.1).a merge. Once the merged append is strictly longer than the
value, the window has been sliding and the append is the more expensive
spelling, so collapse to s. Strictly: while a string is still growing
toward its cap the append equals the value, and collapsing there would resend
the whole string every batch.t and a on one path commute — the truncate drops from the front, the
append adds to the end — so a rolling window's t,a,t,a,... folds to one of
each. Without this, pairwise merging never fires at all.t or a following a pending s changes nothing: the s already means "this
path becomes its current value".The trap that prototype hit. Slots emit in first-touch order, which loses the fact that a child write happened before a later parent write:
script : a={x:1}; a.b=99; a={c:2}
producer: {"a":{"c":2}}
replica : {"a":{"c":2,"b":99}} <- WRONG
A set or delete must invalidate every pending slot below its path, at record
time. The existing verify-dead style test passed 1190 sequences while this was
broken, because its generator only nested one level. Any test for this must
generate genuinely nested paths with parent overwrites interleaved between child
writes.
overlap() was 94.5% of prototype tracker time — closedHistorical severity: high. Production decision: do not add an explicit append/truncate API. The prototype profile was dominated by a slow startsWith path; production uses a flattened-slice comparison and measured 17.8–18.7 µs per 200 KB assistant append flush and 2.43–2.46 µs per 50 KB rolling-window flush locally. See append-decision.md. The original evidence follows.
The tracker infers "you appended and evicted" by searching two 50 KB strings.
Each new value produced by next.slice(-CAP) is a V8 SlicedString — a lazy
substring — and indexOf/endsWith force it to flatten, copying 50 KB per
call.
CPU profile of a 20 000-write rolling-window loop, node, flush at the end:
94.5% overlap
2.7% (anon)
1.2% (garbage collector)
0.5% set
0.3% emit
0.0% fold
And the flattening is directly observable:
overlap() on a 50 KB window | µs |
|---|---|
| the same two strings reused | 37 |
| a fresh sliced string each call | 149 |
An earlier benchmark reported 29 µs because it reused two already-flattened strings 2000 times — a case that never occurs in the real loop. Do not benchmark string operations against a reused fixture.
Do not infer what the producer already knows. ToolOutput performs the windowing,
so it can emit the ops directly rather than assigning a new string and having the
tracker search for the difference:
out.append(chunk); // -> ["t", path, dropped], ["a", path, chunk]
Two integers — chars appended, chars evicted — describe everything that happened between any two observations of a bounded window. A prototype of this shape held exactly the window in memory under every load, with no ops array, no overlap detection and no folding rules at all.
This does not remove overlap: it stays for the general case where a producer
assigns a whole new string and the delta genuinely has to be discovered. It
removes it from the hot path.
Do not "optimise" overlap itself before doing the above. Its algorithm is
already correct and bounded (maxOverlapScan); the cost is string flattening,
which no probe strategy avoids.
Severity: unknown — measure before acting.
overlap dominates so completely that nothing else shows in a profile. But two
benchmarks disagree in a way that points at path depth:
| µs per write | |
|---|---|
top-level path ({ text }) | 63 |
nested path ({ content: [{ text }] }) | 244 |
4x for depth alone. Each nested property access wraps a child proxy and builds a
cache key by joining the path, so a read of content[0].text allocates on every
access.
Re-profile only if production delta cost becomes material. This may be the next bottleneck or it may be
nothing; the current numbers cannot distinguish them because overlap swamps both.
Recorded so nobody repeats the work. All were under 1.5% combined in the profile above.
A path trie replacing JSON.stringify(path) keys. Genuinely faster in
isolation — 6x on the LaneSnapshot shape, 93x at 500 distinct paths — because
stringify cost scales with path depth while a trie walk is one Map.get per
segment. But the whole slot layer is 0.5% of runtime. Build it only if a current production profile says so.
Trie as storage vs trie as index. If a trie is ever built, it should map path to a position in the ops array, not hold the ops. Holding them loses first-touch ordering and forces a sort at flush to rebuild it. Measured difference: none (1.4x / 0.9x / 1.0x). The argument is structural, not performance.
A maxDepth heuristic to skip descendant scanning. Rejected: the flat map was
losing on the common case too, so the heuristic would have hidden a real cost
rather than removed it.
Eager string concatenation in the a+a merge. Real (it rebuilds a growing
string per write) but worth ~5%. Compare lengths and materialise only when
emitting.
Listed because each one produced a confident wrong conclusion, and the same traps are still there.
tsx — inflated everything 2.6x."x".repeat(n)) — overlap detection hits its candidate
bound and gives up, so the benchmark measures the fallback path, not the real one.
Use varied text.Markdown.render() returns
cachedLines when text and width are unchanged, so a repeat-the-same-call loop
measured 0.065 ms for something that costs 0.86 ms.