Skip to content

Structural and merge replacements, Hypothesis property tests - #8

Merged
simonw merged 4 commits into
mainfrom
merge-replacements
Aug 3, 2026
Merged

Structural and merge replacements, Hypothesis property tests#8
simonw merged 4 commits into
mainfrom
merge-replacements

Conversation

@simonw

@simonw simonw commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Three features that extend replacements beyond substring matching, built for llm's condensed response payload storage.

Three key changes:

  • Replacements of {"a": {"object": ["nested", "list"]}}` now work - previously the value had to be a string, now it can be any JSON structure.
  • For objects in that replacement list, the resulting condensed structure can specify merge operations - take that object, add or change these keys, delete these other keys.
  • Hypothesis testing to help ensure round-trips across complex examples.
Claude Fable 5 description

Structural replacements

A replacement value may now be a dict or a list. Container values match structurally - any subtree equal to the value in canonical JSON form is replaced whole with the existing {"$": id} marker, so key order and serialization bytes never matter. Matching is outermost-wins and strictly structural (a string containing the JSON serialization of a value never matches). Resolution substitutes independent deep copies; round trips are structurally equal rather than byte-identical, since the original key order of a matched subtree is not recorded.

Merge references

Dict replacements double as merge bases: a dict that is mostly equal to a base - a static envelope with a few varying fields, like API response metadata - is stored as a dict-valued marker:

{"$": {"m": "base_id", "u": {"added or changed": "keys"}, "d": ["absent keys"]}}

Condensing needs no similarity heuristic: for each base the patch is computed and both encodings are measured, and the merge form is emitted only when it is smaller - unrelated bases price themselves out. Deletion is the explicit d list rather than a JSON Merge Patch-style null sentinel, because null is a legitimate payload value. The dict-valued marker is still a single-key $ dict, so the existing $raw escaping already protects input that looks like a merge reference. Measured on real OpenAI Responses payloads in llm, merge references took a typical reply from 22% to 44% saved, since the ~15 static top-level envelope keys collapse to one reference.

Performance was benchmarked and tuned: a key-overlap early-out skips the cost computation for nodes sharing no keys with any base (without it, deeply nested documents went quadratic - 29ms for a 36KB doc, now 0.6ms), base canonical forms are computed once per call, and input subtree canonicals are memoized. A realistic payload condenses in under 0.1ms; adding bases to a large string-replacement workload costs nothing measurable.

Property-based tests

Six Hypothesis properties: the round-trip contract under arbitrary, document-derived, and merge-targeted replacements; output always JSON-serializable; repeated application lossless; and uncondense_json on arbitrary input either resolves or raises UncondenseError, never anything else. The strategies were validated by mutation testing - three planted bugs (equality-semantics, dropped escaping, raw-instead-of-processed patches) are all caught. That process exposed and fixed two real weaknesses: a confusables generator now draws True/False/0/1/0.0/1.0 frequently, and assertions compare canonical JSON alongside ==, because True == 1 makes bool/int corruption invisible to == alone. HYPOTHESIS_PROFILE=thorough runs 2,000 examples per property.

Notes for review

  • First-ID-wins now holds for merge bases too (kept in mapping order, equivalent values deduplicated), matching the documented rule for other replacement kinds; cost ties also go to the earlier entry.
  • Patches are flat by design: a nested value that differs at all travels whole in u (condensed recursively on the way). Nested bases provide depth when wanted.
  • uv run pytest: 71 passed. uv run mypy condense_json tests: clean. Black formatted. README documents both new behaviors with executable examples.

🤖 Generated with Claude Code

simonw and others added 4 commits August 2, 2026 21:38
A replacement value may now be a dict or a list as well as a string.
Container values match structurally: any subtree equal to the value in
canonical JSON form - keys sorted, so ordering never matters - is
replaced whole with the existing {"$": id} marker. Matching is
outermost-wins and strictly structural: a string containing the JSON
serialization of a value is never matched, because a reference in
string context must resolve to a string.

On the way out, container references resolve to independent deep
copies, so mutating a result cannot alias the replacements mapping or
a sibling marker. A container id appearing inside $r string segments
raises UncondenseError. Round trips are structurally equal rather than
byte-identical: a matched subtree comes back with the replacement's
key order, since the original ordering is not recorded.

Canonical forms of input subtrees are memoized per call, and a
(type, length) shape check skips nodes that cannot possibly match, so
documents with no plausible matches never pay for serialization.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dict replacements now double as merge bases. A dict node that is
mostly equal to a base - a large static envelope with a few varying
fields, like API response metadata - is stored as a dict-valued
marker:

    {"$": {"m": base_id, "u": {added or changed}, "d": [absent keys]}}

Uncondensing deep-copies the base, removes the "d" keys and applies
the "u" entries, themselves uncondensed recursively so markers nest
inside patches. Deletion is the explicit key list rather than the
JSON Merge Patch null sentinel, because null is a legitimate value in
real payloads. Malformed references - unknown or non-dict base, extra
fields, non-list "d", deleting a key the base lacks, non-dict "u" -
raise UncondenseError, as does a merge reference inside $r segments.

Condensing needs no similarity heuristic: for each base the patch is
computed (per-key canonical equality, so True/1/1.0 stay distinct and
unserializable values always travel in the patch) and both encodings
are measured - the merge form is emitted only when it is smaller than
writing the dict out. Unrelated bases price themselves out. Bases are
kept in mapping order with equivalent values deduplicated, so the
first ID wins, and cost ties also go to the earlier entry. An exact
match still takes the plain {"$": id} form. The dict-valued marker
stays a single-key "$" dict, so the existing $raw escaping covers
input that already looks like a merge reference.

Sizing serializes candidate encodings, so it is guarded to keep
condense tolerant of JSON-ish input, and a key-overlap early-out skips
the whole computation for the overwhelming majority of nodes that
share no keys with any base - without it, deeply nested documents
would go quadratic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six properties over generated documents: the round-trip contract
uncondense(condense(obj, r), r) == obj under arbitrary, self-derived
(subtrees and substrings of the document itself) and merge-targeted
(near-copies of a base) replacements; condensed output always
JSON-serializable; repeated application still lossless; and uncondense
on arbitrary input either resolves or raises UncondenseError, never
anything else.

The strategies were tuned by mutation testing - planting bugs and
checking the properties catch them. Two lessons are baked in: a
confusables generator draws True/False/0/1/0.0/1.0 often, because the
bool/int collision that breaks canonical-equality code is otherwise
too rare to generate; and assertions compare canonical JSON alongside
Python ==, because True == 1 makes that exact corruption invisible to
== alone. Key generation is biased toward the marker vocabulary so
escaping collisions occur constantly.

HYPOTHESIS_PROFILE=thorough runs 2000 examples per property instead
of 100, for pre-release checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The opening Usage prose still told the string-only story - matching
substrings, two marker shapes, uncondense restoring "the original
strings" - leaving structural and merge references undocumented until
their own sections much further down. The function descriptions now
enumerate all three reference forms with links, and the strictness
paragraph mentions malformed merge references.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@simonw
simonw merged commit 2114ec1 into main Aug 3, 2026
10 checks passed
@simonw
simonw deleted the merge-replacements branch August 3, 2026 04:52
simonw added a commit that referenced this pull request Aug 3, 2026
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