diagram: one flow geometry owner, gesture planner and executor - #1066
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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".
| } else if (sourceMoves || sinkMoves) { | ||
| const end = sourceMoves ? 'source' : 'sink'; | ||
| valid = routeMovedEnd(input, el, end, d, changed, base, stocks, moving, uids, frameStocks) && valid; |
There was a problem hiding this comment.
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 👍 / 👎.
| variable: Some(name.clone()), | ||
| owner: None, | ||
| severity: DiagnosticSeverity::Warning, |
There was a problem hiding this comment.
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 👍 / 👎.
| const stock = base.get(from.stock); | ||
| if (stock?.type !== 'stock') { | ||
| return idlePlan(input); | ||
| } | ||
| source = stockTerminal(stock); |
There was a problem hiding this comment.
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 👍 / 👎.
| stock.inflows = canonical_flow_list(inflows); | ||
| stock.outflows = canonical_flow_list(outflows); |
There was a problem hiding this comment.
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.
d296716 to
10e37d9
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
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
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):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 isplanGesture(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).group-movement.ts,flow-attach.ts,canvas-interaction.ts,selection-logic.ts,cloud-utils.ts,merge-live-view.ts, the routing half ofdrawing/Flow.tsx. Rendering is unchanged (TS/Rust SVG parity stays byte-identical).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 aRepeatedStockFlowadvisory).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
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):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.tson C-LEARN, 4,059 view elements; headless Chromium with SwiftShader, one machine, no budget asserted):planGestureon 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