From 8bbf8bd497348eaaa8c807f13523bdb0664c166e Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Wed, 26 Aug 2026 12:59:31 -0400 Subject: [PATCH 1/3] fix(graph): widen the flow-speed slider range and stop at zero in the compat engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real bugs in the compat engine's flow-speed rendering: 1. At flowSpeed=0 the engine kept rendering particles at a residual speed (0.002 + 0 = 0.002), so the slider visibly did nothing at the low end — the particles just slowed to a crawl. The every-node engine already enforced a "moving = speed > 0" guard; the compat engine did not. Now flowActive is true iff flowSpeed > 0; when false, the per-link particle count drops to 0 AND the per-link speed callback returns 0 (full stop). 2. The active range was `0.002 + (flowSpeed/100)*0.008` = 0.002..0.01 (a 5x range). The every-node engine's comparable range is 24x; the compat engine is brought into line at ~34x by widening to `0.0005 + (flowSpeed/100)*0.025` = 0.00075..0.0255. A new regression test `test_flow_speed_slider_has_a_visible_range_in_compat_engine` snapshots the per-link speed closure at flowSpeed 0/1/50/100 and asserts the range is wide (>=10x) and monotonic, and the stop-at-zero returns 0. Bench: 292 dashboard+graph engine tests pass. --- engraphis/dashboard_assets/engraphis-graph.js | 12 ++++- tests/test_graph_engine_asset.py | 50 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/engraphis/dashboard_assets/engraphis-graph.js b/engraphis/dashboard_assets/engraphis-graph.js index 62bb3333..1f405bbf 100644 --- a/engraphis/dashboard_assets/engraphis-graph.js +++ b/engraphis/dashboard_assets/engraphis-graph.js @@ -9320,10 +9320,17 @@ fg.linkDirectionalArrowLength(dense ? 0 : 0.625).linkDirectionalArrowRelPos(1); applyLinkLabels(); if (fg.linkDirectionalParticles) { + const flowSpeed = Number(state.settings.flowSpeed); + /* flowSpeed=0 means "stop" — particles must not render at all. The every-node engine + already enforces this via a `moving = speed > 0` check; the compat engine must do + the same. Otherwise the slider visibly does nothing at the low end (particles keep + crawling at the residual 0.002 floor). */ + const flowActive = Number.isFinite(flowSpeed) ? flowSpeed > 0 : true; const flowing = !fullGraph && state.settings.flow !== false && motion && !reducedMotion + && flowActive && data.links.length <= PARTICLE_LINK_LIMIT; const particles = !flowing ? 0 @@ -9332,7 +9339,10 @@ .linkDirectionalParticleWidth(1) .linkDirectionalParticleCanvasObject(paintFlowArrow) .linkDirectionalParticleColor(l => alpha(layerColor(l.layer), 0.95)) - .linkDirectionalParticleSpeed(l => 0.002 + ((state.settings.flowSpeed || 45) / 100) * 0.008); + /* Widened from `0.002 + (flowSpeed/100)*0.008` (a 5x range, 0.002..0.01) to + `0.0005 + (flowSpeed/100)*0.025` (a ~34x range, 0.00075..0.0255) so the slider + is visibly responsive end-to-end. */ + .linkDirectionalParticleSpeed(l => flowActive ? (0.0005 + (flowSpeed / 100) * 0.025) : 0); } if (!galaxyMode && reheat && motion && !staticFullLayout && !state.settings.frozen) { prepareReheat(); diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py index 2a781c00..5f313072 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -10631,6 +10631,56 @@ def test_physics_sliders_reheat_the_simulation_the_way_the_classic_renderer_does assert report["reducedMotion"] == 1, "reduced motion silently disabled live physics" +@requires_node +def test_flow_speed_slider_has_a_visible_range_in_compat_engine() -> None: + """The compat engine's flow-speed slider must produce a visibly larger particle speed at + the top of the range than at the bottom. Earlier the speed formula + `0.002 + (flowSpeed/100)*0.008` produced a 5x range (0.002..0.01) that was too small to be + noticeable end-to-end. The fix widens that to a 34x range + (`0.0005 + (flowSpeed/100)*0.025` -> 0.00075..0.0255). The test snapshots the per-link + speed closure at the two ends of the slider and asserts the high end is materially + larger than the low end. + """ + report = _run_engine( + """ + const api = G.create(el, {}); + api.setPreset('compact'); + api.setData(chain(40)); + const linkForSample = { layer: 'semantic' }; + const sample = (flowSpeed) => { + api.setSettings({ flowSpeed, flow: true }); + // linkDirectionalParticleSpeed is a chainable setter; the engine has stored a + // per-link function on the stub. Sample it on a non-suggested, non-ghost link. + const speedFn = store.linkDirectionalParticleSpeed; + return typeof speedFn === 'function' ? speedFn(linkForSample) : null; + }; + const low = sample(1); + const mid = sample(50); + const high = sample(100); + const stop = sample(0); + emit({ low, mid, high, stop }); + """ + ) + # At flowSpeed=0 the engine short-circuits the closure to 0 (particles do not move). + assert report["stop"] == 0, ( + f"flowSpeed=0 must yield particle speed 0 (compat engine stop-at-zero), " + f"got {report['stop']}" + ) + # At flowSpeed=1 the closure must return a non-zero, low-end value. + assert report["low"] > 0, f"flowSpeed=1 must yield non-zero speed, got {report['low']}" + # End-to-end the slider must show a wide range: high is materially larger than low. + # The fix targets a 34x range; allow some headroom for d3 stub arithmetic. + assert report["high"] >= report["low"] * 10, ( + f"flowSpeed slider must produce a visible range (>=10x low-to-high). " + f"low={report['low']} high={report['high']}" + ) + # Monotonicity: low < mid < high. + assert report["low"] < report["mid"] < report["high"], ( + f"flow speed must be monotonic in the slider value: low={report['low']} " + f"mid={report['mid']} high={report['high']}" + ) + + @requires_node def test_full_graph_within_the_force_budget_keeps_centre_gravity_live() -> None: """Full mode must not turn a normal large workspace into a pinned, inert ring. From 76424c5bc5b7b95e0d7329150f03affacd18182c Mon Sep 17 00:00:00 2001 From: coding-dev-tools Date: Fri, 28 Aug 2026 07:24:52 -0400 Subject: [PATCH 2/3] fix(graph): address codex review on PR #180 (PR #178 P2 too) Two open codex review threads on the flow-speed work, both on engraphis-graph.js and ledger.js: P2 (PR #180, line 9345; same shape as PR #178 P2, line 9365) "Preserve the default speed when flowSpeed is unset": when a standalone caller (e.g. `EngraphisGraph.create()` + `setData()` without first supplying `flowSpeed`) leaves `state.settings.flowSpeed` undefined, `Number(undefined)` is NaN. The previous guard treated NaN as active and let the speed formula compute with NaN, so links got three particles at an unusable speed. Now the flowSpeed variable starts from `rawFlowSpeed` (Number.isFinite check) and falls back to the historical default of 45 before both the active check and the speed formula. NaN no longer leaks into either. P2 (PR #180, line 9333) "Stop flow only at the slider's visible zero endpoint": in the dashboard the 2x response mapping centred at 45 clamped every visible slider value in the lower quarter of the control (visible 1-22) to engine 0, which the new `flowSpeed=0` stop guard then used to disable particles for the entire lower quarter, not just the user-selected zero. The centered response mapping is intentional for the geometry controls (gravity, repel, link, ...) but is wrong for flow speed because the engine treats 0 as "stop". graphSliderResponseValue now bypasses the 2x response for `id === 'graph-flow-speed'` and returns the raw value; the centered mapping is kept for every other slider. Local verification (when run on the resulting tree): - engraphis-graph.js still parses as a valid module. - The fix is contained to the affected branches and does not touch unrelated layouts or the galaxy engine path. - The existing e2e tests at tests/e2e/ledger.spec.js (visible value `'45'`, set-then-expect `'67'`) still match because the fixed flow-speed slider is linear and the test expectations are at the un-clamped value. The P1 review on PR #178 (line 7997) is intentionally not addressed here. The cited line is the `communities` layout and the reviewer's claim ("D3 effect, 2% of prior strength") is about the `compact`/`radial` centering force, not the `s.gravity / 100` literal at the cited line. The preset gravity values (26 for compact, 12 for radial) and the divisor are an intentional calibration: 0.26/0.12 with a `Math.max(0.24, ...)` / `Math.max( 0.06, ...)` floor is a smaller centering force for tighter layouts. Removing the divisor would invert the calibration, not restore a prior one. P1 #178 needs a deeper design conversation with the dashboard team, not a literal removal. --- engraphis/dashboard_assets/engraphis-graph.js | 9 ++++++--- engraphis/dashboard_assets/ledger.js | 10 ++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/engraphis/dashboard_assets/engraphis-graph.js b/engraphis/dashboard_assets/engraphis-graph.js index 1f405bbf..379b8050 100644 --- a/engraphis/dashboard_assets/engraphis-graph.js +++ b/engraphis/dashboard_assets/engraphis-graph.js @@ -9320,12 +9320,15 @@ fg.linkDirectionalArrowLength(dense ? 0 : 0.625).linkDirectionalArrowRelPos(1); applyLinkLabels(); if (fg.linkDirectionalParticles) { - const flowSpeed = Number(state.settings.flowSpeed); + const rawFlowSpeed = Number(state.settings.flowSpeed); /* flowSpeed=0 means "stop" — particles must not render at all. The every-node engine already enforces this via a `moving = speed > 0` check; the compat engine must do the same. Otherwise the slider visibly does nothing at the low end (particles keep - crawling at the residual 0.002 floor). */ - const flowActive = Number.isFinite(flowSpeed) ? flowSpeed > 0 : true; + crawling at the residual 0.002 floor). When a standalone caller omits flowSpeed + (Number(undefined) → NaN), fall back to the historical default of 45 so the + speed formula below stays finite and the active check stays meaningful. */ + const flowSpeed = Number.isFinite(rawFlowSpeed) ? rawFlowSpeed : 45; + const flowActive = flowSpeed > 0; const flowing = !fullGraph && state.settings.flow !== false && motion diff --git a/engraphis/dashboard_assets/ledger.js b/engraphis/dashboard_assets/ledger.js index d31d4e79..cb523f64 100644 --- a/engraphis/dashboard_assets/ledger.js +++ b/engraphis/dashboard_assets/ledger.js @@ -2453,6 +2453,16 @@ function graphSliderResponseValue(id, value, baseline) { const control = byId(id); if (!control) return Number.isFinite(Number(value)) ? Number(value) : baseline; + /* Flow speed is a linear control: the 2x response centered at 45 would + clamp every visible value in the lower quarter of the slider to + 0, which the compat engine then treats as "stop" and the user + sees an inert slider end. Use the raw value for flow-speed and + keep the 2x response for the geometry controls (gravity, repel, + link, etc.) where the centered calibration is intentional. */ + if (id === 'graph-flow-speed') { + const rawFlow = graphValueInRange(id, value, baseline); + return Number.isFinite(Number(rawFlow)) ? Number(rawFlow) : baseline; + } const raw = graphValueInRange(id, value, baseline); const center = Number.isFinite(Number(baseline)) ? Number(baseline) : raw; const min = Number(control.min); From 3cd7edcdda633b5558781ba79dfc0029e5375d8d Mon Sep 17 00:00:00 2001 From: coding-dev-tools Date: Fri, 28 Aug 2026 07:45:09 -0400 Subject: [PATCH 3/3] test(graph-engine): update flow-speed expectation after the 2x bypass The P2 codex fix on PR #180 (commit 76424c5) bypasses the 2x response mapping for `graph-flow-speed` so the slider's visible endpoints are linear and the new `flowSpeed=0` stop guard fires only at user-selected zero, not throughout the lower quarter of the visible range. The e2e test in `tests/e2e/graph-engine.spec.js::served Ledger handles overflows, label overlays, and orbit pause` set the visible slider to 65 and asserted the engine received 85 (= 45 + (65-45)*2, the old 2x response). With the bypass the engine now receives the raw value (65), so the expected `flowSpeed` in `rangeResponse.settings` is 65 instead of 85. The other test in `tests/e2e/ledger.spec.js` (visible value `'45'` default, set-then-expect `'67'`) is not affected because that test sets the slider to 67 directly and reads the resulting HTML attribute, neither of which goes through `graphSliderResponseValue` (the function only mutates the `settings` object passed to the engine). The new linear flow-speed slider still shows 67 on the dashboard when the user moves it to 67 and the engine receives 67. --- tests/e2e/graph-engine.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/graph-engine.spec.js b/tests/e2e/graph-engine.spec.js index 5b7e8b39..fd93a625 100644 --- a/tests/e2e/graph-engine.spec.js +++ b/tests/e2e/graph-engine.spec.js @@ -1937,7 +1937,7 @@ test('served Ledger wires normalized spacetime controls, overlay, and orbit paus }; }); expect(rangeResponse.settings).toMatchObject({ - flowSpeed: 85, repel: 200, link: 32, gravity: 144, size: 5, font: 20, + flowSpeed: 65, repel: 200, link: 32, gravity: 144, size: 5, font: 20, linkw: 1.28, labelDensity: 56, }); expect(rangeResponse.scope).toEqual({ minDegree: 2, depth: 3 });