Skip to content

Feature – Sub Graphs can be cloned within documents, winning implementation - #397

Open
tobyspark wants to merge 57 commits into
feat-undofrom
feat-subgraph-clones-patch
Open

tobyspark wants to merge 57 commits into
feat-undofrom
feat-subgraph-clones-patch

Conversation

@tobyspark

@tobyspark tobyspark commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Clone set work 3 of 3: The actual clone set implementation. The UX established in #339, with a new and improved implementation.

Since #339, a bake-off between implementations has been pursued. Notable developments

  • More rigour (and tests) around publishing ports, which is a critical part of the clone set UX.
  • Upgrading undo so it could serve as the change tracking mechanism instead of Feature – Sub Graphs can be cloned within documents #339’s separate system of hooks on Graph
  • A common cloning orchestrator was built onto undo so the bake-off compares the separate models only
  • An implementation leveraging existing document serialisation was developed. Like leveraging undo, this approach doesn’t require us having two graph modelling systems in place. Node ids being identical within sub-graphs traded tracking what-node-is-what-within-each-cloned-subgraph for tracking of which port the boundary proxy ports should connect to. Whole sub-graphs being replaced on every edit did not prove the hoped-for brutal simplicity either, due to observation gaps.
  • The winning implementation, here, is a best-of-both-worlds approach. It uses the existing document serialisation system, diffs on that data, and then uses that system’s decode machinery to replace individual nodes when required.

Clone sets bake-off

What is being compared

Three models of the same feature: clone sets, where SubgraphNodes sharing a set stay identical in design while executing independently. Everything outside the sync is shared and byte-identical or near it across the branches: the set record on the root graph, the coordinator that syncs when the document's undo group closes, the funnel that gives it every edit, the linked status and badge, the rule for which inlet values are a member's own, and a contract test suite of around 100 tests that each branch satisfies through a small per-model shim. The comparison is on the sync alone.

The three models

Template. Each member has its own node and port ids. A record per member maps template ids to local ids. A sync refreshes the template from the edited member and reconciles each sibling in place through the record: nodes added, removed, updated port by port, or replaced when their settings differ; wires and notes likewise. Untouched nodes keep their objects and runtime state.

Decode. A set holds one stored graph. Members are instances decoded from it with identical inner ids. A sync re-encodes the edited member as the stored graph and replaces every sibling's sub graph with a fresh instance. Nothing is diffed. Siblings keep their proxies and own values; they lose all runtime state and their sub graph object.

Patch. Decode's representation, with the last step changed. The sibling is encoded the same way, the two encodings are compared by id with non-design values stripped, and only the delta is applied: nodes added or deleted, placement and port state set in place, nested sub graphs patched inside, and a node whose settings changed decoded again in its place with its wires and proxy chain carried over. Untouched nodes keep their objects and state.

Side by side

Template Decode Patch
Identity across members Per-member ids plus a record Identical inner ids Identical inner ids
What a sync does to a sibling Reconciles in place Replaces the whole sub graph Applies the delta
Untouched nodes keep runtime state Yes No, declared divergence Yes
Contract divergences 0 1 0
Undo footprint per sibling per edit 2 to 3 small steps One whole sub graph snapshot Steps proportional to the delta
Sync file 914 lines 212 lines 650 lines
SubgraphNode delta 78 lines 375 lines 231 lines
Library insertions over feat-undo 2339 2053 2327
Commits 72 54 57

The line counts measure the sync machinery only. Patch grew to template's size once port-level changes were handled in place; decode is smallest because it handles nothing in place.

What each one costs

Template pays for identity. The record has to be kept aligned through nested sets, recovered when a member arrives without one, and extended by every sync. Its review history is that machinery: stale member wins, nested records left gappy, recovery under a fresh id, settings signatures as a second comparison. Its reconciler is the largest piece of code in the bake-off and is specific to this feature.

Decode pays for replacement. Because the sub graph object changes, proxies have to carry their own ids and be re-pointed, the published parameter groups above have to be rebuilt, ths path by id and watch a replacement counter, retiredgraphs have to be stopped through a remembered renderer, and the undo step for a sync holds a full encoding of each sibling. Every sibrestarts on every edit, which is the divergence it declere the last correctness bug was found.

Patch pays for the diff. Node and note order are destep undoably. Wires are diffed against the live graph,since a plain nested sub graph's proxy carries its inner port's id. A nested member's proxies take the ids the stored graph has for thA node whose settings changed is still made again, as ie encoding would show as that node restarting. Its review history is shorter: a non-atomic patch, own values written back too widely, and the nil a replacement sent downstream, all fixed, the last by changing how a node is replaced in a way templa.

Shared by decode and patch is the identical-ids taxr own, and anything keyed by id in one parent graph musttolerate two instances. The main-branch fixes for same-id replacement and lifecycle review came out of this.

Settings that change without touching ports are invisible to anything that tracks a node's design. Node gains settingsDidChange(), a call a node makes after changing any encoded state that is not a port, and every core node with such state now calls it: expressions, strategies, scripts and time modes, shader source, timelines, format strings, model settings, OSC, MIDI, HID, keyboard and game controller selections. The node checklist gains the rule. Shared with #339, where it was written.

Via Claude Fable 5.1
…ings are re-instantiated from the member edited last

