Make JSON.Patch.diff linear in depth, as #21 did for merge-diff - #22
Conversation
diff-into compared both subtrees with JSON.= before descending, 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 diff O(size x depth^2).
The comparison is only load-bearing on the branches that would otherwise emit
an unconditional replace. Two objects can recurse instead: diff-members emits
nothing exactly when the two are deep-equal — by induction it emits nothing iff
no key of a is missing from b, no key of b is missing from a, and every shared
member diffs to nothing, which for a scalar is JSON.= and otherwise the same
property one level down. Two arrays can recurse for the same reason: diff-elems
walks the common prefix and emits nothing for equal elements, and its add and
remove loops are empty when the lengths agree. Mismatched kinds never needed
it, because JSON.= returns false on differing constructors immediately. Only
scalar against scalar still compares, and there it is one Double or one String.
An empty object or empty array as a member value is unaffected: nothing here
reads emptiness as “unchanged”, so 1 becoming {} is still a replace and
{"a":{"b":1}} becoming {"a":{}} is still a remove of /a/b.
diff-members also walked both member maps through Map.keys and Map.vals, which
deep-copy every value of b at each level, and looked a up with Map.get-maybe,
which deep-copies the matching value. Map.kv-reduce hands the reducer key and
value as references, the shape both loops already wanted. Core has no
by-reference lookup — get, get-maybe and get-with-default all copy — so the a
side goes through Map's bucket array: one hash, one bucket scan, no copy.
Keeping get-maybe leaves a residual factor of depth; measured against each
other, the depth 126 case below is 307 ms with it and 14 ms without.
One changed scalar beside an untouched 5000-element array, nesting depth on the
left, best of three runs on a Pi 500:
depth 1 6.84 ms -> 3.22 ms
depth 32 710.59 ms -> 3.93 ms
depth 126 11303.39 ms -> 9.48 ms
126 wrappers is 128 levels counting the leaf object and its array, which is what
JSON.parse accepts, so an 11-second one-op diff was reachable from any untrusted
document parse was willing to hand on.
Two 20000-member objects whose members are equal 2-member objects:
identical 546.49 ms -> 734.14 ms
one member changed 1429.12 ms -> 757.44 ms
Descending costs the identical case: proving two objects equal that way looks
every key up in both maps where the short-circuit looked it up once. That is
only expensive because a parsed object never rehashes — the parser fills it with
Map.put!, which does not grow — so each of those lookups scans a 1250-entry
bucket. The same pair rebuilt with Map.put, which grows the map to 32768
buckets, is faster after than before in both directions: 155.13 -> 132.25 ms
identical, 347.30 -> 138.31 ms with one member changed.
Output is unchanged: over every ordered pair of 50 documents the serialized
patch and its round trip through JSON.Patch.apply are byte-identical to before.
There was a problem hiding this comment.
Build & Tests
carp -x test/json.carp at a9c4904 on this armhf Pi — 431 passed, 0
failed, exit code read from the unpiped command, up from 423 at 7db4fa8.
CI green on both legs. The branch sits on 7db4fa8, origin/main's head, with
nothing to rebase over. This repo really does have no CHANGELOG, so "no
changelog entry" is right rather than an omission. First review round.
The "output is unchanged" claim holds, over a wider corpus than the PR
measured. I built two trees — json.carp at 7db4fa8 and at a9c4904 — and
ran one probe against both: 58 documents, every ordered pair, 3364 diffs,
printing the serialized patch and the serialized result of applying it. The
corpus is random documents to depth 4; wide objects of 17, 24, 32 and 45 keys
(a parsed map is 16 buckets and never rehashes, so anything past 16 keys forces
bucket collisions); non-ASCII keys; a base document with ten single-edit
mutations; the structural corner cases; and three 120-deep documents.
- the serialized patch is byte-identical between the two trees on all 3364
apply(a, diff(a, b))equalsbon all 3364, on both trees
Plus 81 pairs of duplicate-key documents ({"a":1,"a":2} and friends). Those
are worth their own run because Map.put! bumps len whether or not the key
was already there, so a parsed duplicate-key object has a len that disagrees
with its bucket contents — exactly the kind of thing the removed JSON.=
short-circuit could have been papering over. Identical between the two trees,
round trip clean.
The bucket shortcut is correct, including where the two maps have different
geometry. bucket-for reproduces Map.get-maybe's arithmetic exactly
(Int.positive-mod (hash k) @(n-buckets m) into Array.unsafe-nth (buckets m))
and member-at reproduces Bucket.get-maybe's read minus the copy. Measured
rather than read off: a 44-key object built with JSON.obj — which uses
Map.put, so it grows to 64 buckets — diffs to the empty patch against its
parsed twin at 16 buckets, in both directions, and change / add / remove across
the two representations produce the same patch main produces.
The benchmark reproduces to within a few percent, main and this branch
built side by side, best of three, on an otherwise idle Pi 500:
| one changed scalar beside an untouched 5 000-element array | main | branch |
|---|---|---|
| depth 1 | 6.26 ms | 3.24 ms |
| depth 32 | 703.77 ms | 3.87 ms |
| depth 126 | 10 753.51 ms | 9.40 ms |
1144x at the depth JSON.parse will hand you from an untrusted document.
And the disclosed regression is real, and rather bigger here than the PR
reports. Two 20 000-member objects of 2-member objects:
| main | branch | |
|---|---|---|
| identical | 472.70 ms | 756.22 ms |
| one member changed | 992.00 ms | 753.54 ms |
1.60x on the identical case, against the 1.34x in the body. The two after
numbers agree closely with yours (756 vs 734, 754 vs 757) and only the before
numbers diverge, which is what you would expect from "measured with two other
builds running" — the short-circuit path is the one that was competing for CPU.
Same shape, same conclusion, and the diagnosis in the body (the parser's
non-growing map, not the walk) is the interesting part and looks right.
Findings
1. The two helpers that replace Map.get-maybe are the only new logic here, and the suite does not constrain either of them
Mutation battery, each mutant applied to json.carp alone with the suite re-run
against it:
| mutant | suite |
|---|---|
diff-members, take the bucket's last entry instead of Bucket.find's |
430 / 1 |
removed-members, never emit a remove |
427 / 4 |
diff-into, scalar leaf always replaces |
423 / 8 |
bucket-for, hard-code 16 buckets instead of @(n-buckets m) |
431 / 0 — survives |
member-at, ignore i and read entry 0 of the bucket |
431 / 0 — survives |
The top three say the harness has teeth. The bottom two are precisely what
bucket-for and member-at add over the Map.get-maybe they replace — bucket
geometry and collision resolution — and nothing in 431 assertions watches
either.
member-at reading entry 0 survives because no diff in the suite ever
looks a member up in a bucket holding more than one entry. Every object in
diff-corpus has three keys or fewer. Closing it is two lines: adding two
20-key objects to diff-corpus — a parsed map is 16 buckets and never grows, so
20 keys guarantees a collision — leaves the suite at 431 / 0 and takes that
mutant to 430 / 1.
bucket-for assuming 16 buckets survives because every object the suite
diffs came out of JSON.parse, and the parser is the only thing in
json.carp that fills a map with Map.put!. JSON.obj, JSON.set-key,
JSON.merge-patch, JSON.to-json and JSON.Patch.apply's own add and replace
all use Map.put, which grows — so any object a caller builds, or that apply
hands back, with more than a dozen members has 32 or 64 buckets, and
(JSON.Patch.diff &(JSON.Patch.apply ...) ...) is an ordinary call that goes
down a path no test reaches. One assertion — a JSON.obj-built object diffing
empty against its parsed twin — takes the suite to 432 / 0 and that mutant
to 431 / 1.
Both are coverage rather than defects: I verified externally, above, that the
shipped code is right in both directions. But this is the part of the PR that
swaps a core primitive for hand-rolled arithmetic over another module's
representation, and it is the part the suite is silent about.
2. For the part you flagged as worth arguing about: it already has a precedent in the org
web.carp has done exactly this since its initial commit. Map.update-value!
and Map.value-ref! at web/web.carp:290-308 compute the same index the same
way, call Bucket.find, and read
(Pair.b (Array.unsafe-nth (Bucket.entries bucket) i)) — the same four lines
this PR open-codes. They are the only other place in 47 clones that touches
Map.buckets, Map.n-buckets or Bucket.; core itself has no user outside
Map.carp.
They differ in shape, and the difference is the interesting bit: web reopens
defmodule Map and names them as the core accessors that are missing, where
this PR keeps them private to JSON.Patch. That cuts both ways —
- the technique is not a new precedent, so the decision here is narrower than
the PR frames it; and - it is now open-coded in two repositories, which is a stronger argument for the
Map.get-refin core the PR names than the PR itself makes.
Nothing to change on this branch. Lifting web's exact Map.value-ref! would not
work as a drop-in, incidentally: its default is an ordinary argument and so is
evaluated eagerly, and both branches here consume out.
Also checked, nothing found
- The induction behind dropping the short-circuit holds at every branch.
Obj/Obj recurses intodiff-members, which emits nothing exactly when the two
are member-wise equal; Arr/Arr recurses intodiff-elems, which recurses per
common index and emitsadd/removeonly for the length difference;
mismatched constructors never neededJSON.=, since it returns false on them
immediately; scalar against scalar still compares. The case the commit message
calls out as nearly biting — an empty object or array as a member value — is
covered by the new tests and by the corpus above. - Nothing can reorder.
Map.keysandMap.valsare themselveskv-reduce
over the same bucket walk, so going straight tokv-reducecannot change the
order ops are emitted in, andremoved-membersstill seeds the accumulator so
removals still precede adds. The byte-identical differential confirms it. Array.unsafe-nthinbucket-forcannot run off the end.n-bucketsand
the length of the buckets array are written together and only byMap.create
andMap.resize, so they cannot drift apart.- Handing
diff-intoa reference intoam's storage is safe here — it only
reads, and neither map is mutated during the walk. - A wrong bucket index would not be silent in production even so: it makes
Bucket.findmiss and the diff emit a spuriousadd, which the new
self-diffs-empty?assertion catches for any object it covers. That is why
finding 1 is worth two lines rather than nothing — the guard exists, it is
just pointed at the narrow case. - The eight new assertions all matter. Reverting the source and re-running
is not needed to see it: three of the five mutants above are killed by them.
Verdict: revise
The change is right and it is a big one — 1144x at the nesting depth parse
will accept from a stranger, byte-identical output over 3364 ordered pairs plus
81 duplicate-key pairs plus the mixed-representation cases, and an honest
account of the case it makes slower with the cause actually diagnosed rather
than shrugged at. What I would not merge without is a guard on the one piece of
hand-rolled core-internals arithmetic it introduces: bucket-for and
member-at are the whole of what is new, and both survive mutation at 431 / 0
today. Two additions, both measured above, close them and cost nothing.
JSON.Patch.diffhas the bug #21 fixed inmerge-diff, and this is the same fix in the same shape.The bug
diff-intoopened with(if (JSON.= a b) ops ...).JSON.=walks the whole subtree, and is itself O(size × depth) on nested objects becauseMap.valsandMap.get-maybehand back deep copies, so every ancestor of a changed leaf re-walked everything below it before descending.JSON.parseaccepts documents 128 levels deep, so a one-operation diff of an untrusted document took eleven seconds.The fix
The comparison is only load-bearing on the branches that would otherwise emit an unconditional
replace. Two objects can recurse instead, becausediff-membersemits nothing exactly when the two are deep-equal; two arrays can for the same reason; mismatched kinds never needed it, becauseJSON.=returnsfalseon differing constructors immediately. Only scalar against scalar still compares. The commit message states the induction, including the case that nearly bites: an empty object or array as a member value still emits the right op, because nothing here reads emptiness as "unchanged".diff-membersthen walks both member maps withMap.kv-reduce, which hands key and value over as references, instead ofMap.keys/Map.vals— bd48292 forPatch.The one place this goes further than #21, and the part worth arguing about: core has no by-reference lookup (
get,get-maybeandget-with-defaultall copy the value out), and leavingMap.get-maybein place kept a factor of depth — 307 ms on the depth 126 case against 14 ms without, measured against each other. So theaside goes throughMap's bucket array directly, which reaches into a core data structure's representation. The clean alternative is aMap.get-refin core, which is a carp-lang change; the fallback is a one-line revert toMap.get-maybe, at that cost.Evidence
Output is unchanged. Over every ordered pair of a 50-document corpus (2500 pairs; the corpus already in
test/json.carp, extended with documents that differ only deep down, arrays that grow and shrink, objects that gain and lose keys, empty objects and arrays as values, and values that change kind), the serialized patch and its round trip throughJSON.Patch.applyare byte-identical tomain's.Benchmarks. One changed scalar beside an untouched 5000-element array, nesting depth on the left, best of three runs,
mainand this branch measured back to back on a Pi 500 with two other builds running:(126 wrappers is 128 levels counting the leaf object and its array, the deepest
JSON.parseaccepts.)The wide case bd48292 was written for — two 20000-member objects whose members are equal 2-member objects:
The identical case is 1.35× slower and I could not remove that. Proving two objects equal by descending looks every key up in both maps where the short-circuit looked it up once. That only costs anything because a parsed object never rehashes — the parser fills it with
Map.put!, which does not grow — so each of those lookups scans a 1250-entry bucket. Rebuilding the same pair withMap.put, which grows the root to 32768 buckets, makes this branch faster thanmainin both directions:Map.putSo the residue is the parser's non-growing map, not the walk. Happy to file that separately if you want it fixed.
Tests.
test/json.carpgains the semantics the removed short-circuit used to provide: every document in the corpus diffs to the empty patch against itself, a member emptied to{}or[]emits the right op, a member that is{}in both emits nothing, and a 120-deep document diffs to onereplaceat the leaf and round-trips throughapply. 431 assertions pass, up from 423.carp -x test/json.carp,carp-fmt --check,anglerandcarp -x gendocs.carp(tree unchanged) all pass. No changelog entry: this repo has no changelog.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.