From 544aaa661649d0be7e96253384ca77269378bad0 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Sun, 6 Sep 2026 03:55:32 -0400 Subject: [PATCH 1/5] fix(dashboard): refine galaxy layout and physics response --- engraphis/core/graph_scene.py | 156 +++++++++++++----- engraphis/dashboard_assets/engraphis-graph.js | 145 +++++++++++----- engraphis/dashboard_assets/index.html | 2 +- engraphis/dashboard_assets/ledger.js | 4 +- tests/e2e/graph-engine.spec.js | 4 +- tests/e2e/ledger.spec.js | 6 +- tests/test_graph_engine_asset.py | 20 +-- tests/test_ledger_sliders_and_physics.py | 8 +- 8 files changed, 243 insertions(+), 102 deletions(-) diff --git a/engraphis/core/graph_scene.py b/engraphis/core/graph_scene.py index f60e5881..cee320b8 100644 --- a/engraphis/core/graph_scene.py +++ b/engraphis/core/graph_scene.py @@ -707,47 +707,121 @@ def _community_positions( # survive the overview cap. Rank-based assignment (rank/N) fails when only the top-K # by mass are shown — they occupy a tight arc instead of spreading evenly. GOLDEN_ANGLE_RAD = math.pi * (3.0 - math.sqrt(5.0)) - orbital_rank = 0 - for community in ordered: - community_id = str(community["id"]) - system_radius = _clamp( - _finite_float(community.get("radius"), 36.0), 36.0, 10_000.0 - ) - if community_id == global_community_id: + non_global = [c for c in ordered if str(c["id"]) != global_community_id] + non_global_count = len(non_global) + + if non_global_count <= 1: + orbital_rank = 0 + for community in ordered: + community_id = str(community["id"]) + system_radius = _clamp( + _finite_float(community.get("radius"), 36.0), 36.0, 10_000.0 + ) + if community_id == global_community_id: + specs.append({ + "id": community_id, "system_radius": system_radius, + "arm": -1, "nominal_x": 0.0, "nominal_y": 0.0, + }) + continue + arm = orbital_rank % arm_count if arm_count > 0 else 0 + digest = hashlib.sha256( + f"{ALGORITHM_VERSION}:{layout_seed}:system:{community_id}".encode("utf-8") + ).digest() + # Small angular jitter for visual variety; kept tight so even spacing dominates. + angular_jitter = ( + int.from_bytes(digest[:4], "big") / float(1 << 32) - 0.5 + ) * 0.06 + radial_jitter = 0.95 + ( + int.from_bytes(digest[4:8], "big") / float(1 << 32) + ) * 0.10 + # Golden-angle based placement: each successive system advances by ≈137.5°. + # This guarantees that any contiguous or sampled subset fills the circle evenly. + golden_angle = base_phase + orbital_rank * GOLDEN_ANGLE_RAD + angle = golden_angle + angular_jitter + # Ring radius clears the core envelope. Inter-system clearance is handled + # per-pair in the collision pass using actual radii, not a pessimistic global max. + baseline_radius = max( + core_clearance_radius, + spacing * 0.90 * radial_jitter, + ) specs.append({ - "id": community_id, "system_radius": system_radius, - "arm": -1, "nominal_x": 0.0, "nominal_y": 0.0, + "id": community_id, + "system_radius": system_radius, + "arm": arm, + "nominal_x": baseline_radius * math.cos(angle), + "nominal_y": baseline_radius * math.sin(angle), }) - continue - arm = orbital_rank % arm_count if arm_count > 0 else 0 - digest = hashlib.sha256( - f"{ALGORITHM_VERSION}:{layout_seed}:system:{community_id}".encode("utf-8") - ).digest() - # Small angular jitter for visual variety; kept tight so even spacing dominates. - angular_jitter = ( - int.from_bytes(digest[:4], "big") / float(1 << 32) - 0.5 - ) * 0.06 - radial_jitter = 0.95 + ( - int.from_bytes(digest[4:8], "big") / float(1 << 32) - ) * 0.10 - # Golden-angle based placement: each successive system advances by ≈137.5°. - # This guarantees that any contiguous or sampled subset fills the circle evenly. - golden_angle = base_phase + orbital_rank * GOLDEN_ANGLE_RAD - angle = golden_angle + angular_jitter - # Ring radius clears the core envelope. Inter-system clearance is handled - # per-pair in the collision pass using actual radii, not a pessimistic global max. - baseline_radius = max( - core_clearance_radius, - spacing * 1.10 * radial_jitter, - ) - specs.append({ - "id": community_id, - "system_radius": system_radius, - "arm": arm, - "nominal_x": baseline_radius * math.cos(angle), - "nominal_y": baseline_radius * math.sin(angle), - }) - orbital_rank += 1 + orbital_rank += 1 + else: + # Multi-tiered concentric orbital lanes: distribute communities across radial bands + # (inner, mid-inner, mid-outer, outer) filling the 2D disk from the core clearance radius + # outward. Each tier accommodates as many systems as geometrically fit without overlap + # before placing subsequent systems on the next radial tier, interleaved with golden-angle + # angular offsets. This prevents all star systems from colliding onto a single outer hoop. + for community in ordered: + if str(community["id"]) == global_community_id: + system_radius = _clamp( + _finite_float(community.get("radius"), 36.0), 36.0, 10_000.0 + ) + specs.append({ + "id": str(community["id"]), "system_radius": system_radius, + "arm": -1, "nominal_x": 0.0, "nominal_y": 0.0, + }) + break + + avg_sys_radius = sum( + _clamp(_finite_float(c.get("radius"), 36.0), 36.0, 10_000.0) + for c in non_global + ) / non_global_count + tier_step = max(spacing * 0.65, 2.0 * avg_sys_radius + GALAXY_SYSTEM_MIN_GAP * 0.35) + + tiers: list[dict[str, float | int]] = [] + curr_radius = core_clearance_radius + avg_sys_radius * 0.25 + remaining = non_global_count + while remaining > 0: + circ = 2.0 * math.pi * curr_radius + envelope_size = 2.0 * avg_sys_radius + GALAXY_SYSTEM_MIN_GAP * 0.35 + capacity = max(2, int(circ / envelope_size)) + take = min(capacity, remaining) + tiers.append({ + "radius": curr_radius, + "count": take, + }) + remaining -= take + curr_radius += tier_step + + sys_idx = 0 + for tier_info in tiers: + t_rad = float(tier_info["radius"]) + t_count = int(tier_info["count"]) + for _ in range(t_count): + community = non_global[sys_idx] + community_id = str(community["id"]) + system_radius = _clamp( + _finite_float(community.get("radius"), 36.0), 36.0, 10_000.0 + ) + arm = sys_idx % arm_count if arm_count > 0 else 0 + digest = hashlib.sha256( + f"{ALGORITHM_VERSION}:{layout_seed}:system:{community_id}".encode("utf-8") + ).digest() + angular_jitter = ( + int.from_bytes(digest[:4], "big") / float(1 << 32) - 0.5 + ) * 0.06 + radial_jitter = 0.96 + ( + int.from_bytes(digest[4:8], "big") / float(1 << 32) + ) * 0.08 + golden_angle = base_phase + sys_idx * GOLDEN_ANGLE_RAD + angle = golden_angle + angular_jitter + + nominal_r = max(core_clearance_radius, t_rad * radial_jitter) + specs.append({ + "id": community_id, + "system_radius": system_radius, + "arm": arm, + "nominal_x": nominal_r * math.cos(angle), + "nominal_y": nominal_r * math.sin(angle), + }) + sys_idx += 1 def pack_with_radial_clearance( targets: Mapping[str, tuple[float, float]], @@ -2341,7 +2415,7 @@ def _build_complete_scene( str(all_nodes[global_anchor]["community_id"]) if global_anchor else "" ) positions, community_hints = _community_positions( - communities, global_community_id, layout_seed, spacing=92.0 + communities, global_community_id, layout_seed, spacing=74.0 ) for community in communities: community.update(community_hints[community["id"]]) @@ -2890,7 +2964,7 @@ def eligible(node_id: str) -> bool: graph, set(graph["community_members"]), set(graph["nodes"]), _system_radii ) layout_positions, layout_hints = _community_positions( - layout_communities, global_community_id, layout_seed, spacing=98.0 + layout_communities, global_community_id, layout_seed, spacing=78.0 ) seeded_positions = _orbital_layout_positions( graph["nodes"], graph["community_members"], graph["community_anchors"], diff --git a/engraphis/dashboard_assets/engraphis-graph.js b/engraphis/dashboard_assets/engraphis-graph.js index 5ec211b3..642e3dc3 100644 --- a/engraphis/dashboard_assets/engraphis-graph.js +++ b/engraphis/dashboard_assets/engraphis-graph.js @@ -9,7 +9,7 @@ with both the dashboard adapter and standalone scene payloads. */ (function () { const PRESETS = { - galaxy: { label: 'Galaxy gravity', repel: 100, link: 8, gravity: 96, font: 12, size: 3, linkw: 0.72, labelDensity: 24, curve: 0.12, particles: 0 }, + galaxy: { label: 'Galaxy gravity', repel: 100, link: 8, gravity: 120, font: 12, size: 3, linkw: 0.72, labelDensity: 24, curve: 0.12, particles: 0 }, original: { label: 'Original force', repel: 120, link: 30, gravity: 14, font: 13, size: 3, linkw: 1, labelDensity: 40, curve: 0, particles: 0 }, compact: { label: 'Compact clusters', repel: 42, link: 20, gravity: 26, font: 12, size: 3, linkw: 0.7, labelDensity: 30, curve: 0.08, particles: 0 }, communities: { label: 'Community islands', repel: 48, link: 16, gravity: 48, font: 12, size: 3, linkw: 0.72, labelDensity: 24, curve: 0.12, particles: 0 }, @@ -318,7 +318,8 @@ const GALAXY_ORBITAL_SPEED_MINIMUM = 0.25; const GALAXY_ORBITAL_SPEED_RESPONSE_GAIN = 0.5; const GALAXY_ORBITAL_SPEED_MAXIMUM = 4.6; - const GALAXY_ORBITAL_RADIUS_MAXIMUM = 1.24; + const GALAXY_ORBITAL_RADIUS_MAXIMUM = 1.06; + const GALAXY_BASE_ORBITAL_SPEED_BOOST = 1.625; function galaxyOrbitalSpeedMultiplier(setting) { const raw = Number(setting); const value = Number.isFinite(raw) @@ -543,23 +544,21 @@ the same immediate ratio response as the primary Gravity slider. They normalize around the calibrated 1.0 defaults, so the scale is unchanged when both sliders sit neutral. */ function galaxyImmediateGravityRadiusScale(setting, centralMultipliers) { - const extra = centralMultipliers && typeof centralMultipliers === 'object' - ? centralMultipliers : {}; - const gCenter = Math.max(0, Number.isFinite(Number(extra.gravitationalConstant)) - ? Number(extra.gravitationalConstant) : 1); - const mass = Math.max(0, Number.isFinite(Number(extra.blackHoleMass)) - ? Number(extra.blackHoleMass) : 1); - /* Field strength follows G * sqrt(mass) (the same law the live integrator uses), so the - density response stays physically consistent with the acceleration it previews. The - normalization keeps the raw gravity-slider endpoint ratio identical to the pre-spacetime - behavior (0.6 at setting 400, 1.0 at setting 0 with neutral multipliers); the central - multipliers then rescale the normalized fraction without clipping the slider's own span. */ - const effective = Math.max(0, galaxyBlackHoleGravityConstant(setting, true) - * gCenter * Math.sqrt(mass)); + const effective = Math.max(0, galaxyBlackHoleGravityConstant(setting, true)); const maximum = Math.max(1e-9, galaxyBlackHoleGravityConstant(GALAXY_GRAVITY_MAXIMUM, true)); - const normalized = Math.max(0, Math.min(1.25, effective / maximum)); - return Math.exp(Math.log(0.6) * normalized); + const normalized = Math.max(0, effective / maximum); + const t = Math.pow(normalized, 0.45); + const baseScale = 1.25 * Math.pow(0.38 / 1.25, t); + const extra = centralMultipliers && typeof centralMultipliers === 'object' + ? centralMultipliers : {}; + const gNorm = extra.gravitationalConstant !== undefined && Number.isFinite(Number(extra.gravitationalConstant)) + ? Math.max(0, Number(extra.gravitationalConstant)) / 2.0 : 1.0; + const mNorm = extra.blackHoleMass !== undefined && Number.isFinite(Number(extra.blackHoleMass)) + ? Math.max(0, Number(extra.blackHoleMass)) / 1.0 : 1.0; + const fCentral = Math.max(0.05, gNorm * Math.sqrt(Math.max(0, mNorm))); + const rMod = Math.pow(fCentral, -0.65); + return baseScale * rMod; } /* The oversized-scene fallback has no live integrator, so its grid must map the complete slider range directly. Keeping the old `setting / 100` scale made compactness hit its @@ -1181,7 +1180,7 @@ || ((seededHash(opts.layoutSeed, 'system:' + String(parent.id)) & 1) ? 1 : -1); const targetTangent = galaxyRelativeSpeedBudget(parent, absoluteSpeedLimit, Math.min(GALAXY_LOCAL_RELATIVE_SPEED_LIMIT, - Math.sqrt(Math.max(0, acceleration * radius)) * orbitalSpeed), + Math.sqrt(Math.max(0, acceleration * radius)) * GALAXY_BASE_ORBITAL_SPEED_BOOST * orbitalSpeed), tangentX * sign, tangentY * sign); const parentId = String(parent.id); const previousParent = typeof node.__galaxyOrbitAnchorId === 'string' @@ -2965,16 +2964,18 @@ authoredHierarchy) * Math.max(0.25, localGravityMultiplier), rawAcceleration); const omega = Math.min( - Math.sqrt(Math.max(0, acceleration / localRadius)) * orbitalSpeed, + Math.sqrt(Math.max(0, acceleration / localRadius)) * GALAXY_BASE_ORBITAL_SPEED_BOOST * orbitalSpeed, GALAXY_LOCAL_RELATIVE_SPEED_LIMIT * orbitalSpeed / localRadius); const requestedLocalSpeed = omega * localRadius; const localTangentX = -Math.sin(local.angle) * local.direction; const localTangentY = Math.cos(local.angle) * local.direction; - const phaseSpeed = Math.min( - galaxyRelativeSpeedBudget(parentTarget, absoluteSpeedLimit, - requestedLocalSpeed, localTangentX, localTangentY), - galaxyRelativeSpeedBudget(parentTarget, absoluteSpeedLimit, requestedLocalSpeed), - ); + const b1 = galaxyRelativeSpeedBudget(parentTarget, absoluteSpeedLimit, + requestedLocalSpeed, localTangentX, localTangentY); + const nextAngle = local.angle + local.direction * (b1 / Math.max(1e-9, localRadius)) * timestep; + const nextTanX = -Math.sin(nextAngle) * local.direction; + const nextTanY = Math.cos(nextAngle) * local.direction; + const phaseSpeed = Math.min(b1, galaxyRelativeSpeedBudget(parentTarget, absoluteSpeedLimit, + b1, nextTanX, nextTanY)); const cappedOmega = phaseSpeed / Math.max(1e-9, localRadius); local.angle += local.direction * cappedOmega * timestep; const offsetX = Math.cos(local.angle) * localRadius; @@ -4260,7 +4261,10 @@ if (chord < laneExtent * 2 + gap - 1e-9) break; capacity = nextCapacity; } - const count = Math.min(capacity, systems.length - cursor); + /* Cap maximum systems per ring so systems form tiered concentric circles + rather than collapsing all systems onto a single giant outer circle. */ + const maxPerRing = Math.max(3, Math.min(6, Math.floor(2 + laneIndex * 1.5))); + const count = Math.min(capacity, maxPerRing, systems.length - cursor); const phaseOffset = seededHash(opts.layoutSeed, 'carrier-ring:' + String(laneIndex)) / 0x100000000 * Math.PI * 2; for (let slot = 0; slot < count; slot++) { @@ -5974,17 +5978,19 @@ collision, and relation work may translate the whole system, but they cannot turn a planet backward or pull it onto a chord through the star. */ const timestep = Math.max(0.001, Math.min(2, Number(opts.timestep) || 1)); - const requestedRelativeSpeed = baseSpeed * orbitalSpeed; + const requestedRelativeSpeed = baseSpeed * GALAXY_BASE_ORBITAL_SPEED_BOOST * orbitalSpeed; const phaseTangentX = -Math.sin(phase.angle) * phase.direction; const phaseTangentY = Math.cos(phase.angle) * phase.direction; /* Use one scalar for the phase clock and emitted velocity. The final tangent rotates - during the step, so also apply the direction-independent residual cap; reusing a - pre-step directional budget after that rotation must never exceed the absolute cap. */ - const phaseSpeed = Math.min( - galaxyRelativeSpeedBudget(parent, absoluteSpeedLimit, - requestedRelativeSpeed, phaseTangentX, phaseTangentY), - galaxyRelativeSpeedBudget(parent, absoluteSpeedLimit, requestedRelativeSpeed), - ); + during the step, so apply the directional budget across both start and end tangents; + this preserves full perpendicular orbital velocity without exceeding the absolute cap. */ + const b1 = galaxyRelativeSpeedBudget(parent, absoluteSpeedLimit, + requestedRelativeSpeed, phaseTangentX, phaseTangentY); + const nextAngle = phase.angle + phase.direction * (b1 / Math.max(1e-6, targetRadius)) * timestep; + const nextTanX = -Math.sin(nextAngle) * phase.direction; + const nextTanY = Math.cos(nextAngle) * phase.direction; + const phaseSpeed = Math.min(b1, galaxyRelativeSpeedBudget(parent, absoluteSpeedLimit, + b1, nextTanX, nextTanY)); const angularSpeed = phaseSpeed / Math.max(1e-6, targetRadius); phase.angle += phase.direction * angularSpeed * timestep; const unitX = Math.cos(phase.angle), unitY = Math.sin(phase.angle); @@ -7824,9 +7830,9 @@ + system.radius), 1); const available = Math.max(1, Math.min(width, height) - 2 * padding); fg.centerAt(anchor.x, anchor.y, duration); - /* Reserve a small paint/camera margin for trails, labels and sub-pixel transforms; + /* Reserve a balanced paint/camera margin for trails, labels and sub-pixel transforms; the physical lane projector keeps carriers inside this stable disk afterward. */ - fg.zoom(Math.min(MAX_AUTO_FIT_ZOOM, available / (diskRadius * 2.3)), duration); + fg.zoom(Math.min(MAX_AUTO_FIT_ZOOM, available / (diskRadius * 1.45)), duration); return; } } @@ -10290,6 +10296,9 @@ always yields previous == next (ratio 1, no visible response). */ const previousGCenter = Number(state.settings.gravitationalConstant); const previousBlackHoleMass = Number(state.settings.blackHoleMass); + const previousLocalG = Number(state.settings.localGravitationalConstant !== undefined + ? state.settings.localGravitationalConstant + : (state.settings.G_star !== undefined ? state.settings.G_star : 100)); Object.assign(state.settings, next); if (next.orbitPaused !== undefined && previousMode === 'galaxy') { if (state.settings.orbitPaused) cancelGalaxyDynamics(true); @@ -10309,6 +10318,14 @@ || next.blackHoleMass !== undefined) && previousMode === 'galaxy' && state.settings.mode === 'galaxy'; const spacetimeChanged = gravityChanged || centralChanged; + const nextLocalG = Number(state.settings.localGravitationalConstant !== undefined + ? state.settings.localGravitationalConstant + : (state.settings.G_star !== undefined ? state.settings.G_star : 100)); + const localGChanged = (next.localGravitationalConstant !== undefined || next.G_star !== undefined) + && Number.isFinite(previousLocalG) && Number.isFinite(nextLocalG) + && previousLocalG > 0 && nextLocalG > 0 + && Math.abs(nextLocalG - previousLocalG) > 1e-12 + && previousMode === 'galaxy' && state.settings.mode === 'galaxy'; /* A galaxy slider burst (gravity / black-hole mass / damping / etc.) is a setting change, not a fresh physics seed. Set the phase-preserve flag *before* any render below so the inner immediate-render does not re-seed orbits and overwrite the just-scaled carrier @@ -10358,11 +10375,14 @@ if (!item.carrier || item.nodes.includes(anchor)) return; const dx = item.carrier.x - anchor.x; const dy = item.carrier.y - anchor.y; - if (!Number.isFinite(dx) || !Number.isFinite(dy)) return; + const targetCarrierX = anchor.x + dx * ratio; + const targetCarrierY = anchor.y + dy * ratio; + const shiftX = targetCarrierX - item.carrier.x; + const shiftY = targetCarrierY - item.carrier.y; item.nodes.forEach(node => { if (node === anchor || node.ghost) return; - const nx = anchor.x + (node.x - anchor.x) * ratio; - const ny = anchor.y + (node.y - anchor.y) * ratio; + const nx = node.x + shiftX; + const ny = node.y + shiftY; if (Number.isFinite(nx) && Number.isFinite(ny)) { maximumShift = Math.max(maximumShift, Math.hypot(nx - node.x, ny - node.y)); @@ -10379,7 +10399,9 @@ galactic_target_radius as a hard minimum floor. Without scaling the floor with the position, the next fixed slice immediately pulls the system back out and the user-visible contraction vanishes. */ - ['galactic_target_radius', 'galactic_radius', 'galactic_preferred_radius'] + ['galactic_target_radius', 'galactic_radius', 'galactic_preferred_radius', + '__galaxyCarrierLaneRadius', '__galaxyCarrierLaneBaseRadius', + '__galaxyCoreLaneRadius', '__galaxyCoreLaneBaseRadius'] .forEach(key => { const target = Number(node[key]); if (Number.isFinite(target) && target > 0) { @@ -10387,6 +10409,16 @@ } }); }); + if (item.carrier) { + ['__galaxyCarrierLaneRadius', '__galaxyCarrierLaneBaseRadius', + '__galaxyCoreLaneRadius', '__galaxyCoreLaneBaseRadius'] + .forEach(key => { + const target = Number(item.carrier[key]); + if (Number.isFinite(target) && target > 0) { + item.carrier[key] = target * ratio; + } + }); + } moved++; }); galaxyLastGravityResponse = { @@ -10405,6 +10437,41 @@ } } } + if (localGChanged && !state.settings.orbitPaused) { + /* Local solar gravity slider immediate feedback: rescale planetary satellites + relative to their host star carrier so tightening local gravity draws planets closer + and loosening local gravity expands them outward. */ + const graph = fg.graphData ? fg.graphData() : null; + const nodes = graph && graph.nodes ? graph.nodes : null; + if (nodes) { + const anchor = galaxyGlobalAnchor(nodes); + const localRatio = Math.pow(previousLocalG / nextLocalG, 0.35); + if (Number.isFinite(localRatio) && localRatio > 0 && Math.abs(localRatio - 1.0) > 1e-9) { + galaxyBlackHoleCarrierSystems(nodes, anchor).forEach(item => { + if (!item.carrier) return; + item.nodes.forEach(node => { + if (node === item.carrier || node === anchor || node.ghost) return; + const dx = node.x - item.carrier.x; + const dy = node.y - item.carrier.y; + if (Number.isFinite(dx) && Number.isFinite(dy)) { + node.x = item.carrier.x + dx * localRatio; + node.y = item.carrier.y + dy * localRatio; + } + ['orbit_radius', '__galaxyOrbitBaseRadius'].forEach(key => { + const val = Number(node[key]); + if (Number.isFinite(val) && val > 0) { + node[key] = val * localRatio; + } + }); + }); + }); + render(false, false); + if (previousMode === 'galaxy' && state.settings.mode === 'galaxy') { + preserveGalaxyPhaseOnResume = true; + } + } + } + } if (state.settings.mode === 'galaxy') { if (previousMode !== 'galaxy' && state.sizeBy !== 'mass') legacySizeBy = state.sizeBy; state.sizeBy = 'mass'; diff --git a/engraphis/dashboard_assets/index.html b/engraphis/dashboard_assets/index.html index 3cf991e6..bd494eca 100644 --- a/engraphis/dashboard_assets/index.html +++ b/engraphis/dashboard_assets/index.html @@ -362,7 +362,7 @@

Saved views

- + diff --git a/engraphis/dashboard_assets/ledger.js b/engraphis/dashboard_assets/ledger.js index 70cb0895..5d869562 100644 --- a/engraphis/dashboard_assets/ledger.js +++ b/engraphis/dashboard_assets/ledger.js @@ -141,7 +141,7 @@ const GRAPH_TUNING = [ { id: 'graph-repel', key: 'repel', fallback: 100 }, { id: 'graph-link', key: 'link', fallback: 8 }, - { id: 'graph-gravity', key: 'gravity', fallback: 96 }, + { id: 'graph-gravity', key: 'gravity', fallback: 120 }, { id: 'graph-node-size', key: 'size', fallback: 3 }, { id: 'graph-text-size', key: 'font', fallback: 12 }, { id: 'graph-line-width', key: 'linkw', fallback: 0.72, precision: 2 }, @@ -158,7 +158,7 @@ original: { repel: 120, link: 30, gravity: 14, font: 13, size: 3, linkw: 1, labelDensity: 40 }, compact: { repel: 42, link: 20, gravity: 26, font: 12, size: 3, linkw: 0.7, labelDensity: 30 }, communities: { repel: 48, link: 16, gravity: 48, font: 12, size: 3, linkw: 0.72, labelDensity: 24 }, - galaxy: { repel: 100, link: 8, gravity: 96, font: 12, size: 3, linkw: 0.72, labelDensity: 24 }, + galaxy: { repel: 100, link: 8, gravity: 120, font: 12, size: 3, linkw: 0.72, labelDensity: 24 }, radial: { repel: 68, link: 26, gravity: 12, font: 13, size: 3, linkw: 0.75, labelDensity: 55 }, constellation: { repel: 34, link: 16, gravity: 38, font: 12, size: 3, linkw: 0.65, labelDensity: 35 }, }; diff --git a/tests/e2e/graph-engine.spec.js b/tests/e2e/graph-engine.spec.js index 40983d09..b257f995 100644 --- a/tests/e2e/graph-engine.spec.js +++ b/tests/e2e/graph-engine.spec.js @@ -3457,14 +3457,14 @@ test('Galaxy sliders retain full ranges with orbital-speed and radius response', expect(naturalOrbits.before.diagnostics.orbitalSeparationStrength).toBe(1); expect(fastOrbits.before.diagnostics.orbitalSeparationSetting).toBe(400); expect(fastOrbits.before.diagnostics.orbitalSpeedMultiplier).toBeCloseTo(2.5, 12); - expect(fastOrbits.before.diagnostics.orbitalRadiusMultiplier).toBeCloseTo(1.24, 12); + expect(fastOrbits.before.diagnostics.orbitalRadiusMultiplier).toBeCloseTo(1.06, 12); expect(fastOrbits.before.diagnostics.orbitalSeparationPadding).toBe(15); expect(fastOrbits.before.diagnostics.orbitalSeparationStrength).toBe(1); expect(fastOrbits.before.diagnostics.crossSystemRepulsionStrength).toBe(0); expect(fastOrbits.maximumSeparations).toBeGreaterThan(0); expect(fastOrbits.starPlanetBefore).toBeGreaterThan(naturalOrbits.starPlanetBefore); expect(fastOrbits.starPlanetBefore).toBeCloseTo( - naturalOrbits.starPlanetBefore * 1.24, 6, + naturalOrbits.starPlanetBefore * 1.06, 6, ); // The local orbit is allowed to settle at the modest radius selected by Orbital speed; the // fixed contact cushion remains diagnostics/compatibility telemetry, not the target radius. diff --git a/tests/e2e/ledger.spec.js b/tests/e2e/ledger.spec.js index 691bf4bd..1a59e65b 100644 --- a/tests/e2e/ledger.spec.js +++ b/tests/e2e/ledger.spec.js @@ -856,7 +856,7 @@ test('Ledger narrowly migrates only the legacy Galaxy spacing default', async ({ await page.goto('/'); await expect(page.locator('#graph-repel')).toHaveValue('100'); await expect(page.locator('#graph-link')).toHaveValue('8'); - await expect(page.locator('#graph-gravity')).toHaveValue('96'); + await expect(page.locator('#graph-gravity')).toHaveValue('120'); // A first-time dashboard may use the new HTML default without manufacturing preferences. expect(await readPreferences()).toBeNull(); @@ -871,7 +871,7 @@ test('Ledger narrowly migrates only the legacy Galaxy spacing default', async ({ }); await expect(page.locator('#graph-repel')).toHaveValue('100'); await expect(page.locator('#graph-link')).toHaveValue('8'); - await expect(page.locator('#graph-gravity')).toHaveValue('96'); + await expect(page.locator('#graph-gravity')).toHaveValue('120'); await writePreferences({ preset: 'galaxy', style: 'solar', tuning: { repel: 48, link: 8, gravity: 0 }, @@ -1548,7 +1548,7 @@ test('Graph & Relationships uses the visual explorer controls and applies their await expect(page.locator('#graph-link-label')).toHaveText('Link distance · tight ↔ loose'); await expect(page.locator('#graph-link')).toHaveValue('8'); await expect(page.locator('#graph-gravity-label')).toHaveText('Galactic gravity · loose ↔ tight'); - await expect(page.locator('#graph-gravity')).toHaveValue('96'); + await expect(page.locator('#graph-gravity')).toHaveValue('120'); await expect(page.getByRole('button', { name: 'Schema drift' })).toHaveAttribute('aria-pressed', 'true'); await expect(page.getByRole('button', { name: 'Operations' })).toBeVisible(); await expect(page.getByRole('button', { name: 'People' })).toBeVisible(); diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py index 7b72c0fe..bdd094d9 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -1090,12 +1090,12 @@ def test_orbital_speed_increases_use_a_bounded_response_with_less_expansion() -> assert report["radii"][0] == pytest.approx(report["radii"][1]) assert report["radii"][1] < report["radii"][2] < report["radii"][3] assert report["radii"][1] == pytest.approx(30) - assert report["radii"][2] == pytest.approx(32.4) - assert report["radii"][3] == pytest.approx(37.2) + assert report["radii"][2] == pytest.approx(30.6) + assert report["radii"][3] == pytest.approx(31.8) assert report["multipliers"][2] - 1 == pytest.approx(0.5 * (2 - 1)) assert report["multipliers"][3] - 1 == pytest.approx(0.5 * (4 - 1)) assert report["radii"][3] - report["radii"][1] == pytest.approx( - 0.8 * (39 - 30) + 0.2 * (39 - 30) ) assert report["localSpeeds"] == sorted(report["localSpeeds"]) assert report["globalSpeeds"] == sorted(report["globalSpeeds"]) @@ -1448,7 +1448,7 @@ def test_orbital_speed_scales_live_carrier_and_kinematic_phase_rates() -> None: assert report["naturalKinematic"]["systemTravel"] > 0 assert report["naturalKinematic"]["localTravel"] > 0 assert report["kinematicSystemRatio"] > 1.8 - assert report["kinematicLocalRatio"] > 2.5 + assert report["kinematicLocalRatio"] > 1.25 assert report["naturalCarrier"] > 0 assert report["carrierRatio"] == pytest.approx(2.5, rel=0.02) @@ -1568,7 +1568,7 @@ def test_four_hundred_percent_clock_keeps_release_sized_solar_systems_inside_res assert report["memberCount"] == 480 assert report["finite"] is True assert report["multiplier"] == pytest.approx(2.5) - assert report["radiusMultiplier"] == pytest.approx(1.24) + assert report["radiusMultiplier"] == pytest.approx(1.06) assert report["maximumBoundaryRatio"] <= 1 + 1e-9 assert report["minimumSystemClearance"] >= -1e-8 assert report["minimumCarrierTravel"] > 0.1 @@ -8482,7 +8482,7 @@ def test_galaxy_is_default_and_consumes_the_complete_scene_contract() -> None: """ ) assert report["mode"] == "galaxy" - assert report["settings"] == {"repel": 100, "link": 8, "gravity": 96} + assert report["settings"] == {"repel": 100, "link": 8, "gravity": 120} assert report["sizeBy"] == "mass" assert report["forces"] == { "charge": True, @@ -8503,8 +8503,8 @@ def radius(mass: float) -> float: assert report["d3Budget"] == [0, 0, 0] assert report["diagnostics"]["timestep"] == pytest.approx(0.032) assert report["diagnostics"]["velocityDecay"] == pytest.approx(0.00005) - assert report["diagnostics"]["gravitySetting"] == 96 - assert report["diagnostics"]["blackHoleGravity"] == pytest.approx(1615.3424319876754) + assert report["diagnostics"]["gravitySetting"] == 120 + assert report["diagnostics"]["blackHoleGravity"] == pytest.approx(2317.2923076923075) assert report["diagnostics"]["localGravity"] == pytest.approx(240) assert report["diagnostics"]["linkSetting"] == 8 assert report["diagnostics"]["relationOrbitScale"] == pytest.approx(0.25) @@ -10379,10 +10379,10 @@ def test_primary_graph_dependencies_are_lazy_retryable_and_csp_clean() -> None: assert asset not in markup assert 'id="graph-repel" type="range" min="0" max="400" value="100"' in markup assert 'id="graph-link" type="range" min="4" max="80" value="8"' in markup - assert 'id="graph-gravity" type="range" min="0" max="400" value="96"' in markup + assert 'id="graph-gravity" type="range" min="0" max="400" value="120"' in markup assert "{ id: 'graph-repel', key: 'repel', fallback: 100 }" in source assert "{ id: 'graph-link', key: 'link', fallback: 8 }" in source - assert "{ id: 'graph-gravity', key: 'gravity', fallback: 96 }" in source + assert "{ id: 'graph-gravity', key: 'gravity', fallback: 120 }" in source loader_start = source.index("function ensureGraphAssets") loader = source[ diff --git a/tests/test_ledger_sliders_and_physics.py b/tests/test_ledger_sliders_and_physics.py index c5f10e62..bc9ac59c 100644 --- a/tests/test_ledger_sliders_and_physics.py +++ b/tests/test_ledger_sliders_and_physics.py @@ -97,7 +97,7 @@ {"id": "graph-flow-speed", "min": 0, "max": 100, "fallback": 45, "has_output": True}, {"id": "graph-repel", "min": 0, "max": 400, "fallback": 100, "has_output": True}, {"id": "graph-link", "min": 4, "max": 80, "fallback": 8, "has_output": True}, - {"id": "graph-gravity", "min": 0, "max": 400, "fallback": 96, "has_output": True}, + {"id": "graph-gravity", "min": 0, "max": 400, "fallback": 120, "has_output": True}, {"id": "graph-node-size", "min": 1, "max": 12, "fallback": 3, "has_output": True}, {"id": "graph-text-size", "min": 6, "max": 24, "fallback": 12, "has_output": True}, {"id": "graph-line-width", "min": 0.1, "max": 2.0, "fallback": 0.72, "has_output": True}, @@ -501,9 +501,9 @@ def test_interactive_buttons_and_tuning_reset() -> None: link: api.state().settings.link, }; - // Shipped Galaxy defaults: repel=100, link=8, gravity=96 + // Shipped Galaxy defaults: repel=100, link=8, gravity=120 api.setPreset('galaxy'); - api.setSettings({ repel: 100, link: 8, gravity: 96 }); + api.setSettings({ repel: 100, link: 8, gravity: 120 }); const afterReset = { gravity: api.state().settings.gravity, repel: api.state().settings.repel, @@ -517,4 +517,4 @@ def test_interactive_buttons_and_tuning_reset() -> None: assert all(report["paletteOk"].values()), f"Palette failed: {report['paletteOk']}" assert all(report["colorOk"].values()), f"ColorBy failed: {report['colorOk']}" assert report["beforeReset"] == {"gravity": 400, "repel": 350, "link": 50} - assert report["afterReset"] == {"gravity": 96, "repel": 100, "link": 8} + assert report["afterReset"] == {"gravity": 120, "repel": 100, "link": 8} From 6c231747ea31efba2c7a7a4b911b51a190156bfd Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Sun, 6 Sep 2026 04:25:21 -0400 Subject: [PATCH 2/5] fix(dashboard): address Galaxy review findings --- engraphis/core/graph_scene.py | 4 +- engraphis/dashboard_assets/engraphis-graph.js | 60 ++++++++++++------- engraphis/dashboard_assets/ledger.js | 7 ++- tests/e2e/graph-engine.spec.js | 11 ++-- tests/e2e/ledger.spec.js | 15 ++++- tests/e2e/ledger_sliders_themes.spec.js | 2 +- tests/test_graph_engine_asset.py | 22 +++++-- 7 files changed, 85 insertions(+), 36 deletions(-) diff --git a/engraphis/core/graph_scene.py b/engraphis/core/graph_scene.py index cee320b8..674a6daf 100644 --- a/engraphis/core/graph_scene.py +++ b/engraphis/core/graph_scene.py @@ -776,7 +776,7 @@ def _community_positions( tier_step = max(spacing * 0.65, 2.0 * avg_sys_radius + GALAXY_SYSTEM_MIN_GAP * 0.35) tiers: list[dict[str, float | int]] = [] - curr_radius = core_clearance_radius + avg_sys_radius * 0.25 + curr_radius = core_clearance_radius + avg_sys_radius remaining = non_global_count while remaining > 0: circ = 2.0 * math.pi * curr_radius @@ -877,7 +877,7 @@ def collides(x: float, y: float, system_radius: float) -> bool: # The radius_scale compactness pass may shrink preferred targets inside # the core; clamp the walk's starting radius to the clearance floor so # the collision search never considers orbits inside the black hole. - minimum_orbital_radius = core_outer_extent + GALAXY_SYSTEM_MIN_GAP + minimum_orbital_radius = core_outer_extent + system_radius + GALAXY_SYSTEM_MIN_GAP axis_radius = max(axis_radius, minimum_orbital_radius) # Radial-only walk preserves the even angular distribution. Moving only # the system centre outward (not angularly) keeps every local star/planet diff --git a/engraphis/dashboard_assets/engraphis-graph.js b/engraphis/dashboard_assets/engraphis-graph.js index 642e3dc3..89571491 100644 --- a/engraphis/dashboard_assets/engraphis-graph.js +++ b/engraphis/dashboard_assets/engraphis-graph.js @@ -4550,7 +4550,8 @@ const bodyRadius = node => finitePositive( node.radius, evidenceNodeRadius(node, 3), 160 ); - const anchorRadius = bodyRadius(anchor); + const anchorRadius = bodyRadius(anchor) + * (anchor.anchor_role === 'global' ? GALAXY_BLACK_HOLE_PAINT_SCALE : 1); const anchorX = anchor.x, anchorY = anchor.y; const anchorVx = Number.isFinite(anchor.vx) ? anchor.vx : 0; const anchorVy = Number.isFinite(anchor.vy) ? anchor.vy : 0; @@ -4661,7 +4662,8 @@ }); bodies.forEach(node => { - if (node === anchor) return; + if (node === anchor || node.ghost) return; + projectIndividualNode(node); const clearance = Math.hypot(node.x - anchorX, node.y - anchorY) - anchorRadius - bodyRadius(node) - padding; stats.minimumClearance = stats.minimumClearance === null @@ -5995,8 +5997,19 @@ phase.angle += phase.direction * angularSpeed * timestep; const unitX = Math.cos(phase.angle), unitY = Math.sin(phase.angle); const tangentX = -unitY * phase.direction, tangentY = unitX * phase.direction; - const targetX = parent.x + unitX * targetRadius; - const targetY = parent.y + unitY * targetRadius; + let targetX = parent.x + unitX * targetRadius; + let targetY = parent.y + unitY * targetRadius; + if (globalAnchor && parent !== globalAnchor) { + const minBhDist = (finitePositive(globalAnchor.radius, evidenceNodeRadius(globalAnchor, 3), 160) * GALAXY_BLACK_HOLE_PAINT_SCALE) + + nodeRadius + GALAXY_BLACK_HOLE_EXCLUSION_PADDING; + const bhDx = targetX - globalAnchor.x; + const bhDy = targetY - globalAnchor.y; + const bhDist = Math.hypot(bhDx, bhDy); + if (bhDist < minBhDist && bhDist > 1e-9) { + targetX = globalAnchor.x + (bhDx / bhDist) * minBhDist; + targetY = globalAnchor.y + (bhDy / bhDist) * minBhDist; + } + } const targetVx = (Number.isFinite(parent.vx) ? parent.vx : 0) + tangentX * phaseSpeed; const targetVy = (Number.isFinite(parent.vy) ? parent.vy : 0) @@ -6255,7 +6268,7 @@ padding: opts.systemAnchorExclusionPadding, }); let boundaryIterations = 0; - for (let iteration = 0; iteration < 24; iteration++) { + for (let iteration = 0; iteration < 6; iteration++) { stellarPasses.push(applyGalaxySystemAnchorExclusion(bodies, { padding: opts.systemAnchorExclusionPadding, fixedNodeId: opts.fixedNodeId, @@ -6314,7 +6327,7 @@ no kinetic energy; pointer-owned systems remain fixed and any genuinely infeasible fixed/boundary conflict is reported rather than moved. */ const packingClosureLimit = Math.max(1, - Math.min(256, galaxySystemEnvelopes(bodies, opts).length + 1)); + Math.min(4, galaxySystemEnvelopes(bodies, opts).length + 1)); for (let passIndex = 0; passIndex < packingClosureLimit; passIndex++) { const packingPass = applyGalaxySystemPacking(bodies, Object.assign({}, opts, { gap: opts.systemPackingGap, @@ -9106,11 +9119,11 @@ )); galaxyLastFrameTime = now; galaxyAccumulator = Math.min( - GALAXY_FRAME_INTERVAL_MS * GALAXY_MAX_SUBSTEPS, + GALAXY_FRAME_INTERVAL_MS * 1.5, galaxyAccumulator + elapsed ); } - const ordinarySubsteps = Math.min(GALAXY_MAX_SUBSTEPS, + const ordinarySubsteps = Math.min(1, Math.floor((galaxyAccumulator + 1e-9) / GALAXY_FRAME_INTERVAL_MS)); /* Galaxy is already live. Reheat must never add fixed slices or fast-forward time, even if a future caller accidentally leaves a stale non-zero budget in the telemetry slot. */ @@ -9132,6 +9145,8 @@ if (!kinematicFallback) { report.orbitalSpeed = applyGalaxyOrbitalSpeedControl( data.nodes || [], galaxyIntegratorOptions()); + applyGalaxyBlackHoleExclusion( + data.nodes || [], galaxyIntegratorOptions()); } galaxySteps++; if (kinematicFallback) { @@ -10409,16 +10424,6 @@ } }); }); - if (item.carrier) { - ['__galaxyCarrierLaneRadius', '__galaxyCarrierLaneBaseRadius', - '__galaxyCoreLaneRadius', '__galaxyCoreLaneBaseRadius'] - .forEach(key => { - const target = Number(item.carrier[key]); - if (Number.isFinite(target) && target > 0) { - item.carrier[key] = target * ratio; - } - }); - } moved++; }); galaxyLastGravityResponse = { @@ -10648,10 +10653,23 @@ const point = fg.graph2ScreenCoords(Number(x) || 0, Number(y) || 0); return { x: point.x, y: point.y }; }; + let cachedPhysicsSnapshot = null; + let cachedPhysicsSnapshotStep = -1; api.getPhysicsSnapshot = () => { const data = fg.graphData() || {}; const nodes = Array.isArray(data.nodes) ? data.nodes : []; const center = galaxyGlobalAnchor(nodes); + const isPaused = state.settings.orbitPaused === true || state.settings.frozen === true + || !running || pageHidden(); + if (cachedPhysicsSnapshot && cachedPhysicsSnapshotStep === galaxySteps && cachedPhysicsSnapshotStep >= 0) { + cachedPhysicsSnapshot.paused = isPaused; + if (center && cachedPhysicsSnapshot.center) { + const centerPoint = api.graphToScreen(center.x, center.y); + cachedPhysicsSnapshot.center.screenX = centerPoint.x; + cachedPhysicsSnapshot.center.screenY = centerPoint.y; + } + return cachedPhysicsSnapshot; + } const centerPoint = center ? api.graphToScreen(center.x, center.y) : null; const systemAnchors = []; communityCenters(nodes).forEach(system => { @@ -10696,11 +10714,13 @@ warp: Number(node.__galaxySpacetimeWarp) || 0, })), systemAnchors, - paused: state.settings.orbitPaused === true || state.settings.frozen === true - || !running || pageHidden(), + paused: isPaused, diagnostics: physicsDiagnostics(), slingshot: lastSlingshotRelease ? { ...lastSlingshotRelease } : null, }; + cachedPhysicsSnapshot = snapshot; + cachedPhysicsSnapshotStep = galaxySteps; + return snapshot; }; api.reheat = () => { if (destroyed || state.settings.frozen diff --git a/engraphis/dashboard_assets/ledger.js b/engraphis/dashboard_assets/ledger.js index 5d869562..1fc30941 100644 --- a/engraphis/dashboard_assets/ledger.js +++ b/engraphis/dashboard_assets/ledger.js @@ -134,7 +134,7 @@ const GRAPH_FULL_LOAD_TIMEOUT_MS = 30_000; const GRAPH_CONNECTION_MEMORIES_TIMEOUT_MS = 8_000; const GRAPH_PREFERENCES_KEY = 'engraphis-ledger-graph-preferences-v1'; - const GRAPH_PHYSICS_VERSION = 4; + const GRAPH_PHYSICS_VERSION = 5; const GRAPH_CUSTOM_VIEW_KEY = 'engraphis-ledger-graph-custom-view-v1'; const GRAPH_LAYERS = ['temporal', 'entity', 'causal', 'semantic', 'code']; const GRAPH_DEFAULT_LAYERS = { temporal: true, entity: true, causal: true, semantic: true, code: false }; @@ -2950,6 +2950,11 @@ && [48, 60].includes(Number(effectiveTuning.repel))) { effectiveTuning.repel = 100; } + /* Physics v5 makes 120 the Galaxy gravity default. Migrate only the exact retired default; + a saved 96 in an already-versioned v5 snapshot remains an intentional user choice. */ + if (legacyPhysics && preset === 'galaxy' && Number(effectiveTuning.gravity) === 96) { + effectiveTuning.gravity = 120; + } syncGraphTuning({ ...graphPresetTuning(preset), ...effectiveTuning, diff --git a/tests/e2e/graph-engine.spec.js b/tests/e2e/graph-engine.spec.js index b257f995..66f865f7 100644 --- a/tests/e2e/graph-engine.spec.js +++ b/tests/e2e/graph-engine.spec.js @@ -1852,7 +1852,7 @@ for (const reducedMotion of [false, true]) { expect(diagnostics.renderedNodes).toBe(542); expect(before.collapsed).toBe(false); expect(before.settings).toMatchObject({ - mode: 'galaxy', frozen: false, gravity: 96, repel: 100, link: 8, + mode: 'galaxy', frozen: false, gravity: 120, repel: 100, link: 8, }); expect(diagnostics.orbitalSeparationSetting).toBe(100); expect(diagnostics.orbitalSeparationPadding).toBe(15); @@ -1860,8 +1860,8 @@ for (const reducedMotion of [false, true]) { expect(diagnostics.crossSystemRepulsionStrength).toBe(0); expect(diagnostics.linkSetting).toBe(8); expect(diagnostics.relationOrbitScale).toBeCloseTo(0.25, 12); - expect(diagnostics.gravitySetting).toBe(96); - expect(diagnostics.blackHoleGravity).toBeCloseTo(3230.6848639753507, 12); + expect(diagnostics.gravitySetting).toBe(120); + expect(diagnostics.blackHoleGravity).toBeCloseTo(4624.615384615385, 12); expect(diagnostics.localGravity).toBeCloseTo(240, 12); expect(diagnostics.systemOrbitSeedSpeedLimit).toBeCloseTo(23.4, 12); @@ -3493,8 +3493,9 @@ test('Galaxy sliders retain full ranges with orbital-speed and radius response', expect(immediate.after.radii[id] / radius, id) .toBeCloseTo(immediateResponse.ratio, 2); } - expect(immediate.after.diameter / immediate.before.diameter) - .toBeCloseTo(immediateResponse.ratio, 2); + // The central response translates each solar system as a rigid carrier; its local orbit + // geometry remains unchanged while the system moves radially around the black hole. + expect(immediate.after.diameter / immediate.before.diameter).toBeCloseTo(1, 12); for (const [index, [id, vx, vy]] of immediate.before.velocities.entries()) { const [afterId, afterVx, afterVy] = immediate.after.velocities[index]; expect(afterId).toBe(id); diff --git a/tests/e2e/ledger.spec.js b/tests/e2e/ledger.spec.js index 1a59e65b..135b5840 100644 --- a/tests/e2e/ledger.spec.js +++ b/tests/e2e/ledger.spec.js @@ -881,7 +881,7 @@ test('Ledger narrowly migrates only the legacy Galaxy spacing default', async ({ await expect(page.locator('#graph-repel')).toHaveValue('100'); await expect(page.locator('#graph-gravity')).toHaveValue('0'); const migrated = await readPreferences(); - expect(migrated.physicsVersion).toBe(4); + expect(migrated.physicsVersion).toBe(5); expect(migrated.preset).toBe('galaxy'); expect(migrated.style).toBe('solar'); expect(migrated.tuning.repel).toBe(100); @@ -891,6 +891,15 @@ test('Ledger narrowly migrates only the legacy Galaxy spacing default', async ({ temporal: false, entity: true, causal: false, semantic: true, code: false, }); + await writePreferences({ + preset: 'galaxy', style: 'solar', tuning: { repel: 100, link: 8, gravity: 96 }, + }); + await page.reload(); + await expect(page.locator('#graph-gravity')).toHaveValue('120'); + const migratedGravity = await readPreferences(); + expect(migratedGravity.physicsVersion).toBe(5); + expect(migratedGravity.tuning.gravity).toBe(120); + await writePreferences({ preset: 'galaxy', style: 'galaxy', tuning: { repel: 73, link: 21, gravity: 0 }, }); @@ -899,14 +908,14 @@ test('Ledger narrowly migrates only the legacy Galaxy spacing default', async ({ await expect(page.locator('#graph-link')).toHaveValue('21'); await expect(page.locator('#graph-gravity')).toHaveValue('0'); const custom = await readPreferences(); - expect(custom.physicsVersion).toBe(4); + expect(custom.physicsVersion).toBe(5); expect(custom.tuning.repel).toBe(73); expect(custom.tuning.link).toBe(21); expect(custom.tuning.gravity).toBe(0); // Once versioned, 48 is a deliberate user selection rather than the retired default. await writePreferences({ - physicsVersion: 4, preset: 'galaxy', tuning: { repel: 48, gravity: 0 }, + physicsVersion: 5, preset: 'galaxy', tuning: { repel: 48, gravity: 0 }, }); await page.reload(); await expect(page.locator('#graph-repel')).toHaveValue('48'); diff --git a/tests/e2e/ledger_sliders_themes.spec.js b/tests/e2e/ledger_sliders_themes.spec.js index 32296425..1004f2dd 100644 --- a/tests/e2e/ledger_sliders_themes.spec.js +++ b/tests/e2e/ledger_sliders_themes.spec.js @@ -346,7 +346,7 @@ test.describe('Ledger Dashboard Sliders, Gravity Physics, Themes, and Options', })); expect(defaults.repel).toBe(100); expect(defaults.link).toBe(8); - expect(defaults.gravity).toBe(96); + expect(defaults.gravity).toBe(120); // 9. Test Memory Importance Slider in Library View await page.locator('.nav-item[data-view="library"]').click(); diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py index bdd094d9..a956f992 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -1961,6 +1961,17 @@ def test_live_carrier_support_rotates_without_a_preseeded_lane_cache() -> None: assert report["second"]["localDistance"] == pytest.approx(report["first"]["localDistance"], abs=1e-9) +@requires_node +def test_central_slider_scales_each_carrier_lane_cache_once() -> None: + """Central-field feedback must not apply a carrier lane-cache ratio twice.""" + source = ASSET.read_text(encoding="utf-8") + start = source.index("const targetCarrierX") + end = source.index("moved++;", start) + response = source[start:end] + assert "item.carrier[key]" not in response + assert response.count("__galaxyCarrierLaneRadius") == 1 + + @requires_node def test_system_velocity_guard_preserves_black_hole_carrier_before_local_motion() -> None: report = _run_node( @@ -5582,9 +5593,10 @@ def test_dragging_connected_core_node_over_black_hole_keeps_the_annulus_stable( dragPull = Math.max(dragPull, tick.dragGravity.maximumPull); fixedSystemNodes += tick.blackHoleExclusion.fixedSystemNodes; skippedFixedEndpoint += tick.relationConstraint.skippedFixedEndpoint; + const anchorRadius = nodes[0].radius * (nodes[0].anchor_role === 'global' ? 2 : 1); nodes.slice(1).forEach(node => { minimumClearance = Math.min(minimumClearance, - Math.hypot(node.x, node.y) - nodes[0].radius - node.radius + Math.hypot(node.x, node.y) - anchorRadius - node.radius - options.blackHoleExclusionPadding); }); nodes.slice(2, 4).forEach((node, index) => { @@ -5611,7 +5623,8 @@ def test_dragging_connected_core_node_over_black_hole_keeps_the_annulus_stable( drag. This is the former 400-slice runaway: a skipped fixed system let followers drift hundreds of units out, then snap back only after release. */ if (externalSystem) { - const startRadius = nodes[0].radius + dragged.radius + options.blackHoleExclusionPadding; + const anchorRadius = nodes[0].radius * (nodes[0].anchor_role === 'global' ? 2 : 1); + const startRadius = anchorRadius + dragged.radius + options.blackHoleExclusionPadding; const endRadius = envelope + 320; for (let step = 0; step < 400; step++) { const before = nodes.slice(2, 4).map(node => [node.x, node.y]); @@ -5629,7 +5642,7 @@ def test_dragging_connected_core_node_over_black_hole_keeps_the_annulus_stable( skippedFixedEndpoint += tick.relationConstraint.skippedFixedEndpoint; nodes.slice(1).forEach(node => { minimumClearance = Math.min(minimumClearance, - Math.hypot(node.x, node.y) - nodes[0].radius - node.radius + Math.hypot(node.x, node.y) - anchorRadius - node.radius - options.blackHoleExclusionPadding); }); nodes.slice(2, 4).forEach((node, index) => { @@ -5666,7 +5679,8 @@ def test_dragging_connected_core_node_over_black_hole_keeps_the_annulus_stable( centreHeld, held, released: [dragged.x, dragged.y], anchor: [nodes[0].x, nodes[0].y, nodes[0].vx, nodes[0].vy], draggedRadius: Math.hypot(centreHeld[0], centreHeld[1]), - paintedHorizon: nodes[0].radius + dragged.radius + options.blackHoleExclusionPadding, + paintedHorizon: (nodes[0].radius * (nodes[0].anchor_role === 'global' ? 2 : 1)) + + dragged.radius + options.blackHoleExclusionPadding, }); """ ) From 3cfeda4792cdbcb608e3452b1751a8e124a2dbe7 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Sun, 6 Sep 2026 04:52:20 -0400 Subject: [PATCH 3/5] fix(dashboard): address follow-up review findings --- engraphis/dashboard_assets/engraphis-graph.js | 28 +++++++++-- tests/test_graph_engine_asset.py | 46 +++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/engraphis/dashboard_assets/engraphis-graph.js b/engraphis/dashboard_assets/engraphis-graph.js index 89571491..94b46ec7 100644 --- a/engraphis/dashboard_assets/engraphis-graph.js +++ b/engraphis/dashboard_assets/engraphis-graph.js @@ -560,6 +560,14 @@ const rMod = Math.pow(fCentral, -0.65); return baseScale * rMod; } + /* Local stellar gravity follows the same inverse-radius law as the live solver. Keep its + zero endpoint finite so a 0 -> positive sweep remains reversible and path-independent. */ + const GALAXY_LOCAL_GRAVITY_RADIUS_ENDPOINT = 0.25; + function galaxyImmediateLocalGravityRadiusScale(setting) { + const raw = Number(setting); + const value = Number.isFinite(raw) ? Math.max(0, Math.min(8, raw)) : 1; + return Math.pow(Math.max(GALAXY_LOCAL_GRAVITY_RADIUS_ENDPOINT, value), -0.35); + } /* The oversized-scene fallback has no live integrator, so its grid must map the complete slider range directly. Keeping the old `setting / 100` scale made compactness hit its minimum near 112 and left every higher gravity value visually identical. */ @@ -9377,6 +9385,8 @@ function render(fit, reheat, dragging = false) { if (destroyed) return; + cachedPhysicsSnapshot = null; + cachedPhysicsSnapshotStep = -1; if (suspended) { pendingRender = pendingRender ? [pendingRender[0] || fit, pendingRender[1] || reheat, pendingRender[2] || dragging] @@ -10338,7 +10348,7 @@ : (state.settings.G_star !== undefined ? state.settings.G_star : 100)); const localGChanged = (next.localGravitationalConstant !== undefined || next.G_star !== undefined) && Number.isFinite(previousLocalG) && Number.isFinite(nextLocalG) - && previousLocalG > 0 && nextLocalG > 0 + && previousLocalG >= 0 && nextLocalG >= 0 && Math.abs(nextLocalG - previousLocalG) > 1e-12 && previousMode === 'galaxy' && state.settings.mode === 'galaxy'; /* A galaxy slider burst (gravity / black-hole mass / damping / etc.) is a setting change, @@ -10450,7 +10460,9 @@ const nodes = graph && graph.nodes ? graph.nodes : null; if (nodes) { const anchor = galaxyGlobalAnchor(nodes); - const localRatio = Math.pow(previousLocalG / nextLocalG, 0.35); + const previousLocalScale = galaxyImmediateLocalGravityRadiusScale(previousLocalG); + const nextLocalScale = galaxyImmediateLocalGravityRadiusScale(nextLocalG); + const localRatio = nextLocalScale / previousLocalScale; if (Number.isFinite(localRatio) && localRatio > 0 && Math.abs(localRatio - 1.0) > 1e-9) { galaxyBlackHoleCarrierSystems(nodes, anchor).forEach(item => { if (!item.carrier) return; @@ -10468,6 +10480,15 @@ node[key] = val * localRatio; } }); + ['__galaxyKinematicLocalOrbit', '__galaxyKinematicCoreLocalOrbit'] + .forEach(cacheKey => { + const orbit = node[cacheKey]; + if (!orbit || typeof orbit !== 'object') return; + ['baseRadius', 'radius'].forEach(key => { + const val = Number(orbit[key]); + if (Number.isFinite(val) && val > 0) orbit[key] = val * localRatio; + }); + }); }); }); render(false, false); @@ -10688,7 +10709,7 @@ }); }); const systemAnchorIds = new Set(systemAnchors.map(star => String(star.id))); - return { + const snapshot = { center: center ? { id: center.id, x: center.x, y: center.y, label: nodeName(center), @@ -11043,6 +11064,7 @@ applyGalaxyInwardConvergence, enforceGalaxyOrbitalFloor, enforceGalaxyLocalOrbitBoundaries, supportGalaxyCarrierOrbits, galaxyImmediateGravityRadiusScale, + galaxyImmediateLocalGravityRadiusScale, galaxyLayoutCompactness, applyGalaxyGravitySettingResponse, galaxySpringStrength, galaxySpringDistance, galaxySafeSpringDistance, diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py index a956f992..641f2694 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -1972,6 +1972,52 @@ def test_central_slider_scales_each_carrier_lane_cache_once() -> None: assert response.count("__galaxyCarrierLaneRadius") == 1 +@requires_node +def test_local_gravity_zero_endpoint_is_finite_and_scales_kinematic_cache() -> None: + """Local gravity's zero endpoint must remain reversible in both live and fallback paths.""" + report = _run_node( + """ + const scale = I.galaxyImmediateLocalGravityRadiusScale; + emit({ zero: scale(0), quarter: scale(.25), one: scale(1), two: scale(2), + zeroToOne: scale(1) / scale(0), oneToZero: scale(0) / scale(1) }); + """ + ) + assert all(math.isfinite(report[key]) for key in ("zero", "quarter", "one", "two")) + assert report["zero"] == pytest.approx(report["quarter"]) + assert report["zero"] > report["one"] > report["two"] + assert report["zeroToOne"] * report["oneToZero"] == pytest.approx(1) + + source = ASSET.read_text(encoding="utf-8") + start = source.index("if (localGChanged") + end = source.index("if (state.settings.mode === 'galaxy')", start) + response = source[start:end] + assert "__galaxyKinematicLocalOrbit" in response + assert "__galaxyKinematicCoreLocalOrbit" in response + assert "baseRadius" in response and "radius" in response + + +@requires_node +def test_physics_snapshot_is_cached_after_build() -> None: + """The first built physics snapshot must populate the same-step cache.""" + report = _run_engine( + """ + const api = G.create(el, {}); + api.setPreset('galaxy'); + api.setData({ nodes: [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', gravity_mass: 8, radius: 8, x: 0, y: 0 }, + { id: 'star', anchor_role: 'community', community_id: 'solar', + system_anchor_id: 'star', gravity_mass: 4, radius: 5, x: 120, y: 0 }, + ], edges: [] }); + const first = api.getPhysicsSnapshot(); + const second = api.getPhysicsSnapshot(); + emit({ same: first === second, center: second.center && second.center.id, + nodes: second.nodes.length }); + """ + ) + assert report == {"same": True, "center": "black-hole", "nodes": 2} + + @requires_node def test_system_velocity_guard_preserves_black_hole_carrier_before_local_motion() -> None: report = _run_node( From dfef126caf500727448bbac0b0a62f91c70d3d1a Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 7 Sep 2026 01:09:52 -0400 Subject: [PATCH 4/5] fix(dashboard): stabilize authored Galaxy orbit lanes --- engraphis/dashboard_assets/engraphis-graph.js | 172 +++++++++++++++++- 1 file changed, 168 insertions(+), 4 deletions(-) diff --git a/engraphis/dashboard_assets/engraphis-graph.js b/engraphis/dashboard_assets/engraphis-graph.js index 94b46ec7..8d13b002 100644 --- a/engraphis/dashboard_assets/engraphis-graph.js +++ b/engraphis/dashboard_assets/engraphis-graph.js @@ -688,6 +688,11 @@ remain meaningful at every camera zoom. */ const MIN_NODE_SPEED = 8; const MAX_NODE_SPEED = 48; + /* A capped vector is still projected by the machine-epsilon margin below, but a few ulps + above the limit are ordinary floating-point closure noise rather than a user-visible + speed-cap event. Keep that noise out of the health diagnostic so stable authored orbits + do not report one activation on every frame. */ + const SPEED_LIMIT_DIAGNOSTIC_EPSILON = 1e-6; function galaxyRelativeSpeedBudget(parent, absoluteLimit, requested, directionX, directionY) { const limit = Math.max(0.01, Number(absoluteLimit) || MAX_NODE_SPEED); const requestedSpeed = Math.max(0, Number(requested) || 0); @@ -2055,6 +2060,139 @@ return stats; } + /* Cheap post-clock repair for explicit stellar parents. The full closure below also resolves + pathological contacts, but the browser orbit clock runs after that closure and only needs + this linear final guard to keep a nested moon on its authored lane and outside its + immediate parent. Direct black-hole children remain under the horizon projection. */ + function enforceGalaxySystemAnchorMinimums(nodes, options) { + const opts = options || {}; + const bodies = (nodes || []).filter(node => node && !node.ghost + && Number.isFinite(node.x) && Number.isFinite(node.y)); + const byId = new Map(bodies.map(node => [String(node.id), node])); + const childrenByAnchor = new Map(); + bodies.forEach(node => { + const parentId = node.system_anchor_id === undefined + || node.system_anchor_id === null ? '' : String(node.system_anchor_id); + if (!parentId || parentId === String(node.id)) return; + if (!childrenByAnchor.has(parentId)) childrenByAnchor.set(parentId, []); + childrenByAnchor.get(parentId).push(node); + }); + const padding = Math.max(0, Number.isFinite(Number(opts.padding)) + ? Number(opts.padding) : GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING); + const radiusMultiplier = galaxyOrbitalRadiusMultiplier(opts.orbitalSpeed); + const bodyRadius = node => finitePositive( + node && node.radius, finitePositive(node && node.visual_radius, + radiusFromGravityMass(node && node.gravity_mass), 80), 160 + ); + const ordered = bodies.filter(node => { + const parentId = node.system_anchor_id === undefined + || node.system_anchor_id === null ? '' : String(node.system_anchor_id); + const parent = parentId ? byId.get(parentId) : null; + return parent && parent !== node && parent.anchor_role !== 'global'; + }).sort((left, right) => Number(left.orbit_tier || 0) - Number(right.orbit_tier || 0) + || String(left.id).localeCompare(String(right.id))); + const fixedNodeId = opts.fixedNodeId === undefined || opts.fixedNodeId === null + ? null : String(opts.fixedNodeId); + let correctedNodes = 0, correctedDescendants = 0, maximumShift = 0; + ordered.forEach(node => { + if (fixedNodeId !== null && String(node.id) === fixedNodeId) return; + const parentId = String(node.system_anchor_id); + const parent = byId.get(parentId); + if (!parent || (fixedNodeId !== null && String(parent.id) === fixedNodeId)) return; + const dx = node.x - parent.x, dy = node.y - parent.y; + const distance = Math.hypot(dx, dy); + const minimumDistance = bodyRadius(parent) + bodyRadius(node) + padding; + const authoredRadius = Number(node.orbit_radius); + const cachedRadius = Number(node.__galaxyOrbitBaseRadius); + const baseRadius = Number.isFinite(authoredRadius) && authoredRadius > 0 + ? authoredRadius : cachedRadius; + /* Top-level planets already leave the full integrator with their authored radius. Only + nested descendants need an exact lane restore here; checking every ordinary planet + against its target on every frame adds needless work to the 542-body path. */ + const nested = parent.system_anchor_id !== undefined && parent.system_anchor_id !== null + && String(parent.system_anchor_id) !== String(parent.id); + const phase = node.__galaxySpeedControlPhase; + const phaseAngle = nested && phase && phase.anchorId === parentId + && Number.isFinite(Number(phase.angle)) ? Number(phase.angle) : NaN; + const currentAngle = Math.atan2(dy, dx); + const phaseAligned = !Number.isFinite(phaseAngle) + || Math.abs(Math.atan2(Math.sin(currentAngle - phaseAngle), + Math.cos(currentAngle - phaseAngle))) <= 1e-9; + const targetRadius = nested && Number.isFinite(baseRadius) && baseRadius > 0 + ? Math.max(minimumDistance, baseRadius * radiusMultiplier) : minimumDistance; + if (!Number.isFinite(targetRadius) + || (nested ? Math.abs(distance - targetRadius) <= 1e-9 && phaseAligned + : distance >= targetRadius - 1e-9)) return; + const unitX = Number.isFinite(phaseAngle) ? Math.cos(phaseAngle) + : distance > 1e-9 ? dx / distance + : Math.cos(seededHash(0, String(parent.id) + '|' + String(node.id)) / 0x100000000 * Math.PI * 2); + const unitY = Number.isFinite(phaseAngle) ? Math.sin(phaseAngle) + : distance > 1e-9 ? dy / distance + : Math.sin(seededHash(0, String(parent.id) + '|' + String(node.id)) / 0x100000000 * Math.PI * 2); + const targetX = parent.x + unitX * targetRadius; + const targetY = parent.y + unitY * targetRadius; + const shiftX = targetX - node.x; + const shiftY = targetY - node.y; + const subtree = [], seen = new Set(), pending = [node]; + while (pending.length) { + const member = pending.pop(); + if (!member || seen.has(member)) continue; + seen.add(member); + subtree.push(member); + (childrenByAnchor.get(String(member.id)) || []).forEach(child => pending.push(child)); + } + subtree.forEach((member, index) => { + member.x += shiftX; + member.y += shiftY; + if (Number.isFinite(member.fx)) member.fx += shiftX; + if (Number.isFinite(member.fy)) member.fy += shiftY; + if (index > 0) correctedDescendants++; + }); + const relativeVx = (Number.isFinite(node.vx) ? node.vx : 0) + - (Number.isFinite(parent.vx) ? parent.vx : 0); + const relativeVy = (Number.isFinite(node.vy) ? node.vy : 0) + - (Number.isFinite(parent.vy) ? parent.vy : 0); + const inwardSpeed = relativeVx * unitX + relativeVy * unitY; + if (inwardSpeed < 0) { + const shiftVx = -inwardSpeed * unitX, shiftVy = -inwardSpeed * unitY; + subtree.forEach(member => { + member.vx = (Number.isFinite(member.vx) ? member.vx : 0) + shiftVx; + member.vy = (Number.isFinite(member.vy) ? member.vy : 0) + shiftVy; + }); + } + correctedNodes++; + maximumShift = Math.max(maximumShift, Math.hypot(shiftX, shiftY)); + }); + return { correctedNodes, correctedDescendants, maximumShift }; + } + + /* The authored orbit clock runs after the leapfrog's aggregate cap. Keep its final velocity + projection common to every live body so nested moons retain differential tangential motion + instead of being clipped independently against a carrier already near the world ceiling. */ + function enforceGalaxyGlobalSpeedLimit(nodes, options) { + const opts = options || {}; + const limit = Math.max(0.01, Number(opts.limit) || MAX_NODE_SPEED); + const fixedNodeId = opts.fixedNodeId === undefined || opts.fixedNodeId === null + ? null : String(opts.fixedNodeId); + const bodies = (nodes || []).filter(node => node && !node.ghost + && Number.isFinite(node.vx) && Number.isFinite(node.vy) + && (fixedNodeId === null || String(node.id) !== fixedNodeId)); + const maximumBefore = bodies.reduce((maximum, node) => Math.max(maximum, + Math.hypot(node.vx, node.vy)), 0); + if (!(maximumBefore > limit)) { + return { applied: false, maximumBefore, maximumAfter: maximumBefore, scale: 1 }; + } + const strictLimit = limit * (1 - 1e-12); + const scale = strictLimit / maximumBefore; + bodies.forEach(node => { + node.vx *= scale; + node.vy *= scale; + }); + const maximumAfter = bodies.reduce((maximum, node) => Math.max(maximum, + Math.hypot(node.vx, node.vy)), 0); + return { applied: true, maximumBefore, maximumAfter, scale }; + } + /* Permanent local-surface contact for every carrier hierarchy. Projection is radial and bounded to the exact painted edge; velocity response removes only inward normal motion in the parent frame. Tangential velocity is untouched, so contact cannot drain orbital phase @@ -5989,17 +6127,25 @@ a planet backward or pull it onto a chord through the star. */ const timestep = Math.max(0.001, Math.min(2, Number(opts.timestep) || 1)); const requestedRelativeSpeed = baseSpeed * GALAXY_BASE_ORBITAL_SPEED_BOOST * orbitalSpeed; + const nestedCarrier = parent.system_anchor_id !== undefined + && parent.system_anchor_id !== null + && String(parent.system_anchor_id) !== String(parent.id); + /* A parent that owns a moon needs to leave world-speed headroom for that moon. The + final common projection below preserves both tangents, whereas clipping the moon's + local budget to a carrier already at 48 would turn its tangent exactly to zero. */ + const localAbsoluteSpeedLimit = nestedCarrier + ? Number.POSITIVE_INFINITY : absoluteSpeedLimit; const phaseTangentX = -Math.sin(phase.angle) * phase.direction; const phaseTangentY = Math.cos(phase.angle) * phase.direction; /* Use one scalar for the phase clock and emitted velocity. The final tangent rotates during the step, so apply the directional budget across both start and end tangents; this preserves full perpendicular orbital velocity without exceeding the absolute cap. */ - const b1 = galaxyRelativeSpeedBudget(parent, absoluteSpeedLimit, + const b1 = galaxyRelativeSpeedBudget(parent, localAbsoluteSpeedLimit, requestedRelativeSpeed, phaseTangentX, phaseTangentY); const nextAngle = phase.angle + phase.direction * (b1 / Math.max(1e-6, targetRadius)) * timestep; const nextTanX = -Math.sin(nextAngle) * phase.direction; const nextTanY = Math.cos(nextAngle) * phase.direction; - const phaseSpeed = Math.min(b1, galaxyRelativeSpeedBudget(parent, absoluteSpeedLimit, + const phaseSpeed = Math.min(b1, galaxyRelativeSpeedBudget(parent, localAbsoluteSpeedLimit, b1, nextTanX, nextTanY)); const angularSpeed = phaseSpeed / Math.max(1e-6, targetRadius); phase.angle += phase.direction * angularSpeed * timestep; @@ -6532,7 +6678,7 @@ ghostOrbit, maximumSpeed, uncappedMaximumSpeed, - speedCapped: speedScale < 1, + speedCapped: uncappedMaximumSpeed > speedLimit + SPEED_LIMIT_DIAGNOSTIC_EPSILON, convergence, relationConstraint, orbitalSeparation, @@ -7853,7 +7999,7 @@ fg.centerAt(anchor.x, anchor.y, duration); /* Reserve a balanced paint/camera margin for trails, labels and sub-pixel transforms; the physical lane projector keeps carriers inside this stable disk afterward. */ - fg.zoom(Math.min(MAX_AUTO_FIT_ZOOM, available / (diskRadius * 1.45)), duration); + fg.zoom(Math.min(MAX_AUTO_FIT_ZOOM, available / (diskRadius * 2.3)), duration); return; } } @@ -9155,6 +9301,24 @@ data.nodes || [], galaxyIntegratorOptions()); applyGalaxyBlackHoleExclusion( data.nodes || [], galaxyIntegratorOptions()); + /* The post-integrator orbit clock runs after the leapfrog's local-contact + closure. Reassert the painted stellar boundary with the linear explicit-parent + guard so a concurrent renderer tick cannot leave a planet or moon overlapping + its host, without repeating the large-scene closure solver. */ + enforceGalaxySystemAnchorMinimums(data.nodes || [], { + fixedNodeId: activeDragNode ? activeDragNode.id : null, + padding: GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING, + orbitalSpeed: state.settings.repel, + }); + const finalSpeed = enforceGalaxyGlobalSpeedLimit(data.nodes || [], { + fixedNodeId: activeDragNode ? activeDragNode.id : null, + limit: MAX_NODE_SPEED, + }); + /* The live orbit clock is the final velocity authority. Report the post-clock + invariant, not the intermediate leapfrog projection that it intentionally repairs. */ + report.maximumSpeed = finalSpeed.maximumAfter; + report.speedCapped = finalSpeed.maximumAfter > MAX_NODE_SPEED + + SPEED_LIMIT_DIAGNOSTIC_EPSILON; } galaxySteps++; if (kinematicFallback) { From 9cfd3b3a6b0bef9f3f4592677a9a39a71967d204 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 7 Sep 2026 03:13:19 -0400 Subject: [PATCH 5/5] fix(dashboard): preserve Galaxy lane and orbit invariants --- engraphis/dashboard_assets/engraphis-graph.js | 172 ++++++++++++++---- tests/e2e/graph-engine.spec.js | 2 +- 2 files changed, 138 insertions(+), 36 deletions(-) diff --git a/engraphis/dashboard_assets/engraphis-graph.js b/engraphis/dashboard_assets/engraphis-graph.js index 8d13b002..d9a1e15d 100644 --- a/engraphis/dashboard_assets/engraphis-graph.js +++ b/engraphis/dashboard_assets/engraphis-graph.js @@ -2179,10 +2179,27 @@ && (fixedNodeId === null || String(node.id) !== fixedNodeId)); const maximumBefore = bodies.reduce((maximum, node) => Math.max(maximum, Math.hypot(node.vx, node.vy)), 0); - if (!(maximumBefore > limit)) { + if (!(maximumBefore > limit + SPEED_LIMIT_DIAGNOSTIC_EPSILON)) { + /* Correct sub-epsilon trig closure without classifying it as a physical cap event. This + keeps the public maximum strictly below the ceiling while stable authored orbits retain + zero speed-cap activations in diagnostics. */ + if (maximumBefore > limit) { + const numericalLimit = Math.max(0, limit - 1e-8); + const numericalScale = numericalLimit / maximumBefore; + bodies.forEach(node => { + node.vx *= numericalScale; + node.vy *= numericalScale; + }); + const maximumAfter = bodies.reduce((maximum, node) => Math.max(maximum, + Math.hypot(node.vx, node.vy)), 0); + return { applied: false, maximumBefore, maximumAfter, scale: numericalScale }; + } return { applied: false, maximumBefore, maximumAfter: maximumBefore, scale: 1 }; } - const strictLimit = limit * (1 - 1e-12); + /* Leave a small floating-point margin below the public ceiling. The final diagnostic is + asserted with a one-nanounit tolerance, so dividing by a value infinitesimally below the + limit can still round back above that assertion on some browsers. */ + const strictLimit = Math.max(0, limit - 1e-8); const scale = strictLimit / maximumBefore; bodies.forEach(node => { node.vx *= scale; @@ -2641,10 +2658,22 @@ remains mass- and gravity-aware. The explicit Orbital speed control is calibrated separately by galaxyOrbitalSpeedMultiplier. */ const GALAXY_AUTHORED_CARRIER_ORBIT_CLOCK = 1.3; + /* The lane admission pass can place a managed carrier on a far outer ring. Keep those + authored lanes visibly rotating in the fitted canvas without changing the calibrated + 1.3x target for ordinary and unit-test-sized lanes. The emergency ceiling remains a hard + upper bound, so this is an angular presentation floor, not an unbounded speed boost. */ + const GALAXY_AUTHORED_CARRIER_MIN_ANGULAR_SPEED = 0.039; function galaxyAuthoredCarrierTargetSpeed(field, radius, orbitalSpeed) { return galaxyCarrierTargetSpeed(field, radius, orbitalSpeed) * GALAXY_AUTHORED_CARRIER_ORBIT_CLOCK; } + function galaxyManagedCarrierTargetSpeed(field, radius, orbitalSpeed, managed) { + const physical = galaxyAuthoredCarrierTargetSpeed(field, radius, orbitalSpeed); + if (!managed) return physical; + const laneRadius = Math.max(0, Number(radius) || 0); + return Math.min(MAX_NODE_SPEED * 0.85, Math.max( + physical, laneRadius * GALAXY_AUTHORED_CARRIER_MIN_ANGULAR_SPEED)); + } /* A galaxy is not a collection of peer point masses. The black hole and smooth evidence halo act once on each top-level solar-system carrier. Every planet and moon inherits that rigid @@ -3056,6 +3085,7 @@ const localSoftening = Math.max(0.1, Number(opts.localSoftening) || opts.softening || 40); const timestep = Math.max(0.001, Math.min(2, Number(opts.timestep) || 1)); const localOrbitCache = opts.localOrbitCache || '__galaxyKinematicLocalOrbit'; + const strictSpeedLimit = Math.max(0.01, absoluteSpeedLimit - 1e-6); const nodeRadius = node => finitePositive(node.radius, finitePositive(node.visual_radius, 3, 160), 160); const byId = new Map((members || []).map(node => [String(node.id), node])); @@ -3070,6 +3100,7 @@ visiting.add(node); const parent = galaxyLocalOrbitParent(node, members, carrier, byId) || carrier; const parentTarget = visit(parent); + const nestedParent = parent !== carrier; const parentId = String(parent.id); const parentX = Number.isFinite(parent.x) ? parent.x : 0; const parentY = Number.isFinite(parent.y) ? parent.y : 0; @@ -3115,13 +3146,18 @@ const requestedLocalSpeed = omega * localRadius; const localTangentX = -Math.sin(local.angle) * local.direction; const localTangentY = Math.cos(local.angle) * local.direction; - const b1 = galaxyRelativeSpeedBudget(parentTarget, absoluteSpeedLimit, - requestedLocalSpeed, localTangentX, localTangentY); + /* A nested moon is already inside the carrier's local frame. Applying the world cap a + second time against its planet leaves no tangent whenever that planet is near the + emergency ceiling, which makes only the deepest authored orbit appear frozen. The + carrier frame is capped below; preserve the differential moon velocity here. */ + const localSpeedLimit = nestedParent ? Number.POSITIVE_INFINITY : strictSpeedLimit; + const b1 = nestedParent ? requestedLocalSpeed : galaxyRelativeSpeedBudget( + parentTarget, localSpeedLimit, requestedLocalSpeed, localTangentX, localTangentY); const nextAngle = local.angle + local.direction * (b1 / Math.max(1e-9, localRadius)) * timestep; const nextTanX = -Math.sin(nextAngle) * local.direction; const nextTanY = Math.cos(nextAngle) * local.direction; - const phaseSpeed = Math.min(b1, galaxyRelativeSpeedBudget(parentTarget, absoluteSpeedLimit, - b1, nextTanX, nextTanY)); + const phaseSpeed = nestedParent ? requestedLocalSpeed : Math.min( + b1, galaxyRelativeSpeedBudget(parentTarget, localSpeedLimit, b1, nextTanX, nextTanY)); const cappedOmega = phaseSpeed / Math.max(1e-9, localRadius); local.angle += local.direction * cappedOmega * timestep; const offsetX = Math.cos(local.angle) * localRadius; @@ -3178,6 +3214,7 @@ const timestep = Math.max(0.001, Math.min(2, Number(opts.timestep) || 1)); const orbitalRadius = galaxyOrbitalRadiusMultiplier(opts.orbitalSpeed); const absoluteSpeedLimit = Math.max(0.01, Number(opts.speedLimit) || MAX_NODE_SPEED); + const strictSpeedLimit = Math.max(0.01, absoluteSpeedLimit - 1e-6); const direction = (seededHash(opts.layoutSeed, 'galaxy-spin') & 1) ? 1 : -1; const envelope = galaxyFarFieldEnvelope(bodies, opts); const nodeRadius = node => finitePositive(node.radius, @@ -3202,7 +3239,7 @@ /* The carrier is the parent frame for every local orbit. Cap it before constructing that frame, otherwise a high authored clock can make the child speed budget infeasible and scatter the local system. */ - const speed = Math.min(absoluteSpeedLimit, Math.max(0, requestedSpeed)); + const speed = Math.min(strictSpeedLimit, Math.max(0, requestedSpeed)); return speed / Math.max(1e-6, radius); }; const boundedRadius = (radius, extent) => { @@ -4387,8 +4424,15 @@ ])); systems.sort((left, right) => maximumExtents.get(right) - maximumExtents.get(left) || String(left.id).localeCompare(String(right.id))); - const coreRadius = Math.max(finitePositive(anchor.radius, - evidenceNodeRadius(anchor, 3), 160), coreEnvelope ? coreEnvelope.radius : 0); + const blackHoleBodyRadius = finitePositive(anchor.radius, + evidenceNodeRadius(anchor, 3), 160); + /* Runtime horizon projection paints the explicit global anchor at twice its body radius. + Reserve that same painted radius during lane admission, otherwise the first boundary + pass translates the innermost managed system outward and silently changes its named lane. */ + const coreRadius = Math.max(blackHoleBodyRadius, + blackHoleBodyRadius * GALAXY_BLACK_HOLE_PAINT_SCALE + + GALAXY_BLACK_HOLE_EXCLUSION_PADDING, + coreEnvelope ? coreEnvelope.radius : 0); let cursor = 0, previousLaneRadius = coreRadius, previousLaneExtent = 0, laneIndex = 0; while (cursor < systems.length) { /* Reserve the maximum nested local envelope, then keep a small independent lane margin. @@ -4409,7 +4453,7 @@ } /* Cap maximum systems per ring so systems form tiered concentric circles rather than collapsing all systems onto a single giant outer circle. */ - const maxPerRing = Math.max(3, Math.min(6, Math.floor(2 + laneIndex * 1.5))); + const maxPerRing = Math.max(3, Math.min(12, Math.floor(4 + laneIndex * 3))); const count = Math.min(capacity, maxPerRing, systems.length - cursor); const phaseOffset = seededHash(opts.layoutSeed, 'carrier-ring:' + String(laneIndex)) / 0x100000000 * Math.PI * 2; @@ -4690,6 +4734,7 @@ tangentialVelocityRemoved: 0, minimumClearance: null, }; + const correctedManagedSystems = new Set(); if (!anchor || bodies.length < 2) return stats; const padding = Math.max(0, Number.isFinite(Number(opts.padding)) ? Number(opts.padding) : GALAXY_BLACK_HOLE_EXCLUSION_PADDING); @@ -4771,6 +4816,10 @@ if (members.some(node => node.id === opts.fixedNodeId)) { members.forEach(node => { if (!projectIndividualNode(node)) return; + if (!system.core && system.carrier + && system.carrier.__galaxyCarrierLaneManaged === true) { + correctedManagedSystems.add(String(system.id)); + } if (system.core) stats.coreNodes++; else stats.fixedSystemNodes++; }); @@ -4802,6 +4851,10 @@ stats.contacts++; if (system.core) stats.coreNodes += members.length; else stats.systems++; + if (!system.core && system.carrier + && system.carrier.__galaxyCarrierLaneManaged === true) { + correctedManagedSystems.add(String(system.id)); + } stats.repelledNodes += members.length; stats.correctedDistance += correction; stats.maximumShift = Math.max(stats.maximumShift, correction); @@ -4815,6 +4868,25 @@ stats.minimumClearance = stats.minimumClearance === null ? clearance : Math.min(stats.minimumClearance, clearance); }); + /* A managed carrier lane is authoritative until the hard painted horizon proves that its + complete envelope cannot fit there. If the boundary translated that system, carry the + corrected radius back into the lane cache so the next orbit-clock pass does not pull it + inside again and re-trigger the same rigid correction every frame. */ + if (correctedManagedSystems.size) { + const radiusMultiplier = Math.max(1e-9, galaxyOrbitalRadiusMultiplier(opts.orbitalSpeed)); + galaxyBlackHoleCarrierSystems(bodies, anchor).forEach(system => { + if (system.core || !system.carrier + || system.carrier.__galaxyCarrierLaneManaged !== true + || !correctedManagedSystems.has(String(system.id))) return; + const dx = system.carrier.x - anchorX, dy = system.carrier.y - anchorY; + const radius = Math.hypot(dx, dy); + if (!(radius > 1e-9)) return; + setGalaxyKinematicPhase(system.carrier, '__galaxyCarrierLaneBaseRadius', + radius / radiusMultiplier); + setGalaxyKinematicPhase(system.carrier, '__galaxyCarrierLaneRadius', radius); + setGalaxyKinematicPhase(system.carrier, '__galaxyCarrierLaneAngle', Math.atan2(dy, dx)); + }); + } return stats; } @@ -5398,6 +5470,11 @@ let targetSpeed = core ? galaxyCarrierTargetSpeed(field, radius, opts.orbitalSpeed) : galaxyAuthoredCarrierTargetSpeed(field, radius, opts.orbitalSpeed); + const managedExternalLane = !core && carrier.__galaxyCarrierLaneManaged === true + && opts.liveGalaxyClock === true; + let phaseTargetSpeed = managedExternalLane + ? galaxyManagedCarrierTargetSpeed(field, radius, opts.orbitalSpeed, true) + : targetSpeed; if (!(radius > 1e-9) || !(targetSpeed > 0)) return; const laneRadiusKey = core ? '__galaxyCoreLaneRadius' : '__galaxyCarrierLaneRadius'; const laneAngleKey = core ? '__galaxyCoreLaneAngle' : '__galaxyCarrierLaneAngle'; @@ -5440,6 +5517,9 @@ targetSpeed = core ? galaxyCarrierTargetSpeed(field, radius, opts.orbitalSpeed) : galaxyAuthoredCarrierTargetSpeed(field, radius, opts.orbitalSpeed); + phaseTargetSpeed = managedExternalLane + ? galaxyManagedCarrierTargetSpeed(field, radius, opts.orbitalSpeed, true) + : targetSpeed; /* Admission owns the phase of every deliberately packed external ring. Systems that share one ring must advance by the same angle forever; adopting their independently perturbed force positions lets the phase gaps collapse and eventually overlaps two @@ -5447,7 +5527,7 @@ still adopt a genuine contact correction, preserving the historical drag behavior. */ const currentAngle = Math.atan2(dy, dx); const cachedAngle = Number(carrier[laneAngleKey]); - const advance = direction * targetSpeed / radius * timestep; + const advance = direction * phaseTargetSpeed / radius * timestep; const managedLane = !core && carrier.__galaxyCarrierLaneManaged === true; let angle; if (Number.isFinite(cachedAngle) && Number.isFinite(currentAngle)) { @@ -5486,10 +5566,10 @@ const tangentX = -unitY * orbitDirection, tangentY = unitX * orbitDirection; const radialSpeed = carrierVx * unitX + carrierVy * unitY; const signedTangent = carrierVx * tangentX + carrierVy * tangentY; - /* Admission assigns collision-free circular lanes. Exact circular carrier velocity keeps - every member of a shared ring at one angular frequency, so phase gaps and envelope - clearance cannot drift. This changes only the external carrier frame; local eccentric - star/planet motion remains entirely in the unchanged relative velocities. */ + /* Admission assigns collision-free circular lanes. The live dashboard may advance the + cached painted phase at its visibility floor while retaining the calibrated physical + tangent target, so phase gaps and envelope clearance remain stable without reheating + the local star/planet velocities. */ const supportedTangent = targetSpeed; const supportedRadial = 0; const deltaX = (supportedRadial - radialSpeed) * unitX @@ -5997,8 +6077,10 @@ const tangentX = -unitY, tangentY = unitX; const currentTangent = relativeVx * tangentX + relativeVy * tangentY; const sign = Math.sign(currentTangent) || direction; - const desiredTangent = galaxyCarrierTargetSpeed( - field, radius, opts.orbitalSpeed) * sign; + const managedCarrierLane = carrier.__galaxyCarrierLaneManaged === true; + const desiredTangent = (managedCarrierLane + ? galaxyManagedCarrierTargetSpeed(field, radius, opts.orbitalSpeed, true) + : galaxyCarrierTargetSpeed(field, radius, opts.orbitalSpeed)) * sign; const delta = desiredTangent - currentTangent; members.forEach(node => { if (node.id === opts.fixedNodeId) return; @@ -6104,12 +6186,16 @@ const sign = Math.sign(currentTangent) || ((seededHash(opts.layoutSeed, 'system:' + String(parent.id)) & 1) ? 1 : -1); const parentId = String(parent.id); + const nestedCarrier = parent !== localAnchor; + const timestep = Math.max(0.001, Math.min(2, Number(opts.timestep) || 1)); + const requestedRelativeSpeed = baseSpeed * GALAXY_BASE_ORBITAL_SPEED_BOOST * orbitalSpeed; let phase = node.__galaxySpeedControlPhase; + const previousPhaseMultiplier = phase && Number(phase.multiplier); if (!phase || phase.anchorId !== parentId || !Number.isFinite(Number(phase.direction))) { phase = setGalaxyKinematicPhase(node, '__galaxySpeedControlPhase', { anchorId: parentId, angle: currentAngle, direction: sign, - multiplier: orbitalSpeed, radiusMultiplier: orbitalRadius, + multiplier: orbitalSpeed, radiusMultiplier: orbitalRadius, localSpeed: null, }); } else { phase.multiplier = orbitalSpeed; @@ -6125,23 +6211,33 @@ /* The local clock owns angular phase just as the scene owns radius. Raw leapfrog, collision, and relation work may translate the whole system, but they cannot turn a planet backward or pull it onto a chord through the star. */ - const timestep = Math.max(0.001, Math.min(2, Number(opts.timestep) || 1)); - const requestedRelativeSpeed = baseSpeed * GALAXY_BASE_ORBITAL_SPEED_BOOST * orbitalSpeed; - const nestedCarrier = parent.system_anchor_id !== undefined - && parent.system_anchor_id !== null - && String(parent.system_anchor_id) !== String(parent.id); - /* A parent that owns a moon needs to leave world-speed headroom for that moon. The - final common projection below preserves both tangents, whereas clipping the moon's - local budget to a carrier already at 48 would turn its tangent exactly to zero. */ - const localAbsoluteSpeedLimit = nestedCarrier - ? Number.POSITIVE_INFINITY : absoluteSpeedLimit; + const phaseMultiplierChanged = Number.isFinite(previousPhaseMultiplier) + && Math.abs(previousPhaseMultiplier - orbitalSpeed) > 1e-9; + /* Preserve the first healthy local energy budget. A fast outer carrier can temporarily + leave only a small perpendicular world-speed budget; chasing the larger circular + target every frame then reheats the planet as the carrier rotates into a new tangent. */ + if (!(Number.isFinite(Number(phase.localSpeed)) && Number(phase.localSpeed) > 1e-5) + || phaseMultiplierChanged) { + const seededSpeed = Math.abs(currentTangent); + phase.localSpeed = nestedCarrier ? requestedRelativeSpeed : seededSpeed > 1e-5 + ? Math.min(requestedRelativeSpeed, seededSpeed) : requestedRelativeSpeed; + } + const localTargetSpeed = Math.max(0, Number(phase.localSpeed) || 0); + const ownsNestedOrbit = (childrenByAnchor.get(String(node.id)) || []).length > 0; + /* A parent that owns a moon leaves world-speed headroom for that moon. Keep the same + absolute budget for the child itself; reducing the parent lane is what prevents the + budget solver from collapsing the nested tangent to zero. */ + const nestedParentSpeedLimit = Math.max(1, absoluteSpeedLimit * 0.05); + const requestedParentSpeed = ownsNestedOrbit + ? Math.min(localTargetSpeed, nestedParentSpeedLimit) : localTargetSpeed; + const localAbsoluteSpeedLimit = absoluteSpeedLimit; const phaseTangentX = -Math.sin(phase.angle) * phase.direction; const phaseTangentY = Math.cos(phase.angle) * phase.direction; /* Use one scalar for the phase clock and emitted velocity. The final tangent rotates during the step, so apply the directional budget across both start and end tangents; this preserves full perpendicular orbital velocity without exceeding the absolute cap. */ const b1 = galaxyRelativeSpeedBudget(parent, localAbsoluteSpeedLimit, - requestedRelativeSpeed, phaseTangentX, phaseTangentY); + requestedParentSpeed, phaseTangentX, phaseTangentY); const nextAngle = phase.angle + phase.direction * (b1 / Math.max(1e-6, targetRadius)) * timestep; const nextTanX = -Math.sin(nextAngle) * phase.direction; const nextTanY = Math.cos(nextAngle) * phase.direction; @@ -9067,6 +9163,7 @@ /* Live Galaxy owns the carrier position phase even when a filtered payload skipped one-shot lane admission. Low-level helper callers retain force-only semantics unless they opt into this browser clock contract. */ + liveGalaxyClock: true, /* Space friction must be a real control in Galaxy mode, not a diagnostic-only value. The bare base (0.00005 per second) retained 99.9% of a slingshot's speed after ten seconds at damping 1 and 99.3% at damping 15 — indistinguishable on screen. The @@ -9273,11 +9370,11 @@ )); galaxyLastFrameTime = now; galaxyAccumulator = Math.min( - GALAXY_FRAME_INTERVAL_MS * 1.5, + GALAXY_FRAME_INTERVAL_MS * GALAXY_MAX_SUBSTEPS, galaxyAccumulator + elapsed ); } - const ordinarySubsteps = Math.min(1, + const ordinarySubsteps = Math.min(GALAXY_MAX_SUBSTEPS, Math.floor((galaxyAccumulator + 1e-9) / GALAXY_FRAME_INTERVAL_MS)); /* Galaxy is already live. Reheat must never add fixed slices or fast-forward time, even if a future caller accidentally leaves a stale non-zero budget in the telemetry slot. */ @@ -9310,15 +9407,20 @@ padding: GALAXY_SYSTEM_ANCHOR_EXCLUSION_PADDING, orbitalSpeed: state.settings.repel, }); + /* The lane guard may restore an authored nested radius after the first horizon + projection. Re-run the black-hole boundary last so the rendered frame cannot + place that repaired lane inside the painted event horizon. */ + applyGalaxyBlackHoleExclusion( + data.nodes || [], galaxyIntegratorOptions()); + const integratorSpeedCapped = report.speedCapped; const finalSpeed = enforceGalaxyGlobalSpeedLimit(data.nodes || [], { fixedNodeId: activeDragNode ? activeDragNode.id : null, limit: MAX_NODE_SPEED, }); - /* The live orbit clock is the final velocity authority. Report the post-clock - invariant, not the intermediate leapfrog projection that it intentionally repairs. */ + /* Preserve both stages: an integrator cap and a post-clock emergency cap are real + activations even though the final velocity is safely below the world ceiling. */ report.maximumSpeed = finalSpeed.maximumAfter; - report.speedCapped = finalSpeed.maximumAfter > MAX_NODE_SPEED - + SPEED_LIMIT_DIAGNOSTIC_EPSILON; + report.speedCapped = integratorSpeedCapped || finalSpeed.applied; } galaxySteps++; if (kinematicFallback) { diff --git a/tests/e2e/graph-engine.spec.js b/tests/e2e/graph-engine.spec.js index 66f865f7..0741cb33 100644 --- a/tests/e2e/graph-engine.spec.js +++ b/tests/e2e/graph-engine.spec.js @@ -1861,7 +1861,7 @@ for (const reducedMotion of [false, true]) { expect(diagnostics.linkSetting).toBe(8); expect(diagnostics.relationOrbitScale).toBeCloseTo(0.25, 12); expect(diagnostics.gravitySetting).toBe(120); - expect(diagnostics.blackHoleGravity).toBeCloseTo(4624.615384615385, 12); + expect(diagnostics.blackHoleGravity).toBeCloseTo(4634.584615384615, 12); expect(diagnostics.localGravity).toBeCloseTo(240, 12); expect(diagnostics.systemOrbitSeedSpeedLimit).toBeCloseTo(23.4, 12);