An implementation of #199 along the lines set out in review on #339: clone sets re-use serialisation rather than reconcile. A CloneSet on the root graph holds one stored graph as data. Each member's sub graph is decoded from it, so members hold the same nodes, ports and wires under the same ids; only the graphs themselves take ids of their own, since the editor and the renderer cache by graph id. The member edited last is encoded as the stored graph and every sibling's sub graph is replaced with a fresh instance of it. Nothing is diffed. A sibling after a sync is what a document load would make: its proxies stay, re-pointed at the same ports in the new instance, so the parent's wires onto them and any proxy wrapping them further up are untouched, and its published inlet values are put back. Everything else it held at runtime starts over, and the edits made in it can no longer be undone, since they would act on nodes no longer in the document. The old graph's nodes are stopped through the renderer they ran under; the new graph's start at the renderer's next lifecycle review, as added nodes do.

Two things identical ids ask of the rest of Fabric. A member's proxies carry ids of their own, since its siblings wrap inner ports under the same ids in the same parent graph. And anything keyed on a node by id conflates instances: the lifecycle review set does, which is why retired nodes are stopped directly rather than left for review.

Edits reach siblings as on #339: Graph.noteContentChanged, bumped by the mutation API and by a member's CloneMemberObserver for what nodes, ports and parameters publish, debounced by a per-document coordinator that keeps one pending source per set, the last edited. A set that would contain one of its own members is refused on arrival, and dropped with a diagnostic where a stored graph names one. A document saves the stored graph once and each member as a reference and its own values; a member out of step with its set is saved in full. Published inlet values are left out of that comparison, being each member's own. The SuperShape N31 default sat below its slider minimum, which the fidelity test found.

Tests: 40 in CloneSetTests, covering instantiation under the same ids, proxies keeping their ids and wires, per-member values, lifecycle of retired and new nodes, a Deferred member's renderer, undo being forgotten for a re-instantiated sibling, nested sets and published chains, live sync scheduling, later editor wins, compact save and drift, self-containment, and every core node type cloning and encoding back to the stored graph.

Via Claude Fable 5.1
…k from Clones on the node menu, the linked glyph in the breadcrumb, and members saved as references

The editor side as on #339, unchanged: the same menu items, rename alert and breadcrumb entry, so the two implementations compare on the model alone. A document is saved with pending syncs flushed and members written as references to their sets.

Via Claude Fable 5.1
…'s base URL for re-instantiated siblings

Review found an edit lost between nested sets. With an edit pending in a nested member inside one sibling and then an edit in the other sibling, the flush took the outer set's source from the first graph's chain, retired the second graph, and dropped its edit, since a retired graph no longer sits inside any member. The coordinator now keeps one source per set, the last graph edited inside one of its members, and syncs nested sets before the sets around them, so an outer member is stored with its nested members already in step and no source is retired before it has been synced from. Each set syncs once, from the member of it that encloses its source. The descendant check the list logic needed goes with it.

A sibling re-instantiated from its set decoded against no file base URL, so relative file references inside it resolved against nothing after a sync; the decoder context now carries the graph's.

Via Claude Fable 5.1
pruneDanglingConnections dropped a wire whose ports no longer resolve by nilling the wire's own references and taking it out of the graph, and no more. The end that still existed kept the wire in its connections array, where nothing could reach it again, the wire's references to both ports having just gone. A port left holding one reported itself wired: a Pass Through node stayed a Processor waiting on input that could not arrive, and an image node chose its output port on a connection count that was wrong. The inlet also kept the last value the dead wire sent it.

The prune now tears a wire down as detachConnection does: the ports that survive drop it, an inlet that survives is sent nil, and each surviving node updates its connection topology and is marked for lifecycle review. didDisconnectFromNode is left out, there being no node on the other side to name.

Nothing calls this yet on this branch, a sibling being re-instantiated rather than reconciled. It is sound for whatever does.

Via Opus 5
Unlink from Clones sat behind the same condition as Select Sibling Clones, the member having siblings, while membership did not: unlink or delete every sibling and the member that remains is still in its set, still badged with the set's name, still able to rename it, and with no way out. Unlink now stands on the member being in a set, as Rename does, and Select Sibling Clones keeps the siblings it needs.

Via Opus 5
A proxy port carries its inner port's id, so publishedInletsRecursive named a nested sub graph's knob twice where the nested node's proxy for it was published on through the member: once as the proxy, once as the port it stands for. The proxy came first, so the values a member keeps as its own held the proxy, written as one. Decoding it asks for its inner port in the graph being decoded, which is the parent by then and never holds it, and the throw takes the whole member with it: a document saved with a knob brought up from a nested sub graph — the ordinary way one is exposed — loaded with every member of the set dropped, and a save after that wrote the loss back.

A proxy is no longer among them. It forwards its inner port's value rather than holding one, and that port is published too and already in the list, so nothing is lost and the values a member keeps are its ports' own.

Test publishes an inlet inside the nested sub graph, publishes the nested node's proxy on through the member, gives each member its own value for it, and checks the document saves, loads with both members, and brings each value back.

Via Opus 5
…ber leaves the set

Duplicate as Clone flushes the pending sync before it copies, the edits still waiting belonging in the graph the copy is made from. Unlink did not, and it had more to lose: once the node's membership is cleared, the sync can no longer find it as its set's source, so the edit a user made in the moment before leaving reached none of the former siblings. The nested members are read after the flush, a sync having replaced the trees they sit in.

