Skip to content

diagram: one flow geometry owner, gesture planner and executor - #1066

Merged
bpowers merged 34 commits into
mainfrom
diagram-editing-overhaul
Sep 12, 2026
Merged

bpowers merged 34 commits into
mainfrom
diagram-editing-overhaul

Conversation

@bpowers

@bpowers bpowers commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Fixes #830
Fixes #833
Fixes #51

Why

An adversarial audit of the interactive editor found about fifty defects that trace back to three structural problems (details and evidence in docs/design-plans/2026-09-10-diagram-editing-core.md):

  1. Flow geometry had no owner. Seven straight-to-bend implementations, six face-selection rules, nine valve policies. Endpoints committed at stock centers, pipes ran along stock faces or through stock bodies, valves teleported, a detached cloud landed half a stock width from the pointer.
  2. Preview and commit were different code. Drags jumped on release (Flow endpoint detach off a stock has no live preview (only valve moves) #830 and variants); a no-move click detached a flow from its stock or deleted a link; red drop targets still committed; Escape mid-drag crashed the Canvas.
  3. The commit path could save a model that disagrees with its diagram. Handlers built full-replacement payloads from state read before an await, committed the view when the patch failed, and undo disposed an engine an in-flight patch was using. Duplicate stock inflows were integrated twice; creating a variable under an existing name replaced it whatever its kind.

What changed

  • src/diagram/flow-geometry/ -- the pure geometry core (terminals, route/routeEnd, offsetSegment, valve, heal) holding the plan's invariants: orthogonal pipes (XMILE 1.0 section 6.1.2), perpendicular exit from stock faces, no body crossing, valve on path.
  • src/diagram/gesture-planner/ -- every Canvas gesture is planGesture(base view, gesture, press, current); the preview renders its output and pointer-up commits the same output.
  • src/diagram/project-controller.ts -- committed state + pending queue per model, a state token, one serialized engine executor; a failed patch rolls back the optimistic view (reverses the diagram: a failed flow-attach patch silently discards the drawn flow #820 commit-anyway policy, user decision); model ops are derived from the view diff at dequeue (view-model-sync.ts).
  • Old routing/interaction code deleted: group-movement.ts, flow-attach.ts, canvas-interaction.ts, selection-logic.ts, cloud-utils.ts, merge-live-view.ts, the routing half of drawing/Flow.tsx. Rendering is unchanged (TS/Rust SVG parity stays byte-identical).
  • Engine import/layout fixes found by measuring every flow in test/: XMILE stock-side sign errors and top-left-as-center; MDL endpoints left off-face; 21 MDL flows imported unattached (invisible); clouds off endpoints; corner endpoints and off-valve pipes in layout; incremental layout rewriting untouched flows; duplicate flow elements; duplicate stock flow entries integrated twice (now one set, with a RepeatedStockFlow advisory).
  • core: the engine owns the absent-label-side default (Center), matching the Rust renderer. Simulation series are grouped per variable once per run (groupSeriesByIdent / projectAttachSeries), reusing unchanged arrays, so a landed edit only redraws sparklines whose data changed.
  • notebook-widget: the engine-unavailable notice's Reload remounts the Editor instead of reloading JupyterLab.

Decisions

  • Failed patch rolls back (user).
  • Segments always leave stock faces perpendicular; an aligned stock-to-stock perpendicular drag slides within the faces, then forms a bracket (user); corpus: 593/595 face endpoints already perpendicular.
  • MDL flow attachments follow the model's stock lists.
  • Incremental layout never rewrites a flow the patch did not touch.
  • Upsert keeps full-replacement semantics; the editor prevents name collisions instead.
  • Routed flows avoid every stock body in the view, not just their own terminals: a pipe through a stock reads as attached to it. It is a ranking preference (the route stays total and commit validity is unchanged); G6 stays terminal-only. The cost is a documented one-time step around a stock as the pointer passes its edge.
  • Escape cancels a live drag, restoring the base geometry.
  • A landed edit that only changes geometry refreshes nothing but the save: a view feeds no simulation, diagnostic or equation dependency.

Evidence

Each phase was implemented by one agent and adversarially reviewed by another (mutation testing of the new tests; real-engine fuzzing where it applies); findings were fixed before commit.

  • Invariant checkers (tests/support/flow-invariants.ts, view-invariants.ts): every arm has a valid fixture and a mutation reporting exactly that arm; checker constants are pinned literals, not imported from production; the corpus test runs six real imported models through the WASM engine and fails (not skips) under CI when the wasm is absent.

  • Geometry core (tests/flow-geometry-*.test.ts, 1421 tests): route/routeEnd and offsetSegment tables derived from terminal kind x path shape x drag direction; 1px gesture sweeps pin exact per-revolution transition budgets (a stock circling its cloud: 12 route changes per revolution; reintroducing the removed hysteresis margin fails 35 rows); seeded fuzz holds the strict invariants on every routed frame of every operation (0 violations at 200 seeds); 85/89 mutants killed, 4 justified equivalent.

  • Commit path (tests/project-controller.test.ts, editor-engine-races.test.ts): the audit's race repros (lost attaches, invisible variables, undo during a pending patch, duplicate inflows, name collisions) ported against the real WASM engine; a 400-seed real-engine fuzz with injected patch/read-back/open failures (1,767 faults, 802 resyncs, 108 seeds reaching engine loss) found 0 model/diagram invariant violations and 0 unexplained lost edits or drafts.

  • Gesture planner + Canvas (tests/gesture-planner*.test.ts, canvas-gestures-*.test.tsx, editor-gestures-engine.test.ts): per gesture kind, the last preview frame's DOM equals the committed frame's; every Canvas audit probe (click-detach, link-arrowhead delete, Flow endpoint detach off a stock has no live preview (only valve moves) #830 preview/commit, red-target commits, Escape crash, stale state after exceptions, pointercancel commits) re-run against the new Canvas: fixed or intentionally changed, none still broken.

  • Engine import/layout (corpus of 490 importable models in test/, measured through libsimlin): imported flows passing every strict invariant 364/572 -> 565/572 (the 7 remaining are Simlin-native saves no importer touches; every Vensim, Stella and xmutil flow passes); 363/364 already-valid flows byte-identical (the one that moved drew a cloud where the model links a stock); engine auto-layout 671/745 -> 737/737; 0 sub-minimum legs or valve margins imported or generated; incremental layout changes 0 untouched flows across 7 probe edits; a repeated <inflow> in queue/conveyor models now simulates the same as without the repeat.

  • Rendering parity: tests/svg-rendering.test.ts (TS vs Rust byte-identical SVG) stays green, plus a new row for center label sides.

  • Stock-crossing sweep (reviewer probe, not committed): 1px pointer sweeps over five base shapes and every flow of ~20 imported models, across endpoint drags, stock/cloud moves, createFlow, slideValve and offsetSegment: 0 routed frames through a stock the flow was attached to (1,693 frames before on one shape), 0 terminal crossings where G6 holds, 0 through any other stock; walking near stocks shows one-time steps around a body, no flicker.

  • Real browser (src/notebook-widget/e2e/diagram-editing.spec.ts, Playwright Chromium over the built widget bundle and the real wasm engine, asserting on rendered SVG and on the snapshots the fake kernel receives; 9/9 e2e pass):

    1. stock drag bends a cloud-ended flow into an orthogonal L entering the face perpendicular; preview equals commit;
    2. click on a stock-attached flow arrowhead changes and saves nothing;
    3. detaching an end previews a cloud at the pointer, commits the last preview frame, drops the flow from the stock's list, and routes around the stock it left;
    4. reattaching lands on the new stock's face and moves the flow between stock lists;
    5. a perpendicular valve drag forms a bracket whose middle follows the pointer, and dragging back restores the original path exactly;
    6. click on a link arrowhead changes and saves nothing;
    7. Escape mid-drag restores the pre-drag geometry and commits nothing;
    8. a drawn flow ends in a cloud at the pointer and takes its typed name;
    9. undo and redo restore and reapply the geometry.
      The journey found three defects, all fixed here: a detached pipe crossing the stock it left, Escape not cancelling a drag, and a ~2.8s freeze on every drag release on large models (every Sparkline rebuilt its path because geometry-only edits re-ran the simulation and series arrays were regrouped on every render).
  • Performance (e2e/diagram-editing-perf.spec.ts on C-LEARN, 4,059 view elements; headless Chromium with SwiftShader, one machine, no budget asserted):

    main this branch
    Stock drag frames p95 / max 16.8 / 33.3 ms 16.8 / 16.8 ms
    40-element selection drag frames p95 / max 16.8 / 33.4 ms 16.8 / 16.8 ms
    Stock drag release: longest frame 467 ms 83 ms
    Stock drag release: to saved snapshot 489 ms 75 ms
    Selection drag release: longest frame 450 ms 67 ms

    planGesture on C-LEARN's busiest stock is 2.34ms per frame at p95 with all-stock avoidance. The pre-commit hook is 24s warm end to end, unchanged from before this work.

What this does NOT establish

  • Real-browser behavior is established only for the journey's nine steps with a mouse in headless Chromium at zoom 1, through the widget's static harness: touch, pen and pinch input, Firefox and Safari, other zoom levels, and real JupyterLab/VS Code hosts are not exercised; the rest of the gesture evidence is jsdom. The perf numbers compare runs on one machine with software rendering and do not predict user hardware.
  • Label placement: flow labels keep their stored side, so a valve that ends up on a vertical segment near a corner can have its label sit over the adjacent pipe (visible in the journey's stock-drag screenshot). Picking a side automatically would override a side the user set with the label gesture, so it needs its own product decision rather than a silent rule here.
  • Corpus coverage beyond the measured flows (views with groups, additional views, link multipoint geometry are out of scope and unchanged).
  • The engine-unavailable notice and resync paths are exercised with injected failures, not with real engine crashes.
  • Visual judgment of routes the invariants allow (e.g. a created stock-to-stock flow that crosses an existing sibling flow when the two slots sit on opposite sides of its line is permitted; its valve is kept off the sibling's pipe).

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 12, 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-09-12T03:14:05.510508Z d296716 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: d2967164c0

ℹ️ 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 on lines +217 to +219
} else if (sourceMoves || sinkMoves) {
const end = sourceMoves ? 'source' : 'sink';
valid = routeMovedEnd(input, el, end, d, changed, base, stocks, moving, uids, frameStocks) && valid;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Move selected flow valves with their selected endpoint

When a multi-selection contains a flow and exactly one of its terminal elements, this branch routes the flow exactly as if only the terminal were selected. routeEnd preserves the valve's arc-length distance from the fixed end, so the explicitly selected valve can lag behind or snap instead of following the drag delta with the selected stock/cloud and flow. Preserve the selected-flow case by translating and clamping its valve after rerouting, and restore coverage for this selection/endpoint arm.

AGENTS.md reference: AGENTS.md:L99-L99

Useful? React with 👍 / 👎.

Comment on lines +441 to +443
variable: Some(name.clone()),
owner: None,
severity: DiagnosticSeverity::Warning,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep repeated-flow advisories non-fatal in the editor

For an imported model whose stock repeats an inflow or outflow, this emits a warning associated with the stock, but convertErrorDetails places every non-Units detail with a variableName into varErrors without checking severity. variableDetailsView then treats it as a fatal equation/compile error, hides the valid simulation chart, forces the raw equation editor open, and presents the generic FFI error code. Preserve the warning severity through the diagram error model or otherwise keep this advisory out of the fatal equation-error path.

AGENTS.md reference: src/simlin-engine/AGENTS.md:L170-L175

Useful? React with 👍 / 👎.

Comment on lines +203 to +207
const stock = base.get(from.stock);
if (stock?.type !== 'stock') {
return idlePlan(input);
}
source = stockTerminal(stock);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate the source stock before creating a flow

When a loaded view contains an orphan stock element or one whose ident names a non-stock variable, starting a flow from that element passes this type-only check even though the corresponding stock variable does not exist. The planner then commits a visually stock-attached flow, while buildEditOps cannot add it to the source stock's outflow list because kindAfterEdit finds no stock, leaving the model and diagram inconsistent. Apply the same stockVariableExists validation used for the sink before allowing this source.

AGENTS.md reference: AGENTS.md:L99-L99

Useful? React with 👍 / 👎.

Comment on lines +341 to +342
stock.inflows = canonical_flow_list(inflows);
stock.outflows = canonical_flow_list(outflows);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve stock-flow priority when updating lists

When a diagram edit attaches or detaches a flow on an existing conveyor, buildEditOps preserves the stock's existing declaration order and appends additions, but these assignments sort both lists alphabetically. The conveyor/queue compiler uses <inflow> declaration order as admission priority, so an otherwise graphical connector edit can change which constrained inflow is admitted and silently alter simulation results. Deduplicate canonical names while retaining their first-occurrence order instead of sorting UpdateStockFlows payloads.

AGENTS.md reference: src/simlin-engine/AGENTS.md:L160-L164

Useful? React with 👍 / 👎.

The editor rewrite in docs/design-plans/2026-09-10-diagram-editing-core.md
replaces seven inconsistent flow-routing implementations with one geometry
core, and the core needs an independent oracle before it exists: a checker
that encodes the plan's invariants (G1-G8 flow geometry, M1-M3 model/view
agreement) exactly, with strict mode for flows an edit routes and tolerant
mode for imported input the editor must accept without asserting.

The checker's constants are literals pinned by their own test rather than
imported from production, so drift in either place is caught. The scene
generator builds scenes as engine JSON through the production loader and
records, per imported shape, exactly which strict arms it must produce, so
"imported scenes pass tolerant mode" is not vacuous. The corpus test runs
the checker over real imported models through the WASM engine and fails
under CI rather than skipping when the wasm is absent. Rename exclusion in
the M2 delta check is derived through the engine's real rename path,
because the engine rewrites stock flow lists on rename and a hand-built
fixture would not.
The editor's flow routing had seven straight-to-bend implementations with
different thresholds and corner conventions, at least six rules for which
stock face a flow uses, and nine valve policies, each re-deriving
orientation from raw coordinates. This core is the one owner the gesture
planner will call: terminals with a single face-attachment owner, a total
route/routeEnd search ranked by validity, non-crossing, stickiness to the
base face, bends, axis change and length, offsetSegment (slide along a
face, hold within MIN_SEGMENT past its extent, then a stub and riser, so a
dragged bracket collapses back to straight), arc-length valve placement,
and heal for imported geometry. Every result holds the committed flow
invariants; nothing production calls it yet.

Stickiness is the only base-face preference. A per-segment hysteresis
margin was measured to flicker (20 route changes per revolution of a stock
around its cloud versus 12 without it), and a pure function of the base
view and pointer cannot have hysteresis anyway. Preserved or pinned tails
are accepted only when valid, non-crossing, and no more bent or U-turned
than the base, so a drag releases to a fresh route instead of keeping a
contorted one. The remaining large drag transitions are feasibility
changes where a preserved segment falls under its G3 minimum; two
alternative rankings were measured and neither was better.

The sweep tests pin exact per-revolution transition budgets and derived
flip bands on 1px gesture sweeps, so reintroducing the margin fails 35
rows; route, offset, heal and repro tables are derived from enumerations
of terminal kinds, path shapes and drag directions.
Edits raced the engine: handlers built payloads from state read before
earlier patches landed, engine calls could overlap, and a failed patch
still committed its view (#820), saving diagrams whose elements named
variables the model lacked. ProjectController holds the engine's last
acknowledged project plus a FIFO of pending items, and one serialized
executor runs every engine call. The rendered view is the last pending
next view, a token aborts work planned on a superseded view, and
maintenance (save, errors, connectors, sim) is coalesced behind edits
with a bound so saving is never starved. A view edit carries only its
base and next view; buildEditOps (view-model-sync.ts) derives renames,
deletes, creates and stock flow list deltas from that difference against
the committed model at dequeue, planDelete produces the delete's view,
and variable-names allocates collision-free names. Model-only edits
build their payload from committed state at dequeue.

A failed view edit rolls back instead of committing anyway: later view
edits planned on it are discarded and the token moves; a failed
model-only edit only reports. A patch whose read-back fails is resynced
before its fate is decided (a re-read keeps it, a reopen of the last
snapshot fails it after the swap), and a failed reopen latches
engineUnavailable behind one persistent notice. Details panels measure a
draft against what they last submitted and hold their key while one
exists, so landed edits, renames and reverts never overwrite typed text.

The engine owns the absent labelSide default. XMILE 1.0 section 6.1
lists top|left|center|bottom|right and names no default; json.rs reads
an absent side as Center and the Rust renderer draws it centered, while
core defaulted auxes to right, so any view upsert rewrote the stored
side and the two renderers disagreed. Core defaults to center and the TS
renderers place it as Rust does; 0 of 4,425 corpus auxes lack a side.
The Editor's engine-unavailable notice offers Reload, which defaults to
window.location.reload(). Inside JupyterLab that reloads the whole
front end -- every open notebook, its kernel connections and any unsaved
notebook changes -- to recover one widget whose project the kernel still
holds. The widget passes onReload and remounts the Editor on its seed,
the last state the kernel acknowledged, by bumping the generation
itself, carrying the live viewport, freeing an in-flight snapshot and
publishing an empty selection, exactly as a kernel-originated remount
does.
The XMILE importer judged flow takeoffs on the raw ViewObjects, where a
stock written with width/height still carries its top-left corner (the
center conversion happens later), and its left/above predicates added the
half-extent where they meant to subtract it. So covid19_severity's
"presumed new infections" imported 22.5px off its stock. Clouds were created
from the raw first/last points before 2-point straightening, leaving 83 of
103 xmutil-converted clouds up to 13.5px off their endpoints.

diagram::flow_geometry is now the one owner of where a pipe meets a stock or
a cloud, and the importer runs normalize_flow_geometry over the converted
datamodel view, where centers are known. Geometry that already holds the
invariants is not moved, so valid off-center slots survive. Otherwise the
end segment's line slides into the face's clearance span (at most
MIN_SEGMENT_LENGTH beyond it), gains a perpendicular leg of at least 3px when
the line misses the face by more, or jogs when a valid slot at the other end
pins the line. The minimum leg and the 3px corner clearance match the
editor's geometry checker. Straightening moved out of From<Flow> into the
pass, and the takeoff predicates and fixup_flow_takeoffs are removed.

Corpus, flows passing every strict invariant (researcher's measure.py):
stella 75 -> 86 of 86, vensim-via-xmutil 20 -> 103 of 103, simlin-xmile
8 -> 9 of 9, all groups 364 -> 459 of 572. MDL groups are unchanged here.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
A Vensim sketch anchors a pipe's endpoints to the connected elements'
centers and draws each stock at the modeler's size, and the MDL importer
snapped an endpoint to a 45x35 face only when the other endpoint lay inside
that face's band. For any stock drawn larger than 45x35 (thyroid, FREE6,
Covid19US, C-LEARN) the pipe line misses the box, so 44 endpoints were left
on the stock's center axis, off every face. Two-point pipes were never
straightened and cloud comments were left where Vensim drew them, a few
pixels off the pipe's end.

The importer now runs the shared diagram::flow_geometry pass the XMILE
importer runs, in place of its own takeoff fixup. The sketch's pipe line is
kept and gains a perpendicular leg into a face when it passes the 45x35 box
(scirev7's PSR gets the long horizontal leg its own sketch corner implies).
When the line is within reach of the face it approaches, it slides into the
face's clearance span. The pass also gained its last-resort arm here: a valid
slot slides within its own span by the least amount, when no shared line or
jog fits (thyroid Flow_k63, 0.5px).

Collateral: mdl_format_roundtrip's AC1.3 compared mark2's written valve and
cloud records byte for byte with the source, but two of those flows violate
the invariants and are moved on import. They are now named in
AC1_3_NORMALIZED_FLOWS, compared without coordinates, and required to still
differ so the entries cannot go stale. test_zeroled_decimals.mdl became a
writer fixpoint and is removed from the idempotence allowlist.

Corpus, flows passing every strict invariant: vensim-mdl 237 -> 322 of 343
(the 21 left are the unattached flows), all groups 459 -> 544 of 572. In
vensim-mdl, violations went to 0 for on-face (37), corner clearance (38),
perpendicular exit (37), cloud on endpoint (53), valve on pipe (6) and
diagonal segments (1).

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
21 imported MDL flows had no attachments and rendered in neither renderer.
compute_flow_points accepted a pipe end only when its stock listed the flow,
and fell back to an unattached placeholder whenever fewer than two ends
validated. The corpus has three ways to get there. A flow's primary sketch
record can be a valve-less label while its pipe is drawn on a ghost copy (4
flows: FREE6, C-LEARN). A pipe can end at a stock the importer gave a
synthesized net flow, because that stock's rate does not decompose (10:
IDch15d, RealBeer4, Query_file, subscript_mapping_simple, FREE6, C-LEARN).
And a flow can be drawn as a label with no pipe at all (6), or with a pipe
that misses its only linked stock (Query_file Expenses).

Attachments now follow the model's stock lists, as they already do in the
XMILE importer and the layout metadata; the editor's commit path assumes the
two agree. resolve_flow_ends resolves each side on its own. A pipe end at the
linked stock attaches there, and the remaining pipe ends (clouds, and ends at
unlinked stocks) serve the sides with no stock. When one side's stock is not
a pipe end, the other side takes the pipe end on the valve's far side from
that stock; this is geometry only, with no claim about Vensim. The flow copy
that carries the pipe presents the flow. The ends the sketch does not place
are routed after the views merge (mdl::view::routes): through the valve at
its sketch position, with a bend when the stock is not ahead of it, and a
cloud 50px out or just outside an unlinked stock's box. The shared geometry
pass then enters the faces. Comments become clouds only when an end attaches
to them.

Corpus, flows passing every strict invariant: vensim-mdl 322 -> 343 of 343,
all groups 544 -> 565 of 572. The 7 left are stored simlin-native files,
which no importer touches.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
When two stocks list the same flow on the same side (degenerate input,
sdCloud's non_negative fixtures), detect_chains claimed the flow in each
stock's chain, so layout emitted two Flow elements with one name. The
metadata's flow_to_stocks keeps one source and one sink per flow, so the copy
laid out in the other stock's chain attached to a stock it was not drawn
next to.

A stock now claims a flow for its chain only when flow_to_stocks names it as
that flow's sink (inflows) or source (outflows). The chain traversal still
follows the connection.

Corpus, engine auto-layout of the sdCloud group: 24 flow elements, 14 passing
every strict invariant -> 16 flow elements (one per flow), all 16 passing.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
A stock whose inflows held ["f", "f"] simulated [0, 2, 4, 6] instead of
[0, 1, 2, 3], and getErrors() reported nothing. The stock update sums the
list, updateStockFlows and upsertStock canonicalized and sorted without
deduplicating, and the XMILE reader stores a file's repeated <inflow> as
written, so a repeat could reach the compiler from an edit or from a file.

XMILE 1.0 section 4.2 defines these lists as "the set of inflows and/or
outflows", listed "in inflow-priority order": each flow is one member with
one priority, so a repeat names nothing, and the compiler's answer is to
dedupe, not warn. SourceVariableFields::from_datamodel, the extraction both
the fresh and the incremental sync read, keeps each flow's first occurrence
by canonical name in list order, so priority order survives. Every salsa
reader (the stock update, conveyor leak validation, the causal graph) sees a
set. Both patch ops store the canonical, sorted, deduplicated list through one
helper, and upsert stays a full replacement (pinned here and by MCP's
upsert_stock_is_full_replacement).

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
Engine auto-layout left 64 flow endpoints exactly on a stock corner (7.4%
of stock endpoints) and 10 valves off their pipes. create_flow_view_element,
resnap_flow_endpoints and declutter's resnap clamped an endpoint's position
along the face to the full stock span, so whenever the valve sat past the
stock's span the endpoint landed on a corner, and the orthogonalizer then ran
the pipe along the face's edge line. The orthogonalizer routes only between a
pipe's two attached ends, so a valve placed off that route (an L through a
cloud on another row, as in C-LEARN's natural_ch4_emissions, or a valve
offset from a straight pipe, as in thyroid's k12/k21 and FREE6's fluxes)
stayed where placement put it, 17-40px off the pipe. Clamping the endpoint
into the face span produces the L shape too (scirev7's dr and dinp).

The clamps now go through diagram::flow_geometry::clamp_to_face_span (3px
corner clearance, the editor checker's). A fresh layout ends with
finish_flow_geometry: the orthogonalizer, then normalize_flow_geometry, the
same pass the importers run, which owns where the valve, the endpoints and
the clouds end up. The valve rule is deliberately not added to the
orthogonalizer: incremental layout runs it over flows the patch did not
touch, where moving a valve rewrites a hand-placed element (pysimlin's
TestDiagramSync pins this).

Corpus, engine auto-layout, flows passing every strict invariant: 673 of 737
-> 737 of 737. (The researcher's baseline was 671 of 745; the previous commit
removed sdCloud's 8 duplicate elements, which leaves 673 of 737.)

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
Incremental layout rewrote flows the patch never named. Adding or removing
a flow on a stock reclassified every sibling on that stock and rebuilt any
whose face or slot the classification would now give differently, and
after settling it resnapped and orthogonalized every flow in the view, so an
off-face or diagonal flow a user had placed was "repaired" by an unrelated
edit (adding a cloud outflow to mark2's risk-taking stock moved its
existing inflow to another face).

A flow is now rebuilt only when the patch creates it or changes its own
attachment: it moves to another stock, an attached stock is deleted, or an
attached stock changes kind, because its stored endpoints then name the
wrong element. Every other flow, siblings included, comes back byte for
byte. The endpoint snap and the finishing pass (the orthogonalizer, then
normalize_flow_geometry) take a flow filter and run only on the flows the
pass creates. Instead of re-spacing a face, a created flow's stock end takes
the largest free gap on its face, keeping the 3px corner clearance, so it
never lands on a preserved sibling (layout/face_slots.rs). A flow the patch
names keeps its geometry. diff_clouds still wires an endpoint the view left
unattached to the flow's own cloud, which moves nothing.

Incremental probe (7 edits on SIR, mark2 and land_model through pysimlin's
diagram_sync): pre-existing flows changed 1 of 10 on mark2 -> 0 everywhere;
created flows' valves off their pipes by 36.5px (SIR, new stock and flow)
and 109.1px (land_model, new stock) -> 0 for every created flow; created
ends 3px from a corner -> at least 10.25px. SIR's new stock-to-stock flow
used to end at the two face centers, on or 2px from the existing flows' ends
there; its ends now take the free gaps, 7.25px and 8.25px from those ends,
with a Z route.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
The sync took the set of a stock's inflows and outflows, but conveyor and
queue expansion builds its plans from the datamodel lists before any sync,
and the XMILE reader stores a repeated <inflow> as written. A queue whose
file repeated its inflow drove the queue negative (waiting 0, -2.5, -5, ...)
while served still doubled; a conveyor with a repeated inflow or outflow
simulated wrong or was refused as having two primary outflows.

datamodel::distinct_stock_flows is now the one statement of how a stock's
list is read: each flow once, at its first occurrence after
canonicalization. That is the engine's rule, unverified against Stella and
Vensim. XMILE 1.0 section 4.2 never addresses a repeated tag; its whole
statement is "The set of inflows and/or outflows is NOT REQUIRED. If there
are multiple inflows, they appear with multiple tags in inflow-priority
order (if the order of inflow to the stock is important)." Every reader
that decides what the model does takes the set through it before reading:
the sync, build_compiled before either expansion, the layout metadata, the
MDL writer's INTEG (a repeat there would make Vensim integrate the flow
twice), and the patch ops. The datamodel keeps what the file wrote.

Taking the set silently changes the model a naive sum over the file's list
describes, so the sync records the repeats on the salsa input and
model_all_diagnostics emits one RepeatedStockFlow Warning per stock naming
them, pinned exactly once across reaches and revisions by the warning-family
matrix.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
thyroid-2008-d.mdl draws T3 absorption with a valve twice: in Gut/Dosage
its pipe drains Gut T3 dissolved and ends nowhere, in THR D&E it fills
4 Plasma T3 from a cloud. Both copies carry a pipe, so flow_copy_rank tied
them, the tie kept the xmutil primary (the Gut/Dosage copy), and the route
the model's sink link needs climbed 1318px across views from that valve to
4 Plasma T3, through the aux kT3deg.

On a rank tie the copies are now compared by how many of the model's linked
stocks their pipe leaves unreached, then by the distance from the copy's
valve to those unreached stocks' primary records, which is the length of
the route the import will have to add. The THR D&E copy is nearer the stock
it leaves unreached, so it presents the flow: an L from Gut T3 dissolved's
face into 4 Plasma T3, 1386px over two segments instead of 1566px over
three, clear of kT3deg's circle by 1px.

Imported corpus: of the 364 flows that passed every strict invariant at the
baseline, 363 are byte-identical; T3 absorption is the one that moves, and
it cannot stay: its baseline cloud sink contradicted the model's stock list,
which the attachments follow.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
Engine auto-layout gave 8 generated flows a leg under 3px (free 6.mdl's
aeei install_rate among them: a 2.16px rise at the cloud before a 327px
run), and incremental layout made SIR's new relapse flow a Z with a 0.5px
riser. The normalization accepted any valid endpoint whatever the length
of the segment next to it, its legs and jogs needed only 3px, and the test
oracle checked neither segment length nor the valve's margin, so nothing
flagged them.

The thresholds are now the editor design plan's
(docs/design-plans/2026-09-10-diagram-editing-core.md, "Units"): 3px corner
clearance, a 10px stub and riser, a final segment of FlowArrowheadRadius +
7.5, and a valve 10px from the path's ends by arc length. Following its G3,
the minima apply when the terminals leave room (the source body inflated by
MIN_SEGMENT and the sink body by MIN_SINK_SEGMENT do not overlap). The
Face/Leg/Jog arms use the end's minimum; a new collapse step removes a
segment under the minimum where the pipe can give it up (a short end
segment's end moves to the bend and the attach arms bring it onto its
face; a short riser merges its runs, preferring the candidate that moves
no valid stock slot). The pass repeats to a fixed point, which also makes
it idempotent (a Z whose first pass left 1.5px of corner clearance for the
second to fix), and a straightening the attach step cannot then bring onto
the stocks is undone. The invariant checker reports zero-length and
collinear segments, the G3 minima and the valve margin.

Corpus at these units: imported flows passing every strict invariant
565 of 572 (the 7 are simlin-native files no importer touches), generated
737 of 737, G3/G8 violations 0 imported and 0 generated, the review's
short-leg scan 0. Seven imported flows that already passed every strict
invariant move, each because a segment its producer drew is under the new
minimum: land_model's forest to agriculture (a 3px jog; its valid source
slot gives 3px and the pipe runs straight), free 6's
Flux_Humus_to_Atmosphere (a 4px final segment), Query_file's
EnergyConsumptionRate (a 3.5px final segment) and I2 (12.5px), and
C-LEARN's Carbon_Sequestration (an 8.5px stub), Flux_Atm_to_Biomass (a
3.5px stub) and Natural_CH4_Emissions (a 12.5px final segment).

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
Incremental layout placed each stock end of a created flow in the largest
free gap of its face independently. A flow between two stocks whose facing
faces already carried a flow got two slots on different lines, so SIR's new
relapse flow became a Z with a 0.5px riser beside succumbing. Two cloud
outflows added at once to one mark2 stock landed 7.25px apart, so their
clouds overlapped.

A created two-point flow between two parallel faces now takes the center
of the longest overlap of the two faces' free gaps for both ends and the
valve, so its pipe runs straight; without an overlap each end still takes
its own face's largest free gap. Once slots are placed, a created flow's
cloud end is pushed out along its pipe, one cloud width at a time, while its
cloud overlaps another.

This also enumerates the rest of the flow contract, which only parts of the
suite pinned. A preserved flow whose stored geometry breaks the invariants
(a diagonal pipe, an off-face endpoint, an off-pipe valve) comes back byte
for byte on both the settle and the early-return paths; the review's
mutants that orthogonalize or resnap every flow survived because the only
fixture broke the valve rule alone. The stock<->cloud attachment arms (a
stock drops the flow from its list; a stock lists it at its cloud end) are
rows, and the docs name them. The swapped-order review test is restated as
preservation. The face_slots rows justify their hand-built views, and the
production composition is pinned on SIR.xmile and mark2.mdl through the
importers.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
The review's mutation run left arms of the flow geometry code that no test
could tell from their removal. Each is now settled by evidence: a
differential that runs the normalization over 40,000 generated views
(one- and two-stock views with every shape the producers hand it) with each
arm removed, counting the views whose result differs and those the
invariant checker then rejects more of.

- leg_clear (M11): 19 views differ, none worse without it. A leg the
  shared line leaves shorter than a stub is removed by the next pass's
  collapse, so the guard only refused routes the passes finish. Removed.
- the slide's perpendicular-neighbour guard (M12): 5 differ, none worse.
  The fold-back guard rejects every neighbour the slide would pull off
  axis (a collinear or zero-length neighbour's far point lies on the old
  line). Removed.
- carrying the valve along a slide (M14): 121 differ, none worse; the
  differences are valve positions the projection after the pass settles.
  Removed, and attach_end_segment now reads the valve and never moves it.
- the fold-back guard (M13): 80 differ, 5 worse without it. Kept, with a
  row built from the first worse input: without it a zero-length riser
  survives, since the collapse skips a segment of no length.
- the commit guard (M17): 62 differ, 11 worse without it. Kept, with a row
  built from the first worse input: overlapping stocks where the arms
  would commit a pipe through a body.
- the jog's feasibility check: 16 differ, none worse by the checker's
  count, but the first differing input shows why it stays: removing it
  commits a step that runs past the adjacent point and doubles back, which
  no invariant names. Its comment says so.
- MDL: a single pipe end drawn vertical takes its target's y (M40); a route
  toward a stock behind the valve turns BEND_STEP past it (M44); a cloud
  near an unlinked stock stays a margin off the valve (M46).

Each new row was run against its mutant (M13, M17, M40, M44, M46) and fails
under it.

The corner-clearance clamps in declutter's resnap and
create_flow_view_element (M03, M04) were not load-bearing: the layout's
finishing pass owns corner clearance for every flow it may move, and the
generated corpus holds every invariant without them. They are back to
full-span clamps, so clearance has one owner; the endpoint snap incremental
layout runs before face_slots keeps its pinned clamp. The private geometry
types carry the debug-derive attribute.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
Three row docs in flow_geometry_tests.rs narrated where their inputs came
from (a review's hand case, a differential that found them) and what an
earlier pass used to leave. That is history the commit log already
carries; a row's doc should say what the input exercises and what the
normalization must do with it. The idempotence row now names the property
its Z exercises -- a riser collapse whose merged line needs a slide, so
the geometry settles only over several passes -- and the two guard rows
keep only what the guard refuses.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
The normalization's collapse could leave a pipe worse than it found it: a
riser merged into a collinear fold back over itself (1281 of the review's
60,000 generated views), a candidate running a segment through a terminal
stock's body, and, with crowded terminals, a sub-1px segment the checker
accepts collapsed anyway, so a valid view moved. Each arm here was settled
by a differential over the same 60,000 views: the arm toggled against the
rest, counting views whose result differs, ends with more checker
violations, or ends with fewer.

- simplify drops the tip of a spur (a point where the pipe turns back along
  its own line) as well as onward collinear points, and re-examines the
  tail until nothing drops; a pass starts from the simplified pipe.
  Keeping spurs: 1754 views worse. Not simplifying at pass start: 35 worse.
- a collapse candidate or an attach commit may not add a segment through
  the body of a stock the pipe ends on (G6), but may keep one the producer
  drew. Without the collapse gate: 7 worse; without the commit guard: 44
  worse; refusing every crossing instead of added ones: 7496 worse.
- a collapse runs only where the checker holds a segment to a minimum
  (the terminals leave room), and with crowded terminals only while a stock
  end is invalid, where the short segment keeps it off its face. Collapsing
  regardless: 10 worse, and a checker-valid view moves; never healing when
  crowded: 67 worse.
- dropped, because they refused heals the next pass finishes: requiring a
  collapse candidate to leave every end valid (1218 worse with it, 15
  better), the slide's fold-back guard (8 worse without it, 275 better:
  the fold is a spur the next pass removes), and the attach commit's
  endpoint-validity check (0 worse without it, 17 better).

At the new defaults the 60,000 views give 43,978 fully healed (41,827 at
the parent), 2 with more violations than they started with (13 at the
parent), no valid view moved, and no second pass that changes anything.
The normalization-is-idempotent sweep also asserts that a view the checker
accepts comes back unchanged.

Corpus: no imported flow moves (572 of 572 byte-identical against the
parent; 565 of 572 pass every strict invariant), generated views pass 737
of 737, and the G3/G8 and short-segment scans find 0 imported and 0
generated.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
Three arms of attach_end_segment refused routes the normalization then
could not reach, which the review's mutation run could not tell from their
removal. Each was settled over the same 60,000 generated views as the
collapse, against the code with the arm restored.

- A two-point pipe's Face end was held to the sink segment's minimum
  whichever end moved. Once the sink's arm has inserted points (ends are
  fixed highest index first) the source's segment is a stub, and while
  the pipe stays two points room already puts its ends further apart than
  both minima, so the end's own minimum is the right one: restoring the
  sink's minimum leaves 20 views unsolved that now settle.
- A jog's step also stayed a riser short of the adjacent point and of the
  valve when the valve sat on the segment. The valve is projected onto the
  settled pipe after the passes, so counting it only refused jogs (15
  views), and the arms no longer read the valve at all. The riser term
  changes none of the views once the valve term is gone: with room the
  two faces are always further apart than a stub and a riser.
- The jog's doubled-back guard stays: removing it is worse in 1 view and
  builds a route that passes the other end and doubles back, which no
  invariant names; a row pins that refusal.

At these defaults the 60,000 views give 44,016 fully healed (43,978 at the
parent), 2 with more violations than they started with, and no second pass
that changes anything.

Corpus: no imported flow moves (572 of 572 byte-identical against the
parent; 565 of 572 pass every strict invariant), generated views pass 737
of 737, and the G3/G8 and short-segment scans find 0 imported and 0
generated.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
A created two-point flow between two stocks took the center of the longest
overlap of the two faces' free gaps as one line for both ends. Between
stocks offset along their faces that overlap is a sliver beside the
existing flow's ends, so the new pipe landed on top of it: adding a reverse
flow B->A beside A->B at y=114, with B 28 below A, drew it 0.25px from A->B's
ends. Over the review's sweep of 435 offsets and lines, 36 created ends
landed within 3px of the existing ones. Created cloud flows had the same
fault on a single face: mark2's two new outflows sat 7.25px apart, under the
design plan's PIPE_SPACING, and the first one's cloud sat on the second
one's pipe.

The slots now follow the design plan's routing preference
(docs/design-plans/2026-09-10-diagram-editing-core.md, "Invariants": "a
flow that newly lands on a face takes a slot at least PIPE_SPACING from
existing endpoints on that face if one exists, else the slot maximizing the
minimum distance"). A joint line is taken only where it keeps PIPE_SPACING
from the ends on both faces; otherwise each end takes its own face's slot
and the finishing pass routes the pipe. A created flow whose far end is a
cloud first looks for a slot CLOUD_RADIUS clear, because the shorter of two
side-by-side pipes leaving one face ends in a cloud that lies across the
longer pipe unless they are at least a cloud's radius apart. Among clear
positions the one nearest the largest free gap's center wins, so a lone
flow still takes the face's center.

The review's sweep through pysimlin (435 offsets and lines) now puts no
created end within 10px of the existing flow's ends (nearest exactly 10;
0 within 3px, from 36), with the existing flow unchanged in every case.
Where the independent slots route a Z, 18 cases leave the valve on a riser
within 10px of a bend: the design plan's arc-length margin holds, but the
audit's per-segment margin does not, which the next commit settles. The
reviewer's probes: SIR's relapse runs straight 10px off succumbing, mark2's
two outflows sit 13.5px apart with neither cloud on the other's pipe, and
no pre-existing flow changes in either incremental probe. Imported and
generated views do not pass through face_slots.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
Where incremental layout gives a created flow independent slots, the
finishing pass routes a Z through the valve's column, so the valve lands on
the riser; with the valve from placement and a riser of about 20px, 18 of
the review's 435 swept cases left it within 10px of a bend. The design
plan's G8 measures the margin by arc length from the path's ends, which
held, but the editor clamps a dragged valve along its closest segment,
VALVE_CLAMP_MARGIN from that segment's ends (clampToSegment in
src/diagram/drawing/Flow.tsx), so the first drag would move it, and the
audit's per-segment check flagged it.

The layout owns the valves of the flows it settles, so its finishing pass
now keeps each off its segment's ends: clamped along that segment where it
is at least twice the margin long, otherwise moved to the middle of the
longest segment, and left alone where every segment is too short. Imported
valves still answer only to the arc-length margin; both placements keep at
least the margin from the path's ends, so G8 is never undone.

The review's sweep through pysimlin now leaves 1 of 435 cases the audit's
per-segment check flags, and that one sits exactly on the margin: the
valve is 10.0px from the bend (108.75 - 98.75), which the audit's
(1 - t) * length evaluates as 9.999999999999998. The engine's rows hold the
margin with a tolerance of 1e-6, as the checker does. No created end lands
within 10px of the existing flow's ends, and no pre-existing flow changes
in either incremental probe.

Corpus: no imported flow moves (572 of 572 byte-identical against the
parent; 565 of 572 pass every strict invariant), generated views pass 737
of 737, and the G3/G8 and short-segment scans find 0 imported and 0
generated.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
Two arms of the MDL importer's choice of which flow copy presents a flow
could be removed without any test noticing. The thyroid row pins the
distance tie-break, where both copies leave one stock unreached, so
dropping the unreached count left it passing; and every row met the
sketch's primary copy first, so letting an exactly tied copy displace the
primary changed nothing.

A row now has one copy piping into one of the two stocks the model links,
far from the other, beside a copy that reaches neither but sits nearer both
in total: the copy reaching more presents the flow, because every unreached
side is a route the import must add. Another has two copies whose scores tie
exactly, with a ghost met before the primary: the primary stays. Each row
fails under its mutant.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
The normalization's rule against body crossings refused any step whose
result ran a segment through a terminal stock where the pipe ran none,
whatever the stocks' placement. The design plan
(docs/design-plans/2026-09-10-diagram-editing-core.md, "Invariants") makes
G6 conditional: no segment through either terminal body whenever the two
bodies, each inflated by MIN_SEGMENT, do not overlap; when they overlap, G1-G5
still hold and G6 is best effort. Between overlapping stocks the rule
therefore kept pipes diagonal with off-face ends rather than attach them
across the other body (the verification review counted 286 such regressions
against 063dce83). And because it compared "any crossing" with "none", a pipe
that already crossed could gain more: one fuzz case went from 1 crossing to 4.

A collapse candidate or attach commit is now refused only where the inflated
terminal bodies do not overlap and it runs more (segment, terminal stock)
crossings than the pipe did. Rows: a diagonal pipe between overlapping stocks
now attaches both ends (G1-G5 hold, only a crossing remains), and a pipe
already crossing its source's body settles straight instead of gaining two
crossings; each fails under its mutant (no room gate; "first crossing only").
The overlapping-stock commit-guard row now asserts G1-G5 where it asserted a
refusal. Over the 60,000 generated views, dropping the room gate is worse in
151 views, and refusing only a first crossing is worse in 11.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
Where incremental layout gives a created flow independent slots on opposite
sides of an existing flow, the finishing pass routes a Z through the valve's
column, and that riser crosses the sibling: in 100 of the review's 435 swept
cases the created flow's valve sat 0 to 0.75px from the sibling's pipe. The
crossing itself has no alternative -- every route between the two faces that
stays between the stocks crosses a sibling straight between them -- but the
valve does: the Z's horizontal runs lie a pipe spacing off the sibling.

settle_laid_out_valve now also keeps a valve VALVE_CLAMP_MARGIN from every
other flow's pipe. A valve clear of both margins is not moved; otherwise it
moves to the nearest position of the pipe that is (the positions within the
margin of another pipe are the line's crossing with that pipe's capsule), and
where every long-enough segment runs within the margin of another pipe it keeps
the segment margin alone, as before. The finishing pass passes every other
flow's pipe, and its rustdoc states the Z limitation.

Rows: the settle table gains a valve on a sibling's pipe, a valve clear of it,
a sibling beside every position, and the longest-segment fallback; the offset
stocks table test asserts the created valve keeps the margin from f1's pipe,
and its rustdoc states the property its rows exercise. The preserved-flow test
gains a flow whose valve sits on its pipe 4px from an end, which must come back
byte for byte, so settling valves outside `include` fails it. Each of 8 mutants
(settle ignoring include, no other pipes passed, the current-valve check or the
interval cut ignoring other pipes, the capsule without its rectangle, either
fallback removed, ties to the later segment) fails a row.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
Two arms of face_slots survived their mutants. The joint line's clearance is
taken against the ends on both faces, but every row put an occupant on the
first face, so checking only that face's ends passed; and the farthest-slot
fallback considers the midpoints between neighbouring occupants as well as
the span's ends, but every fallback row's answer was a span end.

A place_created_flow_ends row now has only the second face occupied, where
the joint line must keep the spacing from that face's end, and a slot_on_face
row has two occupants whose midpoint is the farthest position. Each row fails
under its mutant.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
Canvas gestures were planned in four places (group-movement, flow-attach,
the routing half of Flow.tsx and the Canvas's own pointer handlers), each
with its own idea of a click, a valid drop and a routed flow, so the preview
and the commit could disagree and invalid drops could still commit.
gesture-planner/ is the one pure owner: classifyPress turns a press into a
gesture, and planGesture returns a single value that is both what a frame
renders and what the release commits, with flow geometry coming only from
flow-geometry/.

A drop is valid exactly when the routed flow has no fault and the semantic
rules hold (distinct stocks, the stock's variable exists); an invalid drop,
or a move whose routed flows cannot hold the invariants, commits nothing.
The whole-view sameGeometry comparison lets a live gesture abort when the
view it was planned on changes. Tests derive their rows from the gesture
and arm enumerations, fuzz generated and imported scenes, and run the
planner over the imported-model corpus.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
The Canvas now holds one live gesture, renders the planner's elements while
it is valid, and hands a release to the Editor as a single GestureCommit;
the Editor enqueues it as one view edit and drops it when the base view it
was planned on no longer matches. A gesture aborts on pointercancel, a
second pointer, a lost release, a token change, a geometry change or a
missing subject, and presses are ignored while an undo is queued. Flows
render the same moving and at rest, so the last preview frame is the
committed frame.

group-movement, flow-attach, canvas-interaction, selection-logic,
cloud-utils and the routing half of Flow.tsx are deleted rather than kept
beside the planner: a second implementation drifts exactly where the real
one is non-trivial. Their scenarios are restated as planner rows, as
DOM preview-vs-commit rows per gesture kind, as lifecycle rows, and as
Editor rows against the real engine; routing geometry stays pinned by the
flow-geometry tables and repros. Docs describe the planner-based design.

The corpus invariant rows now expect strict geometry from every import:
the engine's import normalization puts endpoints on faces, clouds at their
endpoints and fallback flows' ends attached, checked per model against the
geometry the engine produces.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
Creating a link aborted whatever drag was live when its patch landed: the
planner stored the link at x/y 0, the datamodel reads links back as NaN,
and the whole-view geometry comparison saw that as a moved element. Links
have no position, so the comparison skips it and the planner stores NaN.
The real-browser journey added two decisions: Escape cancels a live drag,
and no routed segment may pass through a stock the flow was attached to
when the gesture started. The core takes those stocks as obstacles that
rank and fault like terminal bodies, so an end detached from a stock no
longer keeps the old face's line straight through it.

The rest closes lifecycle gaps the mutation review found surviving. A
lost release now forgets its pointer (a later touch started a pinch), an
orphaned name editor closes quietly, capture moves to the svg root because
a valid drop removes the pressed cloud, and the element callbacks are
stable so a drag frame re-renders only what changed. Rows now pin
pressesDisabled, the commit handler's finally, the slot preference, the
press token and flowStillBeingCreated; each fails under its mutant.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
A pipe through a stock reads as attached to it, and the reviewer's crossing
sweep found routes through stocks the flow no longer touched: a detached or
reattached end kept the old face's line straight through its old stock,
offsetting a detached flow dragged it back through that stock, and imported
third stocks were crossed on detach, reattach and stock moves. Avoiding
only the stocks a flow was attached to left the third-stock class open.

Routing now takes every stock in the view as an obstacle. G6 stays
terminal-only: a path through a non-terminal stock is obstructed, which
ranks as crossing, refuses a preserved or pinned tail, and makes the
released search try U turns and more bends before settling for it, but it
is never a fault, so a route still always exists and commit validity is
unchanged. offsetSegment takes the nearest clear coordinate around any
stock. A bounding-box prefilter keeps large views cheap: planGesture on
C-LEARN's busiest stock stays at 2.34ms p95. The geometry sweeps meet
their budgets unchanged.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
The E5 effect drops a live gesture as soon as a republish changes the
geometry it was planned on. The release re-checks validity too, so no
existing row noticed when the effect was disabled: a gesture left live
simply fails that check. The difference shows when the view changes back
to the press-time geometry under the same token. A dropped gesture stays
dropped, while one kept live would revive and commit a drag the user saw
cancelled. This row pins it.

The test header records why the other two surviving mutants get no row.
The release re-check cannot be killed in jsdom, because act() flushes the
E5 effect before the next event; it covers a browser pointerup that arrives
while that effect is still pending. The read-only no-op handler is
equivalent, because the Editor's handler already refuses while read-only.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
On C-LEARN, releasing any canvas drag froze the notebook widget for about
2.8 s (main: 467 ms). The profile of that frame put ~150 ms in the engine
and ~2.3 s in React: every Sparkline rebuilt its path (25M jsFormatNumber
calls). Two things invalidated their memos. runEdit requested a sim run
after every landed edit, and each run published a new Series object for
every variable. And render() regrouped the series through projectAttachData
on every render, so even a landing with no run handed every sparkline a new
array.

A landed edit whose patch holds only upsertView ops now requests just the
save: a view feeds no simulation, diagnostic or equation dependency. runSim
keeps a Series whose time and values are elementwise identical (Object.is,
so NaN matches NaN), and keeps the whole map when nothing changed. The
controller groups series per variable once per data change with
groupSeriesByIdent, reusing the array of any variable whose Series are
unchanged, and projectAttachSeries attaches those arrays by identity;
projectAttachData delegates to both for StaticDiagram. The C-LEARN release
drops to ~100 ms.
Unit and jsdom suites plan gestures and render the Canvas, but they cannot
show that a real browser, with pointer capture, React's scheduling and the
controller's executor running over the real wasm engine, delivers what the
planner promises. This journey mounts the built widget bundle through the
existing static harness and drives the real Editor with Playwright mouse and
key events. It asserts on rendered SVG (path d, valve circles, cloud
transforms, stock rects) and on the snapshots the fake kernel receives.
Covered: a stock drag bends its cloud-ended flow orthogonally with
perpendicular face exits; clicks on flow and link arrowheads change and save
nothing; a detached end previews a cloud at the pointer and commits the last
preview frame; reattaching lands on a face and moves the flow between stock
lists; a valve drag forms a bracket and collapses back; Escape cancels a live
drag; a drawn flow ends in a cloud and takes its typed name; undo and redo
restore and reapply geometry; no routed pipe crosses a stock it was attached
to.

A separate perf spec opens C-LEARN, converted from its .mdl in Node, framed
by a seeded viewBox so nothing refits mid-measurement. It reports frame
deltas, long animation frames and release-to-snapshot latency for a stock
drag and a ~50-element selection drag, but asserts only that the edits land:
headless SwiftShader numbers compare runs on one machine, not user hardware.
Tracing is off for that file because Playwright snapshots the page per
action. The harness plumbing moves to e2e/support.ts and the SVG probe to
e2e/canvas-probe.ts so both specs and widget.spec.ts share them.
The bundle journey observed the snapshot in the fake model's `snapshots`
list, which `send` fills synchronously, and read `kernel.revision` in the
very next evaluate. The fake kernel accepts on a later setTimeout, as a real
comm round trip would, so the test raced the accept. It won 6 of 6 runs on
main and lost 4 of 6 on the diagram-editing branch; which timing change tips
it was not isolated, since the assertion is wrong either way. Polling for the
revision to advance waits on the kernel's own state instead of on timing;
10 of 10 repeats pass.
@bpowers
bpowers force-pushed the diagram-editing-overhaul branch from d296716 to 10e37d9 Compare September 12, 2026 03:40
@codecov

codecov Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.39%. Comparing base (fc17f84) to head (a8eb2a5).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1066      +/-   ##
==========================================
+ Coverage   93.33%   93.39%   +0.05%     
==========================================
  Files         260      263       +3     
  Lines      155401   156846    +1445     
==========================================
+ Hits       145049   146480    +1431     
- Misses      10352    10366      +14     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Cancel restored each panel field to committed text, but a field whose
submission was still pending measures its draft against that submission,
so the restored text read as a new draft and the next blur or canvas
press submitted it over the pending save. Cancel now puts every field
back to its base (the pending submission, else the seeded text), and
Save/Cancel are enabled exactly while a draft exists.

convertErrorDetails ignored severity, so an engine advisory such as the
repeated stock-flow warning landed in variable.errors, hid the chart and
showed the wire Generic description. A non-unit Warning is now an
advisory in variable.warnings, shown beside the chart and in the errors
panel with its details. Idents are canonicalized: some raising sites name
a variable by its source spelling.

The rendered model had no variable for an element whose create was still
queued, so a flow drawn or reattached onto that visible stock was refused.
It now holds the variable the create op will produce (createdVariable);
the create lands before the gesture's edit is built, and an orphan import
element still resolves to nothing.

Claude-Session: https://claude.ai/code/session_013bpZ6fEZETky2rvtEUsq1K
@bpowers
bpowers merged commit 1da80fc into main Sep 12, 2026
17 of 18 checks passed
@bpowers
bpowers deleted the diagram-editing-overhaul branch September 12, 2026 13:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant