Make JSON.merge-diff linear in depth: O(size × depth) instead of O(size × depth²) - #21
Conversation
diff-member compared a member's whole subtree with JSON.= before recursing, so every ancestor of a changed leaf re-walked everything below it. JSON.= is itself O(size x depth) on nested objects, because Map.vals and Map.get-maybe deep-copy the values they return, which made merge-diff O(size x depth^2). The comparison is only load-bearing where merge-diff would otherwise return @b unconditionally: two objects can recurse instead, and an empty result object means "no change". For two objects the merge patch is empty exactly when they are deep-equal — by induction, the patch is empty iff no member of a is missing from b and every member of b diffs to nothing, which for a non-object value is JSON.= and for an object value is the same property one level down. An empty object as a member *value* stays distinct from an empty result: it is only the object/object branch that reads emptiness as "unchanged", so an array or scalar becoming {} still emits {}. One changed scalar beside an untouched 5000-element array, nesting depth on the left, best of one run on a Pi 500: depth 1 3.82 ms -> 2.53 ms depth 32 676.25 ms -> 41.38 ms depth 128 11161.83 ms -> 189.34 ms depth 128 is what JSON.parse already accepts, so an 11-second one-op diff was reachable from any untrusted document that parse was willing to hand on. Output is unchanged: over every ordered pair of 42 documents the serialized patch and its round trip through merge-patch are byte-identical to before.
Map.keys and Map.vals return owned copies, so diff-map deep-copied every
value of b at each level and deleted-members allocated a String per key.
That left a residual factor of depth in the recursion and made diffing a
wide object of equal object members slower than the version this replaces.
Map.kv-reduce hands the reducer both key and value as references, which is
the shape both loops already wanted.
Measured on a Pi 500, before this change / after, best of one run:
one changed scalar beside an untouched 5000-element array
depth 1 2.53 ms -> 1.45 ms
depth 32 41.38 ms -> 24.16 ms
depth 128 189.34 ms -> 101.54 ms
two identical documents, 20000 members each an equal 2-member object
250.97 ms -> 158.90 ms (191.74 ms before either commit)
the same pair with one member changed
244.99 ms -> 157.67 ms (184.97 ms before either commit)
Both closures capture only a reference. A sanitizer build (clang
-fsanitize=address,undefined) of the ordered-pair differential reports
nothing beyond the pre-existing signed overflow in core's string hash, and
its output matches the interpreter's.
There was a problem hiding this comment.
Build & Tests
carp -x test/json.carp at bd48292 — 408 passed, 0 failed on this armhf Pi (exit code read from the unpiped command). Both CI legs are green on bd48292 itself. carp -x gendocs.carp leaves the tree clean, no page picks up the renamed private helpers, and nothing anywhere still references the removed diff-objs. Branch based on 494827e, still origin/main's head.
Findings
The equivalence the PR rests on holds, and I checked it against main rather than by re-reading the induction. Independent differential, my own fixtures rather than yours: 48 documents (empty objects at every position, null-valued members, 1 vs 1.0, empty-string and non-ASCII keys, arrays of objects, kind changes) × all 2304 ordered pairs, comparing both the serialized patch and the round trip through merge-patch. Byte-identical to main on every line. Teeth-checked: inverting the Map.empty? test moves 296 lines of that output, so the harness is not vacuous.
Reading the code, the two rewritten branches also collapse to main's exactly — for a non-object av, merge-diff av bv was already @bv, and for an Obj/non-Obj pair it was too, so dropping the recursion there loses nothing.
1. The complexity claim is one factor of depth too optimistic
The title says O(size) and the body says the second commit removes the residual factor of depth. It removes one of them. Both numbers below are from the same harness, main and this branch on identical inputs.
Document size held constant (one 5000-element array at the leaf, only the chain above it lengthens), so O(size) would be a flat column:
| depth | main |
this branch |
|---|---|---|
| 1 | 3.87 ms | 1.42 ms |
| 8 | 52.54 ms | 5.50 ms |
| 32 | 674.32 ms | 24.32 ms |
| 120 | 9607.45 ms | 93.71 ms |
main is quadratic in depth (×3.75 depth → ×14.2 time); this branch is linear in depth (×3.75 → ×3.85). Same patch bytes on both sides at every row.
Pure chain, tiny leaf, so size grows with depth:
| depth | main |
this branch |
|---|---|---|
| 15 | 2.37 ms | 0.43 ms |
| 30 | 15.06 ms | 1.51 ms |
| 60 | 109.98 ms | 5.74 ms |
| 120 | 975.18 ms | 22.11 ms |
Cubic → quadratic, i.e. the same single factor removed.
Where the remaining factor lives. Two copies per level, one on each side:
diff-member's(Map.get-maybe am k)(json.carp:1508) — core returns(Maybe.Just @(Pair.b …))(core/Map.carp:136-141), an owned deep copy ofa's member subtree.diff-map's(Map.put m k &bx)(json.carp:1530) —Map.puttakes the value by reference and copies it into the bucket, so the accumulated result subtree is copied again at every level on the way back up.
Measured separately at depth 120, which is what makes it two rather than one: 5000-element array only in a → 92.81 ms; only in b → 92.47 ms; in both → 86.54 ms. Either side alone costs the full amount.
Neither is removable from this repo — core's Map has no borrowing accessor (get, get-maybe and get-with-default all copy) and no move-in put. So this is a "say what it does" fix, not a code fix: O(size × depth) instead of O(size × depth²) in the title, and the sentence about the second commit wants to say it removes the copy of b's values, not the depth factor.
It matters for the motivation too. The framing is that parse and Patch.apply are budgeted and merge-diff was not; after this, a document at json-max-depth still costs about 66× what the same bytes cost at depth 1 (93.71 ms vs 1.42 ms above). Much better than 2500×, but if a real bound is wanted, this does not remove the need for one.
Verdict: revise
The change is correct — 2304 differential pairs byte-identical to main with a teeth-checked harness — and the speedup is real and large (102× on the deep case). Nothing in the code needs to change. But the title becomes the commit message, and it claims a complexity the code does not have; retitling it and adjusting the two sentences about the residual factor is the whole of what I am asking for.
|
Took the retitle and the two sentences. No commit: the head is still Title is now The sentence about the second commit now says it removes one of the per-level deep copies — A new section names the two survivors with your line numbers ( I also kept your last point rather than dropping it: the motivation paragraph opens by contrasting |
There was a problem hiding this comment.
Build & Tests
carp -x test/json.carp at bd48292 — 408 passed, 0 failed, exit code read from the unpiped command. Both CI legs still green on bd48292 itself.
Prior feedback
The head SHA is unchanged at bd48292 — the same tree I ran the 2304-pair differential against last round — so everything in that review still stands: the equivalence holds, the patches are byte-identical to main on every pair, and the speedup is real.
My revise was metadata-only and the metadata is now right.
- The title says
linear in depth: O(size × depth) instead of O(size × depth²), which is what the measurements show. - The sentence about the second commit now credits it with removing one of the per-level deep copies — "the copy of
b's member values" — rather than the depth factor. - The new What this does not remove section names both survivors at the lines they live on (
Map.get-maybeatjson.carp:1508ona's side,Map.putatjson.carp:1530on the result's), carries the constant-size-varying-depth table with the linearity check, and states the 66× qualification against the "parseandPatch.applyare budgeted,merge-diffis not" motivation. Both line references still point at the code they name on this head.
Nothing in the code needed to change and nothing did.
Findings
None new. This is the same tree, re-tested.
Verdict: merge
The one thing I asked for is done, and it was the right way to do it — the claim was corrected to match the code rather than the code stretched to match the claim.
Make JSON.Patch.diff linear in depth, as #21 did for merge-diff
JSON.merge-diffcompared a member's whole subtree before recursing into it:Every ancestor of a changed leaf therefore re-walked everything below it. And
JSON.=is not itself O(size) on nested objects: it reaches members throughMap.keys/Map.vals/Map.get-maybe, all of which return owned copies, socomparing two objects deep-copies both subtrees at every level. The two effects
compound to O(size × depth²).
JSON.parseaccepts documents up tojson-max-depth(128) andPatch.applyisbudgeted by
json-max-patch-nodes;merge-diffhad no such bound, so a one-opdiff of a document
parsewas willing to hand it took 11 seconds.The fix
The equality test is only load-bearing where
merge-diffwould otherwise return@bunconditionally — a scalar, an array, or a kind mismatch. Two objects canrecurse instead, and an empty result object means "no change".
That equivalence holds by induction.
diff-map am bmis empty exactly when(a)
deleted-membersis empty, i.e. every key ofasurvives inb, and(b) every key of
bdiffs toMaybe.Nothing, which requires the key to bepresent in
aand its values to agree —JSON.=for a non-object value, andthe same property one level down for an object value. Together those give equal
key sets and equal values, which is exactly how
JSON.=compares twoObjs.An empty object as a member value stays distinct from an empty result:
only the object/object branch reads emptiness as "unchanged", so
[] → {}stillemits
{"a":{}}and{"b":1} → {}still emits{"a":{"b":null}}. Bothdirections are pinned by tests.
The second commit replaces the
Map.keys/Map.valsloops withMap.kv-reduce,which hands the reducer key and value as references. That removes one of the
deep copies made per level — the copy of
b's member values. Without it a wideobject of equal object members gets slower than the code being replaced.
Measurements
Pi 500, best of one run,
System.nanotime. First column ismain.One changed scalar beside an untouched 5000-element array:
Two large documents that are identical, the shape most at risk from dropping the
short-circuit:
I tried the top-level fast path the obvious way — one
JSON.=in the publicentry, check-free worker underneath — and did not keep it. It only pays when the
two documents are wholly identical, and it charges every other call a full
JSON.=on top of the diff. With the second commit in place there is no shapeleft that it would rescue: every row above is faster than
main.What this does not remove
The result is linear in depth, not flat. Two deep copies per level survive, one
on each side:
diff-member's(Map.get-maybe am k)(json.carp:1508) — core'sget-maybereturns an owned copy of
a's member subtree (core/Map.carp:136-141).diff-map's(Map.put m k &bx)(json.carp:1530) —Map.putcopies thevalue into the bucket, so the accumulated result subtree is copied again at
every level on the way back up.
Either side alone costs the full amount, which is what makes it two rather than
one. Neither is removable from this repo: core's
Maphas no borrowing accessor(
get,get-maybeandget-with-defaultall copy) and no move-input.Holding document size constant and varying only depth — one 5000-element array
at the leaf, only the chain above it lengthening — separates the two factors.
Measured on review:
mainmainis quadratic in depth (×3.75 depth → ×14.2 time); this branch is linear(×3.75 → ×3.85). One factor removed, not two.
So the motivation above wants a qualification: a document at
json-max-depthstill costs roughly 66× what the same bytes cost at depth 1. That is a large
improvement on 2500×, but it does not remove the case for giving
merge-diffareal bound of its own, the way
parseandPatch.applyhave one.Validation
scalars, arrays, empty objects, null-valued members, kind changes and the RFC
7386 §3 example. For each pair the serialized patch and the round trip
through
merge-patchare byte-identical tomain.an object/object pair as unchanged (10 failures, and 152 of the 1764
differential lines move), inverting the emptiness test (12), reading an empty
object value as "no change" (1), dropping deleted members (5), never
omitting a member (7), consulting the wrong map in
deleted-members(5), andcomparing arrays by length alone (2).
clang -fsanitize=address,undefinedover the differentialharness — the two
kv-reduceclosures capture only a reference. Nothingreported beyond the pre-existing signed overflow in core's string hash, and
the output matches the interpreter's.
carp-fmt --checkandanglerclean,gendocs.carpproduces no change.No changelog in this repo, and no user-visible behaviour change to note in the
README — the patches produced are identical, only the time to produce them
changes.
Follow-up
JSON.Patch.diff-intoin #20 opens with the same(if (JSON.= a b) ...)and hasthe same shape of defect. It is left alone here so the branches stay disjoint —
this one touches
json.carp:1487-1545, #20 inserts at1449, andgit merge-treemerges the two cleanly. Worth a second pass once #20 lands.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.