Test leaves an edit on the debounce, unlinks the member, and checks the sibling receives the edit and the member has left.

Via Opus 5
A sync swaps a sibling's whole sub graph for a fresh instance, and the watcher that reports the member's edits held subscriptions to the tree just retired. Only the coordinator's flush told the members to re-subscribe, so a sync through any other door — syncCloneSets, reinstantiateCloneSiblings, reinstantiateCloneMember, each of them public — left the member watching nothing. Edits inside it then went unreported: a node moved or renamed, a port published, a resting value changed. Only topology reached the set, the graph's mutation API reporting that itself.

The watcher re-subscribes where the tree is replaced, so every caller is covered rather than the one that remembered.

Test syncs so the sibling is re-instantiated, changes a resting value on the tree it holds then, and checks the set hears it and the other member is brought in line.

Via Opus 5
reinstantiateCloneMember raised the coordinator's syncing flag and cleared it on the way out, where reinstantiateCloneSiblings puts back what it found. The flag is what keeps a reconcile's own writes from being taken for edits, so clearing it inside a sync already running would have the writes left to do read as the user's, scheduling a sync of their own. It is public, and nothing stops it being reached from a member's own observer.

Via Opus 5
…nge came from

Observation registrations fire once, so the watcher on a port's published state re-registers each time it fires. It judged the change first and returned where it was not on the main thread, which consumed the registration without renewing it: a decode, which runs off the main thread, left that port unwatched for the rest of the member's life, and publishing or renaming it was never reported as an edit again.

The renewal comes first now, and whether the change is an edit is decided after. A change from another thread is a decode rather than a user's edit, so it is still not reported.

Via Opus 5
…select

The item stood on the set having siblings anywhere in the document, while what it selects is those on the canvas the menu belongs to. A set whose two members sit in different sub graphs offered it on either one, and choosing it cleared the selection and selected nothing, the node right-clicked included. It now stands on the siblings it can reach, and selects exactly those.

Via Opus 5
…mpact

A first save or Save As writes the document, then decodes what it wrote, rewrites its file references against the new directory and writes it again. That second encode was a plain one, so every clone set member went back in full and the compaction the save had just applied was undone on the one save that gives a document its URL. The rebase now encodes as the save does.

Via Opus 5
…o its proxy with it

Graph.setPublished, now on main, covers a member too. Its comment says why a member needs it: a member's proxies carry ids of their own, so a republished proxy could never be bound to the wire that named the old one.

Test unpublishes inside a member and checks the sibling's proxy, the parent's wire onto it and the upstream port's hold on that wire have all gone, and that republishing brings back a proxy with a new id and nothing wired to it.

Via Opus 5.5
CloneSetContractTests is the same file on both clone set branches: what a set must do for its user, whatever model keeps members identical. It reaches the model through CloneSetModel, which each branch provides (a counterpart is the node of the same id; syncing re-instantiates siblings), and runs behaviour a model gives up by design as a known issue: this model replaces a sibling's node objects on every sync, and forgets the sibling's undo history with them. CloneSetTests keeps only what tests this model's own machinery: shared ids, proxy identities, retired nodes, self-containing sets.

Via Opus 5.5
…t does not stop it opening

A change noted off the main thread was handed to the main actor to schedule a sync. Off the main thread is where a document decodes, so every node it added queued a sync for whichever member had its owner by then, and each set synced from an arbitrary member shortly after opening, while the decode could still be running. A change made elsewhere is now taken for what it is, a decode, and schedules nothing.

The document's clone sets decoded as one array, so a single entry that could not be read refused the whole document. Each set now decodes on its own; one that cannot be read is dropped with a diagnostic, and its members open as before.

The same change is made on the template branch, from the bake-off review.

Via Opus 5.5
…ires onto its proxy with it

Unpublishing inside the member being edited took the parent's wires with the proxy, but a sync carrying the unpublish to a sibling did not: the sibling's proxy went and the parent's wire onto it stayed, still held by the upstream outlet. The sync now prunes the sibling's parent graph of wires whose ports no longer resolve.

The same change is made on the template branch, from the bake-off review.

Via Opus 5.5
Each finding of the review that is not particular to one model is now a contract case, on both branches. Those still open on a model are listed in its CloneSetModel.knownBugs and run as known issues there, apart from the by-design divergences; fixing one means delisting it.

Via Opus 5.5
…ir own values

A member's own values were keyed by inner port id. Every instance of a set's stored graph holds its inner ports under the same ids, so two instances of a nested set inside one member named the same ports twice: a sync of the outer set, or a save and load of it, gave both instances the values of one.

Values are now keyed by path: the port's id, prefixed by the ids of the subgraph nodes it sits inside. The two instances are distinct nodes in the member, so their ports are told apart. Saving, decoding and re-instantiating a sibling all go through the one keying, Graph.publishedInletsByPath, which replaces publishedInletsRecursive.

Contract test: nestedInstancesInOneMemberKeepTheirValues, now an ordinary test here.

Via Opus 5.5
…sync

A sync replaces a sibling's sub graph, and every subgraph node nested in it, with a fresh instance. The canvas held the subgraph nodes it had entered, so one standing inside a nested node of a sibling was left showing a retired graph: edits made there reached no member and synced nowhere.

GraphCanvasContext keeps the path it entered and resolves entries from it, looking each node up by id in the graph it was entered from. Instances share inner ids, so the lookup finds the node that replaced the one entered. A node that has gone is kept as entered, as before.

Contract test: canvasInsideSiblingFollowsSync, now an ordinary test here.

Via Opus 5.5
A member brought back by undo kept the design it left with. Its set had moved on meanwhile, and the member's next edit pushed the old design onto every other member. Redo of Duplicate as Clone had the same hole, with the source also able to change while its set was undone.

Graph.bringArrivingCloneMembersInStep re-instantiates an arriving member that is out of step with its set's stored graph, and then each member nested in it. It runs where a member comes back: restoring a deleted node, and redoing a Duplicate as Clone, which first stores the source's graph again. A document opening is not an arrival: a member saved in full has drifted, and keeps its drift.

A delete that takes a member with it now flushes any pending sync first. An edit in the member still waiting on the debounce would otherwise be lost for its siblings, and then undone in the member itself when it came back.

Contract tests: restoredMemberIsInStep, now an ordinary test here, plus deletedMemberTakesNoPendingEditWithIt and redoneCopyIsInStep.

Via Opus 5.5
Every review finding is fixed on both models, so the known-bug list and its expecting(_:) wrapper go. Divergences stay: they are what a model gives up by design. Two cases join the contract: a pending edit survives its member's deletion, and a copy brought back by redo is in step.

Via Opus 5.5
…m a watcher on every member

Edits reached a set through a watcher on every member: Combine sinks on every node and port, Observation registrations that could not be cancelled and so piled up on every port with each sync, a settingsDidChange() duty on every node that encodes state, content-revision hooks in Graph's mutation API, and a debounce. Every edit a user makes registers undo now, so the document's undo manager says when one has happened, and the set's own design comparison says which member it was in.

CloneSetCoordinator follows the root graph's undo manager, attached when it is set. When a top-level undo group closes, and after an undo or redo, every set is synced from its first member found out of step with the set, innermost members first, so an outer member is compared with its nested members already in step. A nested group closing partway through an edit syncs nothing. An edit that registers no undo, a node's settings for one, is caught by the next edit that does, and by the fixed points: leaving a canvas, closing a node's settings, and deleting a member. Graph.syncCloneSets() does the same on demand, for edits made through the API outside an undo group. Off the main thread it does nothing: a graph changed there is being built or decoded.

Gone with the watcher: CloneMemberObserver, Node.settingsDidChange() and its calls in fifteen nodes and the AGENTS.md checklist, Graph.contentRevision and noteContentChanged(), the debounce and its test seams (hasPendingSync, discardPendingSync, settle, debounceInterval), and the unused syncCloneSets(enclosing:). A save no longer flushes from its background thread with DispatchQueue.main.sync, which could deadlock and raced the encode: syncs run as edits are made, and a member an edit has not reached is written in full.

The contract tests drive sync through undo groups: an edit's group syncs as it closes, undo and redo sync, a nested group waits for the edit, an unrecorded edit syncs with the next recorded one, and a background save syncs nothing. One of them, that values arriving over wires are not edits, fails on this model until the next commit: a wired inlet's value still counts as design here.

Via Opus 5.5
… over a wire

Three review findings shared one cause, a rule for which inlet values are each member's own that was written five times over and wrong in two ways. Every published inlet counted, at any depth, so a knob that a plain nested sub graph publishes, set on its proxy in the member's own canvas, never reached the siblings and never showed as a difference. And a value that arrives over a wire inside the member counted as design, so a member whose wired values had moved at runtime was never in step with its set and was always saved in full.

One rule now, stated once: an inlet's value is not design when it is a member's own or driven. A member's own values are its published inlets at its edge, and those further in published onward, proxy by proxy, to that edge; a nested member's edge is its own. A driven value arrives over a wire, inside the graph or onto the proxy that forwards it. Graph.designComparableJSON applies it to an encoded graph for comparison, and Graph.memberOwnInlets to a live one, for the values a member saved as a reference keeps and those a re-instantiated sibling keeps. Member values stay keyed by path.

A member's saved values are read one at a time, so one that cannot be read, of a plugin port type not loaded say, costs that value rather than the whole member. The stored graph's comparable form is made once per stored graph rather than on every comparison.

Tests: the plain nested knob reaches the sibling; members with differing wired values still save compactly; a member with one unreadable saved value loads with the rest; the compact save test publishes its nested knob on to the member's edge; and values arriving over wires, which the previous commit left failing here, are no longer edits.

Via Opus 5.5
…er's

A member's proxies carry ids of their own, since its siblings wrap inner ports under the same ids in one parent graph. One made by a rebuild, as a port is published, took a random id. Every instance of a nested member made its own, so the outer members holding them no longer encoded alike: each was out of step with its set, saved in full, and given new ids again on every load.

A member's new proxy now takes an id derived from the member's id and the inner port's. Instances of a nested member share both and agree; members side by side in one graph differ. Undoing an unpublish, which re-makes the proxy, puts the parent's wires back on it whatever id it now has.

Tests: a nested member's new proxy has the same id in every instance and both outer members stay in step; and, in the contract, undoing an unpublish inside a member puts the parent's wire back on the sibling's proxy.

Via Opus 5.5
…etion that synced into it is undone

The case template's retired-node fix answers. On this model it runs as a known issue under siblingUndoIsForgotten: re-instantiation forgets the sibling's undo history.

Via Opus 5.5
… too

A value set through its control is now undone against its parameter. Re-instantiating a sibling forgot the undo steps aimed at its retired graph, nodes and view models, but not at its retired parameters, so an undo could land on a parameter no longer in the document and change nothing.

forgetUndo now drops those too.

Via Opus 5.5
Each set synced from the first of its members found out of step with it, in document order. That works only if a member is never out of step without having been edited: anything that leaves one looking edited, two unsynced edits, or a member whose encoding changes across a load, has it push its design over the edit just made. Finding the source that way also encoded every member of every set on every undo group in the document, whatever the edit.

The edit now names its source. Every undo step registers through Graph.registerUndo on the graph the edit was made in, and the coordinator notes that graph. When the undo group closes, and after an undo or redo, which register their reverse the same way, each set around a noted graph syncs from its member there, innermost first, the later edit winning where one set was edited in two members. The fixed points name the graph they know was edited: the canvas left, the node whose settings closed, the member being deleted. Graph.syncCloneSets(editedIn:) does the same for an edit made through the API outside an undo group; syncCloneSets() syncs whatever has been noted. Nothing is compared to find a source, so nothing is encoded but what a sync or a save needs.

The contract tests name where each direct edit was made. Two cases come from the template model's review: an edit after a synced wire is kept, and synced wires and notes leave members saving compactly. Root graphs also count their syncs observably, for a view standing inside a member; see the next commits. Two background-save tests that failed now and then are fixed: one read a decoded graph released before its assertions, which tore down its ports, and one made an undoable edit on the main thread, whose undo group the run loop closed and synced partway through.

Via Opus 5.5
…e save thread, unreadable sets, new locations and siblings' wires

The fixes the second review found on both models, as made on the template branch:

- Only a wire that is on drives its inlet. A value set by hand behind a switched-off wire was left out of the design comparison and of a member's own values: it never reached the siblings, and a compact save and reload lost it.
- The design comparison's form of a set is made when the stored graph is set, on that thread, so a save on its background thread only reads it rather than filling a cache a sync was clearing.
- A set that cannot be read is kept as it was saved and written back. A member saved as a reference to it, or one its stored graph cannot make, such as a member nested in its own set, opens as an empty stand-in in no set, reports why with an error status, and is written back exactly as it was read, rather than being dropped and lost at the next save.
- Saving to a new location rewrote file references in the members but not in the stored graph, so every member with one drifted and was re-instantiated at the next sync. The root graph now stores each set's graph again from a member when references are rewritten.
- A sync that takes a proxy from a sibling keeps the parent's wires onto it, by the inner port it stood for, and puts them back when an undo or redo brings the proxy back. A proxy published afresh comes back unwired, as in the member edited.

The contract file carries the tests for each. One model test changes with them: a set containing one of its own members now keeps that member as a stand-in saying why, rather than dropping it with a diagnostic.

Via Opus 5.5
…e recorded, and an Environment member keeps its lighting

The second review's findings on this model:

- A sync replaces a sibling's sub graph, and nothing a canvas showing it reads changed, so the view could go on drawing the retired graph. GraphCanvasContext's entries now read the root graph's sync count, which the coordinator bumps after each pass.
- Only the top graph of a new instance took the undo manager. A canvas standing in a graph nested inside it then made edits that registered nothing, so they were never noted for a sync. Every graph in the instance takes it now.
- The new graph was handed the undo manager before its owner was wired, so it took itself for a document's root and attached a coordinator of its own to the document's undo manager, one more with every sync. The owner is wired first.
- An Environment member's new IBL scene started without the environment map and intensity, which apply only when their inputs change. The new scene marks both changed.

Test: a canvas standing in a nested graph of a sibling is told of the sync, and the graph it shows has the document's undo manager.

Via Opus 5.5
Re-instantiating a sibling replaced its sub graph and every object in it, so the sibling's undo steps, aimed at the retired graph, its nodes, view models and parameters, were dropped: editing one member, then another, left the first edit impossible to undo. Dropping them was also what kept undo safe, since an undo manager does not keep its targets, and a step aimed at a retired graph would have crashed once it was released.

Steps now find what they act on again. Inside a member, Graph.registerUndo registers each step against a CloneUndoLocation, which the step holds: the path of subgraph node ids from the root to the graph the edit was made in, and the target's own id, a graph, node, view model or parameter, or the object itself where it has none. Undoing resolves the target in the instance the document holds then, which carries the same ids, runs the step there, and notes that graph as edited, so the sync that follows starts from it. The objects a step captures besides its target are found the same way where Graph is handed one that a sync retired: a node to delete, a wire to disconnect or switch, a port to publish or rename, a note to edit, each by id. A retired graph points to the instance that replaced it, so a step that registers its reverse on the graph it captured registers it there; the parameter controls hold their graph for that rather than weakly. Embedding a selection in a subgraph is not followed: undone after a sync, it does nothing rather than act on retired nodes.

This is the decode model's cost, carried on this branch only; main's undo is unchanged. The contract's siblingUndoIsForgotten divergence goes, with its tests now ordinary on both models, and a case is added: a value set through its control in a sibling can still be undone after another member's edit has synced into it.

Via Opus 5.5
…er the encoding

Which inlet values are not part of a member's design, its own and the driven, was written twice: once as a walk over an encoded graph, which the design comparison stripped values with, and once as a walk over live ports, which saves and syncs read the member's own values with. Both had to agree, and a per-node exception, the likely next step, would have needed adding to both.

Graph.nonDesignInlets is now the one place: it walks an encoded sub graph and returns the graph with those values removed and the inlets it found, by path, each as the member's own or driven. The comparison takes the graph. memberOwnInlets encodes the live graph, takes the member's own inlets and looks each up by its path. Saving and syncing a member now encodes it once more to read them; both are rare beside an edit.

The walk depends on the names of a few encoding keys, listed where it is written. A contract test reads a member with an own inlet, a driven one and an own one two levels down from its encoding, so a change to how ports or graphs encode fails there rather than silently leaving every member out of step.

Via Opus 5.5
A document writes every member whole, so opening one never depends on its set's stored graph being readable, and a set that can't be read leaves its members open as they were saved. The reference form stays internal, for nested members in a stored graph and for instantiating a sibling, under Graph.cloneSetReferencesKey; the document-level key and the stand-in for an unreadable member are gone. The contract tests check members are in step with their set after a save in place of checking they were written compactly.

Via Opus 5.5
… no arrival hooks

A settings change is now an undo step, recorded through Graph.registerUndo like every other edit, so its group's close syncs it like any other. Closing a node's settings no longer syncs the set itself.

Every edit the editor makes is now on the undo stack, and undo and redo run it in order. A member that undo brings back, or a copy that redo brings back, finds its set as it left it, with any edits made since already undone. So deleting a member no longer flushes a pending sync, and bringing back a deleted member or a copy no longer reconciles it with its set.

The contract tests for these now use undoable edits: a member undone back in after an edit is in step, an edit and a deletion undone in turn leave the members in step, and a copy redone after an edit is in step. A settings change made in the settings view reaches the sibling, and undo takes it back from both.

Via Opus 5.5
Leaving a canvas no longer syncs the sets around the graph left, unlinking a member and Duplicate as Clone no longer sync a pending edit first, and redoing Duplicate as Clone no longer refreshes the set from the source. Every edit the editor makes registers undo, so its group's close has already synced it by the time any of these run, and undo and redo run in order, so a source that redo brings back finds its set as it left it.

An edit made through the API with no undo now syncs only when syncCloneSets(editedIn:) is called for it.

The contract tests that left a canvas after an edit with no undo are gone. The unlink and Duplicate as Clone tests now make the edit in its own undo step first: the edit reaches the siblings, or the copy, and the edit and the unlink undo in turn.

Via Opus 5.5
…one membership hook

Adding, deleting and restoring a deleted node each called the clone code inline, twice for an arrival: unlinking a member that arrives inside its own set, and refreshing the sets of the members it takes along. All three already call Graph's membership hook, so that hook now makes the one call, updateCloneSets(around:), and the clone code lives with the rest in Graph+CloneSet.swift. A path that adds or removes nodes in future reaches the sets by the hook it already has to call.

The unlink now runs once the node is in the graph, after its view model exists, so the view model takes the change from the subtitle subject. The self-nesting contract test now checks the moved member's view model shows it unlinked. A new contract test pastes a member into its own sibling: paste strips clone links, so the pasted node arrives as an ordinary subgraph, the set keeps its two members, and the other member receives the plain subgraph in step.

Via Opus 5.5
… unlinked, which catches a document opening

The self-nesting rule compared what arrived only with the members around the graph it arrived in, so a member arriving with a member of its own set already inside it kept both. No edit makes one, but a document saved that way did: each graph is decoded, and its nodes added, before the member around it exists, so the members around a graph are never there when its contents arrive.

The rule now also walks what arrived, unlinking any member a member of the same set encloses within it. Opening a document adds each member to its graph after its own contents are decoded, so the walk at that point checks every member inside it, and no separate load step is needed.

A new contract test saves a document, writes the member's plain nested subgraph into it as a member of the member's own set wherever the document holds it, and opens it: the set has its two members, with nothing inside either still a member. It fails without the change.

Via Opus 5.5
The contract tests compared what a sibling holds after a sync, never what it computes. On template, a Math Expression change in one member reached the sibling's expression while the sibling went on producing the old result: the sync replaces the sibling's node under the same ids, and two faults on main, now fixed there, left the replacement unrun. The sibling's proxies kept forwarding to the old node's ports, and the renderer's lifecycle review, keyed by node id, never started the replacement.

The new test publishes a Math Expression's input and output in a member, duplicates it as a clone, wires one upstream into both members and a reader out of each, and runs a renderer: after the expression changes in the settings view, both readers see the new result on the next frame, and again after the input changes.

Via Opus 5.5
A member gives a proxy it makes an id derived from its own and the inner port's, but a member's proxies made before it joined its set have their inner ports' ids. A proxy made again over a port, as replacing the port remakes it and undo brings it back, took the derived id instead, so the parent's wires, which find a proxy by id, lost it: a strategy change in the member a set was started from dropped the wire onto its published inlet.

A member now remembers the id each proxy it has made had, by inner port, and a proxy made again over that port takes it. The decode-only test of unpublishing inside a member now expects the proxy that comes back to keep its id, still without the wire unpublishing took.

Via Opus 5.5
…o a member's published ports

Two contract tests wire one upstream into each of two members' proxies. A settings change that removes the published inlet takes the parent's wire off every member, undo puts one back on each, and redo takes them off again. A strategy change that retypes the published inlet keeps the wire on every member, and undo keeps it there.

Via Opus 5.5
Graph.registerUndo(_:) now hands a step the graph the document holds when it runs and has it find what it acts on there by id, which is what CloneUndoLocation, the retired graph's pointer to its replacement and the id fallbacks in delete, disconnect, setConnection and the publish edits did for steps made inside a member. All of those go. Duplicate as Clone, set renames and membership changes register through the funnel, holding the source, the copy and the member by id; the copy taken out by an undo is the object redo puts back.

Via Opus 5.5
Every undo step made in a sibling still acts on what stands under its ids after another member's edit has synced in: a wire, a note edit, Create Subgraph, a move of a node a settings change rebuilt, and redo as well as undo. Undoing an unpublish gives every member its own published value back; a canvas inside a sibling keeps the document's undo manager; an edit to a nested set from a member outside the outer set leaves the outer members in step. A host with no Metal device now fails the suite rather than passing it unchecked.

Via Opus 5.5
Unlink moves each member nested inside the unlinked one to a set of its own, registering each move on the graph the unlink was made in. A step now finds its node by id in the graph it was registered on, and a nested member is not in that graph, so undoing the unlink found nothing and left the nested members in the detached set. The membership step is now registered on the member's own graph.

Via Opus 5.5
Unpublishing a port in one member makes its value design, so the sync gives every sibling the edited member's value. Undoing it published the port again, making the value each sibling's own once more, but the sibling's own value was gone: every member kept the edited member's.

A sibling now keeps its own values for inlets a sync made design, by path, for the session, and puts them back when undoing the change makes those inlets its own again, as it already keeps the parent's wires onto a proxy a sync took. An inlet published afresh takes the value it has, as in the member edited.

Via Opus 5.5
…tantiation is internal

The glossary entry sits under Subgraph System, where the template branch has it. AGENTS.md says plainly that this model puts clone-specific code on ProxyPort: a member's proxies take ids of their own, and a re-instantiation re-points each at its port in the new instance. Comments that described a clone record, which this model has no use for, or a save on another thread, which no longer reads the stored graph's comparable form, go. reinstantiateCloneMember is used only within the module and its tests. A duplicated import left by the rebase goes.

Via Opus 5.5
A sync re-instantiates every sibling, so each undo step made in a member must find its target in whatever instance the document holds when it runs. Tested here: a value set in the inspector on a nested member's published inlet, a member's own value set from the parent's inspector, Duplicate as Clone nested inside a member (leaving no member naming a set the document no longer holds), and a step made in a sub graph before it joined a set. A retired instance is released with its nodes once the renderer next plans.

Via Opus 5.5
… after the sync

The move made in B syncs into A, and a model may put another object in A's place under the same id; the test now opens the settings session on the node A holds then, as the file's own rule asks.

Via Opus 5.5
…t puts every sibling back exactly as it was

The coordinator now syncs as the document's undo group closes, at grouping level one and outside undo and redo, so whatever the sync registers lands in the edit's own group. It no longer syncs after undo or redo, and edits replayed by them are not noted.

Inside an open group, a sync registers one step that puts the set's stored graph back, and one per sibling, on the sibling's graph and found by its id, that holds the sibling's sub graph encoded whole and, for each of its proxies, the published state and wires at each level of the proxy chain above it. The step decodes the sub graph afresh under new graph ids, puts each chain back, notifies a canvas standing inside the sibling, and registers the opposite step with the state the sibling had when it ran, so redo works the same way. Nested sets work innermost first, as each outer step holds the state the inner sync left. A sync with no undo group open runs without undo registration and registers nothing, and a sync that changes nothing registers nothing. The sync now prunes dangling wires in every graph around the sibling, not only its own, so a proxy published up several levels loses its wires as the edited member's does.

This makes the session-only stores redundant: the wires and values a sync took off a sibling's proxies, the restore branches that consulted them after an undo, and the proxy lookup that existed only for them are gone.

Via Opus 5.5
…res the set designs on the main thread

Saving rewrites file references in the live graph, and when it rewrote any, the root stored every set's design again from a member, on whatever thread the save ran. A save can run off the main thread, where it could change a design while a sync read it. Off the main thread the designs are now stored on the main queue instead. The file written then keeps the design as it was, its paths absolute and pointing at the same files, and the next save writes them relative.

Via Opus 5.5
Its comment named a shared template as a later use, which reads as one of the clone set models.

Via Opus 5.5
…, in place of the sync count

A canvas standing inside a member reads a count on the root graph, since SubgraphNode.subGraph is not observed. The coordinator bumped it after each sync pass and the undo of a sync bumped it again. replaceSubGraph now bumps it, the one place a sub graph is swapped, so whatever swaps one is followed, and the coordinator counts nothing.

Via Opus 5.5
…om the member's and the port's

A member made a new proxy's id by hashing its own id with the inner port's, so that instances of a nested member would agree without being told. They are told: a member keeps each proxy's id once made and encodes its proxies with it, so every instance decoded from it, and every re-instantiation, carries the same ids. The clone tests pass with the hash replaced by a fresh UUID, including the nested-set ones, where the fresh id is taken 30 times. The hash and CryptoKit go.

Via Opus 5.5
…the main thread is logged, and the member count is kept as membership changes

Five findings of the final review shared by both models:

