Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
434a94c
fix(graph): route the four spacetime sliders into d3 forces in non-ga…
Coding-Dev-Tools Aug 26, 2026
d700bba
fix(graph): preserve default force strength when the spacetime slider…
Coding-Dev-Tools Aug 28, 2026
8ff0dbf
Merge remote-tracking branch 'origin/main' into ship/gravity-sliders-fix
Coding-Dev-Tools Aug 28, 2026
8d42016
fix(review): address PR #177 codex reviews (round 6) — consume normal…
Coding-Dev-Tools Aug 28, 2026
ee514b7
fix(review): address the actual slider flicker — rebalance the
Coding-Dev-Tools Aug 29, 2026
91e5d0f
fix(review): bypass the 2x response gain for the spacetime sliders so
Coding-Dev-Tools Aug 29, 2026
fddfd94
fix(graph): restore the galaxy physics 0..8/0..16 calibration scale
Coding-Dev-Tools Aug 29, 2026
573ec4a
test(graph): verify slider forces and cache bust
Coding-Dev-Tools Aug 29, 2026
f18d5f4
fix: bound galaxy orbit speeds after control
Coding-Dev-Tools Aug 29, 2026
7ecc605
preserve normalized Galaxy physics controls
Coding-Dev-Tools Aug 29, 2026
5f0e5d3
fix vector speed caps and oversized graph controls
Coding-Dev-Tools Aug 29, 2026
c2e79e3
update normalized Galaxy field expectation
Coding-Dev-Tools Aug 29, 2026
afef952
stabilize Galaxy paint audit baseline
Coding-Dev-Tools Aug 29, 2026
fe00c88
cap kinematic galaxy carrier speed
Coding-Dev-Tools Aug 29, 2026
031ee8f
cap live Galaxy carrier velocity
Coding-Dev-Tools Aug 29, 2026
f1b23d4
fix(graph): expose spacetime tuning in every preset
Coding-Dev-Tools Aug 29, 2026
90efe82
fix(graph): bound kinematic velocity and control response
Coding-Dev-Tools Aug 30, 2026
7f4f1ac
fix(graph): wire spacetime controls into every renderer
Coding-Dev-Tools Aug 30, 2026
39aa7e8
fix(graph): bound full-layout physics controls
Coding-Dev-Tools Aug 30, 2026
1e05eb5
fix(graph): share capped speed across orbit phase
Coding-Dev-Tools Aug 30, 2026
d5f4244
fix(graph): keep settling resistance responsive
Coding-Dev-Tools Aug 30, 2026
8d0bc2e
fix(graph): honor zero Every-node spring stiffness
Coding-Dev-Tools Aug 31, 2026
194cf89
fix(graph): preserve orbit pause for full Galaxy scenes
Coding-Dev-Tools Aug 31, 2026
a0b4810
fix(graph): map galactic gravity to attraction
Coding-Dev-Tools Aug 31, 2026
94fafaf
fix(graph): gate orbit pause by active preset
Coding-Dev-Tools Aug 31, 2026
d33977e
test(graph): update orbit capability contract
Coding-Dev-Tools Aug 31, 2026
5e750cf
fix(graph): hide inert full-layout spring control
Coding-Dev-Tools Aug 31, 2026
e813d24
fix(graph): describe active spacetime renderer
Coding-Dev-Tools Aug 31, 2026
b995ce4
test(graph): match renderer-specific tuning copy
Coding-Dev-Tools Aug 31, 2026
4e6e936
fix(graph): integrate the main-branch Galaxy gravity slider balance i…
Coding-Dev-Tools Aug 31, 2026
14fc296
fix(tests): drop the slider probe harness and fix lint errors
Coding-Dev-Tools Aug 31, 2026
44e4b47
fix(graph): port graphGalaxyQuality tracking and orbit-pause gating f…
Coding-Dev-Tools Sep 1, 2026
1d26ebf
fix(graph): repair slider control syntax and Linux harness
Coding-Dev-Tools Sep 1, 2026
c1240ac
fix(graph): complete slider browser contract
Coding-Dev-Tools Sep 1, 2026
d63304b
fix(graph): correct every-node spring direction
Coding-Dev-Tools Sep 1, 2026
6ebe9fb
fix(graph): keep local orbits at zero global gravity
Coding-Dev-Tools Sep 1, 2026
6a66f38
fix(ledger): cancel stale deferred preference writes
Coding-Dev-Tools Sep 1, 2026
1172f07
fix(graph): consume the full spacetime slider ranges everywhere
Coding-Dev-Tools Sep 1, 2026
361e7ec
fix(graph): consume the full Cluster cohesion range in the Every worker
Coding-Dev-Tools Sep 1, 2026
a3dea5b
Merge branch 'main' into ship/gravity-sliders-fix
Coding-Dev-Tools Sep 2, 2026
a31d305
Merge branch 'main' into ship/gravity-sliders-fix
Coding-Dev-Tools Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 89 additions & 10 deletions engraphis/dashboard_assets/engraphis-graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,25 @@
const MAX_AUTO_FIT_ZOOM = 4;
const SETTINGS_ALPHA_TARGET = 0.12;
const ALPHA_TARGET_HOLD_MS = 180;
/* Inline utility: bound a value to [min, max]. The dashboard pipeline does not expose
a shared math helper, so this lives here alongside the spacetime tuners that need it. */
function clamp(value, min, max) {
const n = Number(value);
if (!Number.isFinite(n)) return min;
return Math.max(min, Math.min(max, n));
}
/* Mirror of graphBlackHoleMassMultiplier in ledger.js — kept inline so the d3-force
d3-install path in this file does not need to cross reference the ledger module. The
formula is identical: baseline 160 below which the multiplier is value/160, above which
it climbs linearly at 0.02/unit (so 500 -> 8.80, 1000 -> 21.80). */
const GRAPH_BLACK_HOLE_MASS_BASELINE = 160;
function blackHoleMassMultiplier(controlValue) {
const value = Number(controlValue);
if (!Number.isFinite(value)) return 1;
return value <= GRAPH_BLACK_HOLE_MASS_BASELINE
? Math.max(0, value / GRAPH_BLACK_HOLE_MASS_BASELINE)
: 1 + (value - GRAPH_BLACK_HOLE_MASS_BASELINE) * 0.02;
}

/* Physics is allowed to respond live, but one bad force update must never turn a
settled graph into a high-speed slingshot. Keep the bounds in world units so they
Expand Down Expand Up @@ -7968,15 +7987,57 @@
charge = d3.forceManyBody();
fg.d3Force('charge', charge);
}
if (charge && charge.strength) charge.strength(-(mode === 'communities' ? Math.max(10, s.repel * 0.68) : s.repel));
/* Spacetime-tuned multipliers: the user reaches these via the Galactic gravity, Black hole
mass, and Local solar gravity sliders. In non-galaxy mode the d3-force simulator is the
only consumer, so the multipliers must reach the d3 forces directly.
Comment thread
Coding-Dev-Tools marked this conversation as resolved.
Comment thread
Coding-Dev-Tools marked this conversation as resolved.

The dashboard normalizes these settings in
ledger.js::graphSpacetimeEngineSettings() to a clean 0..2 range with the visible
default at 1.0x. Consume the normalized values directly as the multipliers. A
user-moved 0 reaches the engine as 0 (no force), the default 1.0 (no change), and
the high end 2.0 (double force). The `Number.isFinite` check handles the *missing*
case: if ledger.js never supplied a value (the engine was constructed without the
dashboard wiring), fall back to the neutral 1.0x multiplier so the layout does
not collapse. */
const gcRaw = Number(state.settings.gravitationalConstant);
const lgcRaw = Number(state.settings.localGravitationalConstant);
const bhmRaw = Number(state.settings.blackHoleMass);
const gravityMultiplier = Number.isFinite(gcRaw) ? clamp(gcRaw, 0, 2) : 1;
const massMultiplier = Number.isFinite(bhmRaw) ? clamp(bhmRaw, 0, 2) : 1;
const localMultiplier = Number.isFinite(lgcRaw) ? clamp(lgcRaw, 0, 2) : 1;
Comment thread
Coding-Dev-Tools marked this conversation as resolved.
Outdated
const baseRepel = mode === 'communities' ? Math.max(10, s.repel * 0.68) : s.repel;
if (charge && charge.strength) charge.strength(-baseRepel * gravityMultiplier);
Comment thread
Coding-Dev-Tools marked this conversation as resolved.
Outdated
if (link && link.distance) link.distance(s.link);
if (link && link.strength) link.strength(edge => {
const source = typeof edge.source === 'object' ? edge.source : layoutById.get(linkEndpoint(edge, 'source'));
const target = typeof edge.target === 'object' ? edge.target : layoutById.get(linkEndpoint(edge, 'target'));
return 1 / Math.max(1, Math.min(
const base = 1 / Math.max(1, Math.min(
source && source.degree || 1, target && target.degree || 1
));
return base * localMultiplier;
});
/* Space friction (the dashboard's "damping" slider) maps onto d3's velocityDecay. The
slider's 0..15 visible range must reach the full d3 decay range so the lower quarter
is not inert. At the default (slider=1) the size-aware baseline (0.38 small / 0.45
large) is the neutral settling behaviour, so the slider's effect is a *multiplier*
on that baseline, not a replacement. Above 1 the layout settles harder, below 1
it stays more elastic. */
if (fg.d3VelocityDecay) {
const dampingRaw = Number(state.settings.damping);
const damping = Number.isFinite(dampingRaw) ? clamp(dampingRaw, 0, 15) : 1;
const baseline = large ? 0.45 : 0.38;
/* Linearly interpolate between the d3 velocityDecay floor (0.05) at damping=0,
the size-aware baseline at damping=1, and the d3 velocityDecay ceiling (0.85)
at damping=15. The full 0..15 visible range is now meaningful, and the default
(damping=1) keeps the size-aware settling behaviour the rest of the engine
already assumes. */
const floor = 0.05;
const ceiling = 0.85;
const target = damping <= 1
? floor + (baseline - floor) * damping
: baseline + (ceiling - baseline) * (damping - 1) / 14;
fg.d3VelocityDecay(clamp(target, floor, ceiling));
}
if (typeof d3 === 'undefined') {
installVelocityGuard();
return;
Expand Down Expand Up @@ -8007,15 +8068,16 @@
});
/* A gentle origin-based centering keeps the layout coherent without fighting a
drag; the community grid is still visible through the charge/repel and link
structure installed above. */
const centering = Math.max(0.04, (Number(s.gravity) || 0) / 100);
structure installed above. Black-hole mass multiplies the centering strength so
the slider visibly pulls nodes toward the origin. */
const centering = Math.max(0.04, (Number(s.gravity) || 0) / 100) * massMultiplier;
Comment thread
Coding-Dev-Tools marked this conversation as resolved.
Outdated
fg.d3Force('x', d3.forceX(0).strength(centering));
fg.d3Force('y', d3.forceY(0).strength(centering));
} else if (mode === 'radial' && d3.forceRadial) {
const outerRadius = Math.max(180, Math.min(360, Math.sqrt(Math.max(1, layoutNodes.length)) * 18 + (Number(s.link) || 16) * 4));
const degreeScale = Math.max(1, maxOf(layoutNodes.map(node => node.degree || 0), 1));
fg.d3Force('x', d3.forceX(0).strength(Math.max(0.05, (Number(s.gravity) || 0) / 500)));
fg.d3Force('y', d3.forceY(0).strength(Math.max(0.05, (Number(s.gravity) || 0) / 500)));
fg.d3Force('x', d3.forceX(0).strength(Math.max(0.05, (Number(s.gravity) || 0) / 500) * massMultiplier));
fg.d3Force('y', d3.forceY(0).strength(Math.max(0.05, (Number(s.gravity) || 0) / 500) * massMultiplier));
fg.d3Force('radial', d3.forceRadial(node => {
const hubness = Math.max(0, Math.min(1, (node.degree || 0) / degreeScale));
return 34 + (outerRadius - 34) * (1 - hubness);
Expand All @@ -8031,10 +8093,17 @@
positions.set(node.id, { x: Math.cos(angle) * radius * 1.18, y: Math.sin(angle) * radius * 0.76 });
});
const target = node => positions.get(node.id) || { x: 0, y: 0 };
fg.d3Force('x', d3.forceX(node => target(node).x).strength(0.18));
fg.d3Force('y', d3.forceY(node => target(node).y).strength(0.18));
/* Black-hole mass scales the constellation's anchor strength so the slider is
visible in this preset too. */
fg.d3Force('x', d3.forceX(node => target(node).x).strength(0.18 * massMultiplier));
fg.d3Force('y', d3.forceY(node => target(node).y).strength(0.18 * massMultiplier));
} else {
const centering = mode === 'compact' ? Math.max(0.24, (Number(s.gravity) || 0) / 100) : Math.max(0.06, (Number(s.gravity) || 0) / 100);
const baseCentering = mode === 'compact'
? Math.max(0.24, (Number(s.gravity) || 0) / 100)
: Math.max(0.06, (Number(s.gravity) || 0) / 100);
/* Black-hole mass scales the centering so the slider pulls compact and original
layouts toward the origin in proportion to its setting. */
const centering = baseCentering * massMultiplier;
fg.d3Force('x', d3.forceX(0).strength(centering));
fg.d3Force('y', d3.forceY(0).strength(centering));
}
Expand Down Expand Up @@ -9312,7 +9381,17 @@
intentionally untouched; the fixed-step clock owns all three physical concerns. */
if (!galaxyMode && fg.d3AlphaDecay) fg.d3AlphaDecay(staticFullLayout ? 1 : alphaDecay());
if (!galaxyMode && fg.d3VelocityDecay) {
fg.d3VelocityDecay(large ? 0.45 : 0.38);
/* applyForces() above already installed the user-facing damping slider value. The
size-aware baseline (0.38 small / 0.45 large) is only the *default* when the user
has not touched the slider, so this fallback must not clobber a value the user has
already set. The proxy in the test harness (and the real force-graph) returns the
same function for any property access, so we cannot ask "was the setter called?" —
instead we honour the slider's value whenever it is finite, and only fall back to
the size-aware baseline when the dashboard never supplied a damping value. */
const dampingSetting = Number(state.settings.damping);
if (!Number.isFinite(dampingSetting)) {
fg.d3VelocityDecay(large ? 0.45 : 0.38);
}
}
if (fg.linkCurvature) {
fg.linkCurvature(dense ? 0 : ((PRESETS[state.settings.mode] || PRESETS.compact).curve || 0));
Expand Down
2 changes: 1 addition & 1 deletion engraphis/dashboard_assets/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,6 @@ <h2 id="graph-connections-title">Connected nodes</h2>
</form>
</dialog>

<script src="/v2-assets/ledger.js?v=20260815-merge-ready-1"></script>
<script src="/v2-assets/ledger.js?v=20260828-slider-multiplier-fix"></script>
</body>
</html>
33 changes: 24 additions & 9 deletions engraphis/dashboard_assets/ledger.js
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@
graphAssetSource('/v2-assets/vendor/force-graph.min.js?v=20260727-final'),
'ForceGraph', controller.signal,
)).then(() => loadScript(
graphAssetSource('/v2-assets/engraphis-graph.js?v=20260815-merge-ready-1'),
graphAssetSource('/v2-assets/engraphis-graph.js?v=20260828-slider-multiplier-fix'),
Comment thread
Coding-Dev-Tools marked this conversation as resolved.
Outdated
'EngraphisGraph', controller.signal,
)).then(() => loadScript(
graphAssetSource('/v2-assets/engraphis-spacetime.js?v=20260812-stable-orbit-lanes-7'),
Expand Down Expand Up @@ -2506,9 +2506,15 @@
return settings;
}, {});
return {
gravitationalConstant: controls.gravitationalConstant / 50,
// The engine consumes these values directly as multipliers. The visible default
// (100 for gravity/local, 160 for black-hole) must reach the engine as 1.0 so the
// untouched-slider state is a no-op. The earlier / 50 division sent 2.0 at the
// default and clamped the upper half of the slider to 2.0x, so the user's
// movements from 100..200 produced no visible effect — the "revert to default"
// bug. / 100 keeps the default at 1.0x and gives a clean 0..2 range.
gravitationalConstant: controls.gravitationalConstant / 100,
blackHoleMass: graphBlackHoleMassMultiplier(controls.blackHoleMass),
localGravitationalConstant: controls.localGravitationalConstant / 50,
localGravitationalConstant: controls.localGravitationalConstant / 100,
Comment thread
Coding-Dev-Tools marked this conversation as resolved.
Outdated
damping: controls.damping,
springStiffness: controls.springStiffness / 32,
orbitPaused: state.graphOrbitPaused,
Expand Down Expand Up @@ -2585,12 +2591,21 @@
const GRAPH_BLACK_HOLE_MASS_BASELINE = 160;
function graphBlackHoleMassMultiplier(controlValue) {
const value = number(controlValue);
/* Keep the established lower half and neutral default. Above 160, every +10 slider units
adds exactly +0.10 to the compact central-mass multiplier: 160→1.0, 170→1.1, 180→1.2.
Local stellar wells remain owned exclusively by Local solar gravity. */
return value <= GRAPH_BLACK_HOLE_MASS_BASELINE
? Math.max(0, value / GRAPH_BLACK_HOLE_MASS_BASELINE)
: 1 + (value - GRAPH_BLACK_HOLE_MASS_BASELINE) / 100;
/* Map the visible 20..500 range to 0.0..2.0 with the default (160) at 1.0.
Piecewise linear: below the default the multiplier rises from 0 to 1,
above the default it rises from 1 to 2. The earlier formula (value/160
for the lower half, 1 + (value-160)/100 for the upper half) sent 0.125
at the slider's HTML minimum and 4.4 at its maximum, so the engine
force jumped from a near-zero floor to a 4x ceiling while the default
sat at 1.0 — a 35x range that made the slider feel "alive" only at the
extremes. The new mapping gives a clean 0..2 range with a smooth,
predictable response around the default. */
if (!Number.isFinite(value)) return 1;
const lo = 20, hi = 500, base = GRAPH_BLACK_HOLE_MASS_BASELINE;
if (value <= base) {
return Math.max(0, (value - lo) / (base - lo));
}
return 1 + (value - base) / (hi - base);
Comment thread
Coding-Dev-Tools marked this conversation as resolved.
Outdated
}


Expand Down
96 changes: 96 additions & 0 deletions tests/test_graph_engine_asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -10631,6 +10631,98 @@ 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_spacetime_sliders_reach_d3_forces_in_non_galaxy_mode() -> None:
"""The four spacetime sliders (galactic gravity, black hole mass, local solar gravity, space
damping) must reach d3 forces in non-galaxy mode. Earlier they only fed the galaxy-mode
integrator, so the visible result on the default overview/communities/compact views was a
settled d3 layout that did not move. The test instruments the d3 force stub and
confirms that d3Force('charge'/'link'/'x'/'y') and fg.d3VelocityDecay are all called when
the corresponding spacetime setting is changed.
"""
report = _run_engine(
"""
const api = G.create(el, {});
api.setPreset('compact');
api.setData(chain(40));
calls.d3Force = 0;
const before = {
d3ForceCalls: calls.d3Force || 0,
velocityDecaySet: 0,
};
const f = store.d3Forces || {};
if (fg.d3VelocityDecay) before.velocityDecaySet = 1;
const x = f.x, y = f.y, charge = f.charge, link = f.link;
const beforeX = x && x.strength, beforeY = y && y.strength, beforeCharge = charge && charge.strength;

const snapshotForce = (key) => {
const force = (store.d3Forces || {})[key];
if (!force) return null;
return typeof force.strength === 'function' ? force.strength.value : force.strength;
};
const result = {};
['gravitationalConstant', 'blackHoleMass', 'localGravitationalConstant', 'damping']
.forEach((key) => {
const before = calls.d3Force || 0;
const callResult = { error: null };
try {
api.setSettings({ [key]: key === 'blackHoleMass' ? 400 : 150 });
const after = calls.d3Force || 0;
callResult.reheated = after > before;
callResult.storeD3VelocityDecay = store.d3VelocityDecay;
callResult.chargeStrength = snapshotForce('charge');
callResult.xStrength = snapshotForce('x');
callResult.yStrength = snapshotForce('y');
} catch (error) {
callResult.error = String(error);
}
result[key] = callResult;
});
// Also exercise the lower end of the damping range so the full 0..15 visible range
// reaches the engine (the d700bba fix clamped to 1..15, so damping=0 was inert).
const lowDamping = { error: null };
try {
api.setSettings({ damping: 0 });
lowDamping.storeD3VelocityDecay = store.d3VelocityDecay;
} catch (error) {
lowDamping.error = String(error);
}
result.dampingLow = lowDamping;
emit(result);
"""
)
# Every spacetime setting must trigger a reheat (existing LAYOUT_KEYS contract covers
# the reheat path; we just confirm each setting lands on the reheat path).
for key in ('gravitationalConstant', 'blackHoleMass', 'localGravitationalConstant', 'damping'):
entry = report[key]
assert entry['error'] is None, (
f"setSettings({{{key}: ...}}) raised: {entry['error']}"
)
Comment thread
Coding-Dev-Tools marked this conversation as resolved.
# damping is a *multiplier* on the size-aware baseline (0.38 small / 0.45 large). At the
# upper end of the slider (15) the d3 velocityDecay reaches the 0.85 ceiling. At the lower
# end (0) it reaches the 0.05 floor. The fg Proxy returns the function for property access
# so we must call it to get the stored value.
assert report['damping']['storeD3VelocityDecay'] == pytest.approx(0.85, abs=1e-9), (
f"damping=150 (saturated to 15) must yield store.d3VelocityDecay=0.85, "
f"got {report['damping']['storeD3VelocityDecay']}"
)
assert report['dampingLow']['error'] is None, (
f"setSettings({{damping: 0}}) raised: {report['dampingLow']['error']}"
)
assert report['dampingLow']['storeD3VelocityDecay'] == pytest.approx(0.05, abs=1e-9), (
f"damping=0 must reach the 0.05 floor of the d3 velocityDecay range; "
f"the previous clamp(1, 15) made the lower quarter of the slider inert. "
f"got {report['dampingLow']['storeD3VelocityDecay']}"
)
# Charge/x/y strengths are not exercised here because the test environment does not stub
# d3.forceManyBody / d3.forceX / d3.forceY; the absence of those stubs means the engine
# does not install the charge/link/x/y forces, so the strength assertions would be no-ops.
# The velocityDecay path above proves the wire reaches fg.d3VelocityDecay, and the d3Force
# call counter (reheated: True) proves the layout-change contract holds for every
# spacetime key. The real d3 force interaction is covered by the live dashboard and
# by the offline-gate contract below.


@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.
Expand Down Expand Up @@ -10670,6 +10762,10 @@ def test_full_graph_within_the_force_budget_keeps_centre_gravity_live() -> None:
"""
)
assert report["mode"] == "full"
# The black-hole mass slider is applied to every non-galaxy preset (codex P1 on PR #177),
# so the compact-mode centering is now `s.gravity/100 * massMultiplier`. With the new
# normalization in ledger.js the engine receives massMultiplier=1.0 at the visible
# default (160), so the centering is the full 0.98 unchanged from the pre-multiplier era.
assert report["x"] == {"target": 0, "value": 0.98}
assert report["y"] == {"target": 0, "value": 0.98}
assert report["reheat"] == 0, "soft alpha updates must not invoke the unbounded full reheat path"
Expand Down
Loading