Model node tree editing in TLA+, and fix the four defects it found - #120
Merged
Conversation
Adds NodeTree.tla, which models the document shape under every editing gesture -- select, drag between rows, drop from the palette, drop on canvas, delete, duplicate, copy/paste, wrap -- and checks five safety properties with TLC. The tree is modelled as a general graph rather than a tree, because the code manipulates parent links and child arrays independently; whether the result is still a tree is the question. Four of the five properties fail against the design as shipped: WithinCapacity, in two moves. Import a mesh, drop a box on it. `text` and `mesh` take no children but are not in NODE_KINDS.primitives, so addNodeFromData -- which asks arity of the dropped node and list membership of the target -- hands them a child. toSDFNode reads children[0..1] and nothing after, so the box never reaches the mesher, and incompleteNodeIds flags only nodes with too few children, so nothing warns. The shape is in the outline and is not in the model. Five more paths reach the same state by appending past a kind's arity. IsATree, in five. Drag an operation that wraps one shape onto another node: moveNode deletes the source with removeFromTree, whose promote-sole-child rule hands the shape up into the vacated slot, then re-attaches the source still holding it. One id, two places. NoSilentLoss, in five. Delete one operand of a union, then delete the union: the vacated slot holds an _empty, so children.length is still 2, so the survivor is not promoted -- it is destroyed. Deleting a union that had never lost an operand would have promoted it. SelectionValid, in four. Delete an ancestor of the selected node and selectedNodeId dangles, because removeNode cleared it only when the removed id was the selected one. addNodeFromData then bails at `if (!targetNode) return`, so the palette stops responding with nothing on screen to say why. The fixes, verified in NodeTreeFixed.tla (280,582 distinct states at MaxNodes=7, MaxOps=5, no error): moveNode detaches instead of deleting; every append site routes through one attachChild that fills a vacated slot, else appends if there is room, else unions the full parent in place; the drop target is classified by arity like the dropped node; promotion counts real operands; and the selection is clamped in commit, the funnel every mutator already goes through. All nine counterexamples are replayed in modelerStore.treeShape.test.ts against the store, with three controls that must not change. One existing test asserted that a drag into a full union yields three children -- that assertion was the defect, and it is updated. An operation dropped on empty canvas is a silent no-op. The model reports it; it breaks no invariant and is asserted by an existing test, so it is documented and left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
🚀 Preview deployed: https://worktree-tla-node-tree.sinter.pages.dev (updates on every push to this PR) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Formal model of the node tree under editing, and the fixes for what it found.
specs/NodeTree.tlamodels the document shape under every gesture a user can make with a mouse — click a row to select, drag a row onto another row, drag a shape in from the palette, drop on empty canvas, press the X, Cmd-D, Cmd-C/Cmd-V, wrap from the Add menu — and checks five safety properties with TLC.The tree is deliberately not modelled as a tree.
kidis a general graph, because the implementation manipulates parent links and child arrays independently; whether the result is still a tree is the question, not an assumption. Kinds are abstracted to arity, with one distinction that does most of the work:textandmeshtake no children but are not inNODE_KINDS.primitives, andaddNodeFromDataasks arity of the dropped node and list membership of the target.What failed
Four of the five properties fail against the design as shipped.
WithinCapacitytoSDFNodereadschildren[0]/children[1]and nothing after, so it never reaches the mesher;incompleteNodeIdsflags only nodes with too few children, so nothing warns. The shape is in the outline and is not in the model. Five more paths reach the same state by appending past a kind's arity — move, duplicate, paste, add-child, and the last branch of the palette drop.IsATreemoveNodedeletes the source withremoveFromTree, whose promote-sole-child rule hands the shape up into the vacated slot, then re-attaches the source still holding it. One id, two places —findNodesees only the first,updateInTreerewrites both.NoSilentLoss_empty, sochildren.lengthis still 2, so the survivor is not promoted — it is destroyed. Deleting a union that had never lost an operand would have promoted it.SelectionValidremoveNodecleared the selection only when the removed id was the selected one, so it dangles;addNodeFromDatathen bails atif (!targetNode) return, and the palette stops responding with nothing on screen to say why.Acyclicholds at this bound, but only conditionally — the model's descendant guard consults the true graph where the implementation'sfindNodesees only the first occurrence of a duplicated id. That is an argument for fixingIsATree, not a reason to relax about cycles.The fixes
Verified in
specs/NodeTreeFixed.tla: all five properties hold, exhaustively atMaxNodes=6, MaxOps=4(11,301 states) and again atMaxNodes=7, MaxOps=5(280,582 distinct states, depth 6).moveNodedetaches instead of deleting.removeFromTreetakes apromoteflag; a move passesfalse, so the subtree travels whole.attachChild. Fill a vacated slot; else append if the kind has room; else the parent is full, so replace it in place with a union of itself and the newcomer. Every append site routes through it. Unioning in place is not a new gesture — it is whataddPrimitivedoes to the root and what dropping a shape on a shape already did.commit— the one funnel every mutator already goes through, and wheresurvivingwas already being used for undo/redo.Verification
src/store/modelerStore.treeShape.test.ts. They fail against the old design and pass against the new one, alongside three controls that must not change.tsc --noEmitclean.specs/check.shnow runs both specs;NodeTreeis expected to produce a counterexample, asWorkerBridgeandUndoHistoryalready are.One existing test asserted that a drag into a full union yields three children. That assertion was the defect, and it is updated to the capacity-respecting shape.
Reported and deliberately not changed
An operation dragged to empty canvas falls through every branch of
addNodeFromDataand does nothing, silently. The model reports it. It loses nothing, breaks no invariant, and is asserted by an existing test, so it is documented inspecs/README.mdand left alone — a UX gap rather than a defect.🤖 Generated with Claude Code