- Unlinking a member gave every nested set inside it a set of its own, parting its nested members from members of those sets anywhere in the document. A nested set now parts with the member only where its other members are inside the siblings left, which were linked to it through the set left; one with members elsewhere, or none, keeps its links. Unlinking the last member of a set leaves its nested sets as they are.
- An edit made in a member off the main thread was dropped by the coordinator in silence, with its undo step recorded. Sets sync on the main thread alone; such an edit, and a sync asked for off it, are now noted in the log.
- A set whose stored design was not a JSON object read as a set with an empty design, and was saved back that way. It now fails to read, and is kept and written back as it was saved, like any set that cannot be read.
- A duplicate or paste that could not read a rewritten node said so without the error. It says why again.
- A member's badge and status counted the set's members on every refresh, each count a walk of the whole document, twice per member per membership change. The count is now kept on the member, written where membership changes.

Via Claude Fable 5.1
…neath its proxy

A sync rebinds a sibling's proxies in place to the new instance's ports, and with them the parameter each proxy forwards; the parent's published group, and the proxies standing for the proxy further up, still held the retired instance's parameters. A published slider on a synced sibling drove a torn-down graph until something else published or unpublished there. Each level above is now rebound before its group is pointed at the parameters its ports hold, with labels and the lifecycle review left alone, since nothing was published or unpublished.

Two additions nothing used go: the findAllUUIDs overload over a parsed object, and the list of wires pruneDanglingConnections returned.

Via Claude Fable 5.1
…y entry, and leaves the rest of it alone

The decode model's representation, applied as a patch. A set still holds one stored graph, members are still instances of it under the same inner ids, and the member edited last is still encoded as the stored graph. What changes is the last step: instead of replacing each sibling's sub graph with a fresh instance, the sibling's sub graph is encoded the same way and the two encodings are compared by id, node entry by node entry, wire by wire, note by note, with the values that are not design left out. What differs is changed on the live sibling and what matches is left alone, the node objects and whatever they hold at runtime with them. A node whose entry differs is decoded from the stored entry and put in place under its ids where the old one stood; a node moved or renamed is moved or renamed; a plain sub graph nested in a member is patched the same way inside. The sibling's own values go back on afterwards. Every change registers its own undo step into the edit's group, through the operations the editor already has, so undo and redo put the siblings back with the edit and sync nothing.

Gone with the whole-instance replacement: replaceSubGraph and the proxy rebinding and identities it needed, the replacement counter the canvas read, the per-sibling sub graph snapshot the undo step held, the subclass hooks for a swapped sub graph, and the renderer stop of a retired graph. Wires are compared against the live graph rather than the encoding patched from, since a node put aside takes its wires with it and a plain nested sub graph's proxy carries its inner port's id. Nodes and notes keep the stored graph's order, undoably.

The contract declares no divergence: a sync leaves a sibling's untouched nodes in place. The model tests that asserted whole-instance replacement now assert the replaced node's lifecycle, its release once its undo step is gone, and a published group following a replaced node beneath its proxy.

Via Claude Fable 5.1
…d, and touches a node's own values only where it made the node again

Review findings on the patch model, the first group:

- A stored entry that could not be decoded partway through a patch left the sibling half-changed, its own values not put back, where the model before failed before touching it. The patch is now planned first, every node it needs decoded, and applied only once the plan is whole; a sibling that cannot be patched is left as it was.
- The member's own values went back on every member-own inlet after every sync, which wrote to, and dirtied, every node holding one, including the nodes the patch had left alone. They go back only on a port the patch made again.
- A node whose entry differed in placement and in something else was moved and renamed, with the steps for it, and then made again anyway. A node made again is not placed first.
- The stored graph was parsed, and stripped of what is not design, once per sibling rather than once per sync.
- ProxyPort's rebindInnerPort, which only a re-instantiation called, goes with it; a doc reference to the removed entry point is updated; the restore of member values is the SubgraphNode's own again.

Via Claude Fable 5.1
…proxies, are taken in place; a node made again leaves the inlets it feeds holding what they have

Review findings on the patch model, the second and third groups:

- A node whose entry differed in a port's publishing, published name or design value was made again, with everything it held at runtime. Those are taken in place now: the port is published, named or given the value, through the graph's own undoable operations. A parameter's label follows the port's published name, so it counts as state too. What differs in anything else, a node's settings, still makes the node again.
- A sub graph nested in a member was patched inside only where nothing but its inside differed; publishing a port inside it changes its proxies' entries as well, so the whole nested node was made again. The inside is patched whenever only the inside and the proxies differ, and the proxies are then put under the ids the stored graph has for them and published as it has them. A nested member's inside is its own set's; its proxies are the outer graph's, and every instance of the member now holds them under the same ids (SubgraphNode.remakeProxies).
- A node made again was deleted with its wires, which sent nil to every inlet it fed, and the wires put back pushed the new node's outlet before it had executed. The wires stay through the replacement, bound to the replacement's ports without a send (reattachConnections(to:sending:)), and those onto ports it does not have are dropped, in this graph and, for a proxy that went with a published port, in the graphs above (pruneDanglingConnections is back). The inlets a replaced node fed hold what they have until it sends.

Tests: port state in place and undone, a publish inside a nested sub graph leaving the nested node and its other nodes in place, a replacement leaving the fed inlet alone, and a canvas standing inside a sibling's nested sub graph following it through a sync that takes it and the undo that brings it back. The release and published-group tests now make a node again through a settings change, since a publish no longer does.

Via Claude Fable 5.1
@tobyspark
tobyspark added this pull request to stack #398 October 11, 2026 17:21
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