Skip to content

Make JSON.Patch.diff linear in depth, as #21 did for merge-diff - #22

Merged
hellerve merged 1 commit into
mainfrom
claude/patch-diff-linear
Aug 25, 2026
Merged

Make JSON.Patch.diff linear in depth, as #21 did for merge-diff#22
hellerve merged 1 commit into
mainfrom
claude/patch-diff-linear

Conversation

@carpentry-agent

Copy link
Copy Markdown

JSON.Patch.diff has the bug #21 fixed in merge-diff, and this is the same fix in the same shape.

The bug

diff-into opened with (if (JSON.= a b) ops ...). JSON.= walks the whole subtree, and is itself O(size × depth) on nested objects because Map.vals and Map.get-maybe hand back deep copies, so every ancestor of a changed leaf re-walked everything below it before descending. JSON.parse accepts 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, because diff-members emits nothing exactly when the two are deep-equal; two arrays can for the same reason; mismatched kinds never needed it, because JSON.= returns false on 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-members then walks both member maps with Map.kv-reduce, which hands key and value over as references, instead of Map.keys/Map.valsbd48292 for Patch.

The one place this goes further than #21, and the part worth arguing about: core has no by-reference lookup (get, get-maybe and get-with-default all copy the value out), and leaving Map.get-maybe in place kept a factor of depth — 307 ms on the depth 126 case against 14 ms without, measured against each other. So the a side goes through Map's bucket array directly, which reaches into a core data structure's representation. The clean alternative is a Map.get-ref in core, which is a carp-lang change; the fallback is a one-line revert to Map.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 through JSON.Patch.apply are byte-identical to main's.

Benchmarks. One changed scalar beside an untouched 5000-element array, nesting depth on the left, best of three runs, main and this branch measured back to back on a Pi 500 with two other builds running:

before after
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, the deepest JSON.parse accepts.)

The wide case bd48292 was written for — two 20000-member objects whose members are equal 2-member objects:

before after
identical 546.49 ms 734.14 ms
one member changed 1429.12 ms 757.44 ms

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 with Map.put, which grows the root to 32768 buckets, makes this branch faster than main in both directions:

rebuilt with Map.put before after
identical 155.13 ms 132.25 ms
one member changed 347.30 ms 138.31 ms

So 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.carp gains 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 one replace at the leaf and round-trips through apply. 431 assertions pass, up from 423.

carp -x test/json.carp, carp-fmt --check, angler and carp -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.

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.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)) equals b on 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-ref in 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 into diff-members, which emits nothing exactly when the two
    are member-wise equal; Arr/Arr recurses into diff-elems, which recurses per
    common index and emits add/remove only for the length difference;
    mismatched constructors never needed JSON.=, 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.keys and Map.vals are themselves kv-reduce
    over the same bucket walk, so going straight to kv-reduce cannot change the
    order ops are emitted in, and removed-members still seeds the accumulator so
    removals still precede adds. The byte-identical differential confirms it.
  • Array.unsafe-nth in bucket-for cannot run off the end. n-buckets and
    the length of the buckets array are written together and only by Map.create
    and Map.resize, so they cannot drift apart.
  • Handing diff-into a reference into am'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.find miss and the diff emit a spurious add, 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.

@hellerve
hellerve merged commit 35d67fe into main Aug 25, 2026
2 checks passed
@hellerve
hellerve deleted the claude/patch-diff-linear branch August 25, 2026 15:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant