Skip to content

fix(merge): keep a blessed hash whole in perl, and close cycles - #171

Merged
rjrodger merged 3 commits into
mainfrom
claude/perl-merge-instance
Oct 9, 2026
Merged

rjrodger merged 3 commits into
mainfrom
claude/perl-merge-instance

Conversation

@rjrodger

@rjrodger rjrodger commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

The cause

Since 7e54712 (#158), perl's merge copies a plain node that wins rather than sharing it. _merge_pair told kinds apart by list or map alone, though, and it recursed with no record of the nodes it was already merging. Two failures followed:

  • A blessed object was not kept whole. perl's typify calls a blessed hash a map, so merge([{x => 10}, $obj]) descended into the object and returned a plain copy. merge([{a => $obj}, {a => {x => 11}}]) merged the map into the object, setting $obj->{x} to 11. typescript keeps a class instance as it is and copies a plain map over one, leaving the instance alone (merge-special in StructUtility.test.ts).
  • A cycle was copied out to the depth limit. clone turns a blessed object into a plain map that keeps any reference back to itself, and transform merges clone($data). So validate and select over such an object copied every cycle to MAXDEPTH, and with more than one way round that is exponential. Found while revendoring sdkgen: a generated perl SDK's entity tests ran out of memory (5 GB and climbing) in select over their own list results, whose entities refer back to themselves through their context. The same tests finish on the previous checkpoint's struct, whose merge shared rather than copied.

Change

perl/lib/Voxgig/Struct.pm, _merge_pair:

  • Kinds. A blessed hash is a class instance, so it and a plain map are different kinds, as a list and a map are; a blessed array is still a list, as an Array subclass is in typescript (_isinstance). An instance that wins is kept as it is, one over another merges into it, and a plain node that wins is copied, as before.

  • Cycles. Two records, both scoped to the path:

    • A (destination, override) pair already being merged further up returns at once, so a cycle in both operands stops where it repeats.
    • An override node already being copied further up returns that copy, so the copy closes the cycle. The copy record holds only copies. An override that loops back under a destination with keys of its own therefore closes on a fresh copy and takes none of those keys, as typescript's unrolled copy does not.

    Acyclic input, including a node shared by two keys, is copied exactly as before: once per place it appears, as typescript does.

typify, validate and clone still read a blessed hash as a map. perl/DOCS.md says so beside the new behaviour: a closed cycle is one node for every level, and a winning blessed array is copied as a plain array. #172 tracks bringing them in line with typescript's T_instance. That changes what generated perl SDKs do with blessed options (sdkgen's templates document and work around clone unblessing them), so it is not attempted here.

Tests

perl/t/struct.t:

  • merge-instance: canonical's merge-special assertions with blessed objects.
    • An object that wins is kept (is identity) over a map, under a key and under an absent key.
    • A plain map over one is a plain copy, and the object is unchanged.
    • An object over an object merges into the destination.
    • An object whose own maps refer back to it is kept as it is.
    • A blessed array merges into the first list and leaves a later one unchanged.
  • merge-cycle:
    • A map whose two keys both refer back to it is copied with the cycle closed, and the override is unchanged.
    • A self-looping override under a destination with a key of its own: the copy takes no key and closes on itself.
    • Two operands whose x and y each point back to their own root merge into the first, which keeps its cycle.
    • select over a blessed object that refers back to itself through a map and a list returns its one match.

Controls

  • On main's Struct.pm with these tests: six merge-instance assertions fail, then the run stops at merge-cycle with Out of memory! under a 1.5 GB limit.
  • On dc11e77's Struct.pm: the two blessed-array assertions and the self-loop's "takes no key" assertion fail. The run then times out at the cycle over a cycle.
  • On the generated SDK: with the kind check alone, its entity tests still run out of memory, because the object reaches merge already unblessed by clone. With the cycle records they finish in a second each.

Verification

Other ports

lua and php compare the full typify, which separates an instance from a map, like typescript. swift's value type has no instances. zig copies the destination at every level and is unchanged. Only perl merges without walk and also treats an object as a map.

Since review

Codex reviewed dc11e77 and found two gaps, both confirmed and fixed in d7ca6e0, with tests that fail on dc11e77:

  • the cycle check ran only on the copy path, so cyclic operands of the same kind still unrolled;
  • a blessed array counted as an instance.

A second Claude reviewer found d7ca6e0 sound. It drove 216 merge shapes through typescript, main, dc11e77 and d7ca6e0, and 11 select, validate and transform probes:

  • JSON-shaped data is identical in every lane.
  • Cyclic shapes: every one that main or typescript cannot finish, d7ca6e0 finishes. An SDK-like graph of 29 blessed nodes takes 4 ms.
  • dc11e77's problems: it reproduced both independently, including a transform that leaked the destination's keys through a closed cycle.

Its notes:

  • A closed cycle is one node for every level, so a later element that writes into one level writes into all of them.
  • A winning blessed array is copied as a plain array, as typescript's Array subclass is.
  • Densely cross-linked cycles still cost one copy per path to their first repeat, far below main's cost.
  • The select test checked only for a list.

40a36c6 says the first two in perl/DOCS.md and makes the select test assert its one match.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BoEed9CaF6sTNMcXxQaH6w

Since 7e54712 perl's merge copies a plain node that wins, but it told
kinds apart by list or map alone and recursed with no record of the nodes
it was already copying.

- A blessed object over a plain map was descended into rather than kept,
  and a plain map over one was merged into it, changing the caller's
  object. typescript keeps an instance whole and copies a plain map over
  one.
- clone turns a blessed object into a plain map that keeps any reference
  back to itself, and transform merges that clone, so validate and select
  over such an object copied each cycle out to the depth limit. A
  generated perl SDK's entity tests ran out of memory selecting over
  their own list results.

_merge_pair now treats a blessed node and a plain one as different kinds,
and a node already being merged further up the path returns its copy, so
the copy closes the cycle. Acyclic input, shared nodes included, is
copied exactly as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BoEed9CaF6sTNMcXxQaH6w
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-08T14:54:15.225725Z dc11e77 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dc11e77875

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread perl/lib/Voxgig/Struct.pm Resolved
Comment thread perl/lib/Voxgig/Struct.pm Resolved
claude added 2 commits October 8, 2026 15:05
Review of dc11e77 found two gaps.

- The cycle check ran only when the override was copied. Where the
  destination held a node of the same kind, as when both operands refer
  back to themselves, the merge descended again: two maps whose x and y
  each point back to their own root still unrolled to MAXDEPTH, two ways
  at every level. A pair already being merged further up now returns at
  once.
- A blessed array counted as an instance, so merge([[1], $b2, $b3])
  returned $b2 and then wrote $b3 into it. An Array subclass is a list
  in typescript, and a blessed array is one here.

The copy record now holds only copies, so an override that closes under a
destination with keys of its own closes on a fresh copy and takes none of
those keys, as typescript's unrolled copy does not.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BoEed9CaF6sTNMcXxQaH6w
From the second review of d7ca6e0. A closed cycle is one node for every
level, so a later element that writes into one level writes into all of
them; typescript's unrolled copy takes the write at one level only. A
winning blessed array is copied as a plain array, as typescript copies an
Array subclass, and one instance over another merges into it. The select
test now asserts its one match rather than only a list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BoEed9CaF6sTNMcXxQaH6w
@rjrodger rjrodger changed the title fix(merge): keep a blessed object whole in perl, and close cycles fix(merge): keep a blessed hash whole in perl, and close cycles Oct 8, 2026
@rjrodger
rjrodger merged commit 3ab807c into main Oct 9, 2026
212 of 215 checks passed
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.

2 participants