From 0e8a8f7f039f67e58740d01f803a5f5d78904a81 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Tue, 8 Sep 2026 04:39:02 -0400 Subject: [PATCH] fix(dashboard): harden Galaxy operating bounds --- engraphis/classic_assets/dashboard.js | 2 +- engraphis/classic_assets/index.html | 2 +- engraphis/dashboard_assets/engraphis-graph.js | 46 ++-- engraphis/dashboard_assets/index.html | 2 +- engraphis/dashboard_assets/ledger.js | 12 +- engraphis/static/dashboard.js | 2 +- engraphis/static/index.html | 2 +- tests/e2e/graph-engine.spec.js | 66 +++-- tests/e2e/ledger.spec.js | 43 +++- tests/test_galaxy_operating_bounds.py | 235 ++++++++++++++++++ tests/test_graph_engine_asset.py | 8 +- 11 files changed, 351 insertions(+), 69 deletions(-) create mode 100644 tests/test_galaxy_operating_bounds.py diff --git a/engraphis/classic_assets/dashboard.js b/engraphis/classic_assets/dashboard.js index 9ca636f8..42517944 100644 --- a/engraphis/classic_assets/dashboard.js +++ b/engraphis/classic_assets/dashboard.js @@ -1237,7 +1237,7 @@ function loadGraphEngine(loadAll=false){ GRAPH_ENGINE_LOADING=new Promise((resolve,reject)=>{ const script=document.createElement('script'); const bust=GRAPH_ENGINE_RETRY>0?'&r='+GRAPH_ENGINE_RETRY:''; - script.src='/v2-assets/engraphis-graph.js?v=20260903-rotation-balance-1'+bust; + script.src='/v2-assets/engraphis-graph.js?v=20260906-galaxy-boundaries-1'+bust; /* A 200 that never registers the global is a corrupt/truncated asset, not a success — resolving there would hand graphRenderEngine() an undefined EngraphisGraph. Failed attempts drop the script node and clear the memo so the next call retries with a diff --git a/engraphis/classic_assets/index.html b/engraphis/classic_assets/index.html index cc6dd96b..2d4af8f8 100644 --- a/engraphis/classic_assets/index.html +++ b/engraphis/classic_assets/index.html @@ -349,6 +349,6 @@ graph view. dashboard.js fetches both on demand from graphRender(); see loadForceGraph() and loadGraphEngine(). scripts/externalize_dashboard_assets.py enforces both halves: they stay out of this file, and the lazy references still have to resolve. --> - + diff --git a/engraphis/dashboard_assets/engraphis-graph.js b/engraphis/dashboard_assets/engraphis-graph.js index 070d926f..be7a6cb0 100644 --- a/engraphis/dashboard_assets/engraphis-graph.js +++ b/engraphis/dashboard_assets/engraphis-graph.js @@ -319,7 +319,6 @@ const GALAXY_ORBITAL_SPEED_RESPONSE_GAIN = 0.5; const GALAXY_ORBITAL_SPEED_MAXIMUM = 4.6; 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) @@ -746,10 +745,9 @@ const circularSpeed = Math.sqrt(Math.max(0, acceleration * localRadius)); const multiplier = Math.max(0, Number(orbitalSpeed) || 0); return kinematicCap - ? Math.min(circularSpeed * GALAXY_BASE_ORBITAL_SPEED_BOOST * multiplier, + ? Math.min(circularSpeed * multiplier, GALAXY_LOCAL_RELATIVE_SPEED_LIMIT * multiplier) - : Math.min(GALAXY_LOCAL_RELATIVE_SPEED_LIMIT, circularSpeed) - * GALAXY_BASE_ORBITAL_SPEED_BOOST * multiplier; + : Math.min(GALAXY_LOCAL_RELATIVE_SPEED_LIMIT, circularSpeed) * multiplier; } /* The classic renderer's *dense* signal (`GPERF.dense`, `links>1500` in dashboard.js). Past @@ -1230,7 +1228,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)) * GALAXY_BASE_ORBITAL_SPEED_BOOST * orbitalSpeed), + Math.sqrt(Math.max(0, acceleration * radius)) * orbitalSpeed), tangentX * sign, tangentY * sign); const parentId = String(parent.id); const previousParent = typeof node.__galaxyOrbitAnchorId === 'string' @@ -3428,12 +3426,13 @@ const globalSpeed = omega * orbit.radius; const globalVx = -Math.sin(orbit.angle) * globalSpeed * direction; const globalVy = Math.cos(orbit.angle) * globalSpeed * direction; - moveNode(star, targetX, targetY, globalVx, globalVy); + // Initialize local phases against the old parent frame before moving the carrier. const localMotion = advanceGalaxyKinematicLocalMembers(members, star, { x: targetX, y: targetY, vx: globalVx, vy: globalVy, }, item.core ? Object.assign({}, opts, { localOrbitCache: '__galaxyKinematicCoreLocalOrbit', }) : opts); + moveNode(star, targetX, targetY, globalVx, globalVy); satellites += localMotion.satellites; speedCapped = speedCapped || localMotion.speedCapped; const carrierContact = nodeRadius(anchor) + nodeRadius(star) @@ -4551,8 +4550,20 @@ const maximumExtents = new Map(systems.map(system => [ system, galaxySystemMaximumEnvelopeRadius(system, opts), ])); - systems.sort((left, right) => maximumExtents.get(right) - maximumExtents.get(left) + /* Rigid translations and rotations introduce a few ulps in otherwise equal envelopes. + Use stable precision only for ordering, so numerical noise cannot move a system to a + different ring. Keep exact extents for every safety reservation below. */ + const extentOrder = new Map(systems.map(system => [ + system, Number(maximumExtents.get(system).toPrecision(12)), + ])); + systems.sort((left, right) => extentOrder.get(right) - extentOrder.get(left) || String(left.id).localeCompare(String(right.id))); + const remainingExtents = new Array(systems.length); + let maximumRemainingExtent = 0; + for (let index = systems.length - 1; index >= 0; index--) { + maximumRemainingExtent = Math.max(maximumRemainingExtent, maximumExtents.get(systems[index])); + remainingExtents[index] = maximumRemainingExtent; + } const blackHoleBodyRadius = finitePositive(anchor.radius, evidenceNodeRadius(anchor, 3), 160); /* Runtime horizon projection paints the explicit global anchor at twice its body radius. @@ -4567,7 +4578,7 @@ /* Reserve the maximum nested local envelope, then keep a small independent lane margin. This remains collision-free when the orbital-speed control reaches its maximum. */ const laneSlack = GALAXY_CARRIER_LANE_SLACK; - const laneExtent = maximumExtents.get(systems[cursor]) * laneSlack; + const laneExtent = remainingExtents[cursor] * laneSlack; let laneRadius = Math.max(coreRadius + laneExtent + gap + GALAXY_BLACK_HOLE_EXCLUSION_PADDING, previousLaneRadius + previousLaneExtent + laneExtent + gap); @@ -4588,8 +4599,7 @@ 'carrier-ring:' + String(laneIndex)) / 0x100000000 * Math.PI * 2; for (let slot = 0; slot < count; slot++) { const system = systems[cursor + slot]; - /* Re-evaluate with the largest member of the next lane only; sorting makes every - remaining extent no larger than this ring's conservative laneExtent. */ + // The exact remaining maximum also covers larger members within an ordering tie. const angle = phaseOffset + slot * Math.PI * 2 / count; const unitX = Math.cos(angle), unitY = Math.sin(angle); const shiftX = anchor.x + unitX * laneRadius - system.x; @@ -6475,19 +6485,9 @@ 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; - 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; - } - } + // The final black-hole exclusion pass translates the complete system together. + const targetX = parent.x + unitX * targetRadius; + const targetY = parent.y + unitY * targetRadius; const targetVx = (Number.isFinite(parent.vx) ? parent.vx : 0) + tangentX * phaseSpeed; const targetVy = (Number.isFinite(parent.vy) ? parent.vy : 0) diff --git a/engraphis/dashboard_assets/index.html b/engraphis/dashboard_assets/index.html index 93eb319f..145b9c6d 100644 --- a/engraphis/dashboard_assets/index.html +++ b/engraphis/dashboard_assets/index.html @@ -823,6 +823,6 @@

Connected nodes

- + diff --git a/engraphis/dashboard_assets/ledger.js b/engraphis/dashboard_assets/ledger.js index 69c73467..a6dad820 100644 --- a/engraphis/dashboard_assets/ledger.js +++ b/engraphis/dashboard_assets/ledger.js @@ -500,7 +500,7 @@ graphAssetSource('/v2-assets/vendor/force-graph.min.js?v=20260727-final'), 'ForceGraph', controller.signal, )).then(() => loadScript( - graphAssetSource('/v2-assets/engraphis-graph.js?v=20260903-rotation-balance-1'), + graphAssetSource('/v2-assets/engraphis-graph.js?v=20260906-galaxy-boundaries-1'), 'EngraphisGraph', controller.signal, )).then(() => loadScript( graphAssetSource('/v2-assets/engraphis-spacetime.js?v=20260812-stable-orbit-lanes-7'), @@ -3129,9 +3129,11 @@ const savedTuning = graphPreference('tuning', {}); const savedPhysicsVersion = Number(graphPreference('physicsVersion', 0)); const sourcePhysicsVersion = Number.isFinite(savedPhysicsVersion) ? savedPhysicsVersion : 0; - const legacyPhysics = hasSavedPreferences && sourcePhysicsVersion < GRAPH_PHYSICS_VERSION; - const needsPhysicsV3Migration = hasSavedPreferences && sourcePhysicsVersion < 3; - const needsPhysicsV4Migration = hasSavedPreferences && sourcePhysicsVersion < 4; + const needsPhysicsMigration = version => hasSavedPreferences + && sourcePhysicsVersion < version; + const legacyPhysics = needsPhysicsMigration(GRAPH_PHYSICS_VERSION); + const needsPhysicsV3Migration = needsPhysicsMigration(3); + const needsPhysicsV4Migration = needsPhysicsMigration(4); const effectiveTuning = savedTuning && typeof savedTuning === 'object' ? { ...savedTuning } : {}; const savedSpacetimeTuning = graphPreference('spacetimeTuning', {}); @@ -3159,7 +3161,7 @@ } /* 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) { + if (needsPhysicsMigration(5) && preset === 'galaxy' && Number(effectiveTuning.gravity) === 96) { effectiveTuning.gravity = 120; } syncGraphTuning({ diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index 9ca636f8..42517944 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -1237,7 +1237,7 @@ function loadGraphEngine(loadAll=false){ GRAPH_ENGINE_LOADING=new Promise((resolve,reject)=>{ const script=document.createElement('script'); const bust=GRAPH_ENGINE_RETRY>0?'&r='+GRAPH_ENGINE_RETRY:''; - script.src='/v2-assets/engraphis-graph.js?v=20260903-rotation-balance-1'+bust; + script.src='/v2-assets/engraphis-graph.js?v=20260906-galaxy-boundaries-1'+bust; /* A 200 that never registers the global is a corrupt/truncated asset, not a success — resolving there would hand graphRenderEngine() an undefined EngraphisGraph. Failed attempts drop the script node and clear the memo so the next call retries with a diff --git a/engraphis/static/index.html b/engraphis/static/index.html index f4b9eebd..dac1e338 100644 --- a/engraphis/static/index.html +++ b/engraphis/static/index.html @@ -349,6 +349,6 @@ graph view. dashboard.js fetches both on demand from graphRender(); see loadForceGraph() and loadGraphEngine(). scripts/externalize_dashboard_assets.py enforces both halves: they stay out of this file, and the lazy references still have to resolve. --> - + diff --git a/tests/e2e/graph-engine.spec.js b/tests/e2e/graph-engine.spec.js index d9992444..01b28b67 100644 --- a/tests/e2e/graph-engine.spec.js +++ b/tests/e2e/graph-engine.spec.js @@ -20,7 +20,7 @@ const { test, expect } = require('@playwright/test'); */ const workspace = 'graph-e2e'; -const stellarOrbitAssetVersion = '20260903-rotation-balance-1'; +const stellarOrbitAssetVersion = '20260906-galaxy-boundaries-1'; // A small connected store: two clusters joined by one bridge, so communities, the legend and // the bridge detector all have something real to work on. @@ -691,7 +691,7 @@ async function renderedStellarSnapshot(page, systemId = 'aurora') { const blackHoleClearances = anchor ? nodes.filter(node => node !== anchor).map(node => Math.hypot(Number(node.x) - Number(anchorPoint.x), Number(node.y) - Number(anchorPoint.y)) - - nodeRadius(anchorPoint) - nodeRadius(node) - blackHolePadding) + - nodeRadius(anchorPoint) * 2 - nodeRadius(node) - blackHolePadding) : []; const stellarClearances = nodes.flatMap(node => { const stellarAnchor = byId.get(String(node.system_anchor_id)); @@ -758,6 +758,7 @@ async function renderedStellarSnapshot(page, systemId = 'aurora') { vx: Number(node.vx) || 0, vy: Number(node.vy) || 0 } : null; })(), systemCenter: center, + carrierAngle: star && anchor ? Math.atan2(star.y - anchor.y, star.x - anchor.x) : 0, globalAngle: Math.atan2(center.y - (anchor ? Number(anchor.y) || 0 : 0), center.x - (anchor ? Number(anchor.x) || 0 : 0)), visible: inside(starPoint) && inside(planetPoint), @@ -1693,12 +1694,16 @@ test('reduced-motion Galaxy preserves simultaneous local and black-hole orbits', })); const start = await galaxySystemSnapshot(page); const startPhase = localAndGlobalPhase(start); - const targetStep = start.diagnostics.steps + 450; - // Wait on the solver's fixed-step telemetry, never an elapsed wall-clock delay. - await page.waitForFunction(target => window.__engraphisGraph.physicsDiagnostics().steps >= target, - targetStep, { timeout: 30_000 }); - const end = await galaxySystemSnapshot(page); - const endPhase = localAndGlobalPhase(end); + const phases = [startPhase]; + let end = start; + // Sample the path so a complete revolution cannot alias to an apparently stationary orbit. + // Wait on fixed-step telemetry, keeping the same 450-step observation boundary. + for (let step = 15; step <= 450; step += 15) { + await page.waitForFunction(target => window.__engraphisGraph.physicsDiagnostics().steps >= target, + start.diagnostics.steps + step, { timeout: 30_000 }); + end = await galaxySystemSnapshot(page); + phases.push(localAndGlobalPhase(end)); + } expect(start.diagnostics.reducedMotion).toBe(true); expect(start.diagnostics.staticLayout).toBe(false); @@ -1707,8 +1712,10 @@ test('reduced-motion Galaxy preserves simultaneous local and black-hole orbits', expect(end.diagnostics.steps - start.diagnostics.steps).toBeGreaterThanOrEqual(450); for (const id of ['aurora', 'borealis']) { expect(startPhase[id].anchor).toBe(`${id}-star`); - const localTravel = signedAngleDelta(startPhase[id].local, endPhase[id].local); - const globalTravel = signedAngleDelta(startPhase[id].global, endPhase[id].global); + const localTravel = phases.slice(1).reduce((sum, phase, index) => sum + + signedAngleDelta(phases[index][id].local, phase[id].local), 0); + const globalTravel = phases.slice(1).reduce((sum, phase, index) => sum + + signedAngleDelta(phases[index][id].global, phase[id].global), 0); expect(Math.abs(localTravel), `${id} local phase`).toBeGreaterThan(0.3); expect(Math.abs(globalTravel), `${id} system phase`).toBeGreaterThan(0.25); } @@ -1736,12 +1743,15 @@ for (const reducedMotion of [false, true]) { // painted planetary arc rather than camera animation. await page.waitForTimeout(1200); + const observationStartedAt = Date.now(); const samples = [await renderedStellarSnapshot(page)]; - for (let sample = 0; sample < 13; sample += 1) { - /* Advance by simulation work, not wall-clock time. Under a busy CI browser, a fixed - timeout can observe fewer integrator steps and turn a healthy global orbit into a - false negative even though the local orbit remains correct. */ - const targetSteps = samples.at(-1).diagnostics.steps + 14; + const nominalObservationMs = 6_500, sampleCount = 13; + const sampleStepBudget = Math.ceil(nominalObservationMs / samples[0].diagnostics.frameIntervalMs); + for (let sample = 1; sample <= sampleCount; sample += 1) { + /* Preserve the original 6.5-second observation at the declared frame cadence (195 + slices at 30 Hz). Absolute step targets prevent polling delays from accumulating. */ + const targetSteps = samples[0].diagnostics.steps + + Math.ceil(sampleStepBudget * sample / sampleCount); await page.waitForFunction(step => window.__engraphisGraph && window.__engraphisGraph.physicsDiagnostics().steps >= step, targetSteps, { timeout: 10_000 }); @@ -1753,6 +1763,7 @@ for (const reducedMotion of [false, true]) { screenAngle: angleDelta(samples[index].screenLocal.angle, sample.screenLocal.angle), globalAngle: angleDelta(samples[index].globalAngle, sample.globalAngle), stepDelta: Math.max(1, sample.diagnostics.steps - samples[index].diagnostics.steps), + carrierAngle: angleDelta(samples[index].carrierAngle, sample.carrierAngle), radiusChange: Math.abs(sample.local.radius - samples[index].local.radius) / Math.max(1e-9, samples[index].local.radius), systemCenterChord: Math.hypot( @@ -1767,6 +1778,7 @@ for (const reducedMotion of [false, true]) { const localTravel = segments.reduce((sum, segment) => sum + segment.localAngle, 0); const screenTravel = segments.reduce((sum, segment) => sum + segment.screenAngle, 0); const globalTravel = segments.reduce((sum, segment) => sum + segment.globalAngle, 0); + const carrierTravel = segments.reduce((sum, segment) => sum + segment.carrierAngle, 0); const screenChord = segments.reduce((sum, segment) => sum + segment.screenChord, 0); const direction = Math.sign(localTravel); const coRotatingSegments = segments.filter(segment => @@ -1783,7 +1795,9 @@ for (const reducedMotion of [false, true]) { }); const before = samples[0], after = samples.at(-1); const evidence = { - preference, sampleStepBudget: 14 * 13, + preference, nominalObservationMs, sampleStepBudget, + observedWallClockMs: Date.now() - observationStartedAt, + simulatedObservationSeconds: sampleStepBudget * before.diagnostics.timestep, assetRequests: fetched(session.requested, '/v2-assets/engraphis-graph.js'), before: { anchor: before.anchor, star: before.star, planet: before.planet, local: before.local, screenLocal: before.screenLocal, globalAngle: before.globalAngle, @@ -1791,7 +1805,7 @@ for (const reducedMotion of [false, true]) { after: { anchor: after.anchor, star: after.star, planet: after.planet, local: after.local, screenLocal: after.screenLocal, globalAngle: after.globalAngle, steps: after.diagnostics.steps, safety: after.safety }, - localTravel, screenTravel, globalTravel, screenChord, coRotatingSegments, + localTravel, screenTravel, globalTravel, carrierTravel, screenChord, coRotatingSegments, phaseReversals, localStepMagnitudes, localStepMean, relativeKinetics, maximumRadiusChange: Math.max(...segments.map(segment => segment.radiusChange)), maximumSystemCenterChord: Math.max(...segments.map(segment => segment.systemCenterChord)), @@ -1802,6 +1816,9 @@ for (const reducedMotion of [false, true]) { body: Buffer.from(JSON.stringify(evidence, null, 2)), contentType: 'application/json', }); + console.log('BASE-PRIMARY-EVIDENCE', JSON.stringify({reducedMotion, before: before.star, + after: after.star, globalTravel, steps: after.diagnostics.steps, + maximumSpeed: after.safety.maximumSpeed})); testInfo.annotations.push({ type: 'visible-orbit-evidence', description: JSON.stringify(evidence), }); @@ -1850,11 +1867,10 @@ for (const reducedMotion of [false, true]) { .toBeLessThan(2); expect(Math.max(...samples.map(sample => sample.star.warp)), JSON.stringify(evidence)) .toBeLessThan(0.01); - /* Six and a half seconds is sampled on a real wall-clock server, so OS scheduling changes - the exact step count. A 0.30-radian sweep is already >17 degrees and independently - visible; the stronger local threshold above proves the nested planet orbit at the same - time. */ + /* A 0.30-radian sweep within the original nominal 6.5-second boundary is >17 degrees; + the stronger local threshold above proves the nested planet orbit at the same time. */ expect(Math.abs(globalTravel), JSON.stringify(evidence)).toBeGreaterThan(0.30); + expect(Math.abs(carrierTravel), JSON.stringify(evidence)).toBeGreaterThan(0.30); expect(after.local.radius, JSON.stringify(evidence)) .toBeGreaterThan(before.local.radius * 0.7); expect(after.local.radius).toBeLessThan(before.local.radius * 1.3); @@ -3894,6 +3910,7 @@ test('Reheat layout control never adds Galaxy bonus physics slices', async ({ pa return { phase: [star.x, star.y, star.vx || 0, star.vy || 0], diagnostics: window.__engraphisGraph.physicsDiagnostics(), + sampledAt: performance.now(), }; }); await page.locator('#graph-reheat, button[title="Re-run layout"]').first().click(); @@ -3910,13 +3927,18 @@ test('Reheat layout control never adds Galaxy bonus physics slices', async ({ pa phase: [star.x, star.y, star.vx || 0, star.vy || 0], diagnostics: window.__engraphisGraph.physicsDiagnostics(), d3: window.__explicitReheatD3, + sampledAt: performance.now(), }; }); expect(after.diagnostics.reheatActivations).toBe(before.diagnostics.reheatActivations + 1); expect(after.diagnostics.reheatStepsApplied).toBe(before.diagnostics.reheatStepsApplied); expect(after.diagnostics.reheatStepsRemaining).toBe(0); expect(after.diagnostics.lastReheatSubsteps).toBe(0); - expect(after.diagnostics.steps - before.diagnostics.steps).toBeLessThanOrEqual(12); + // Include the actual click/polling time in the normal 30 Hz budget. A busy browser can + // spend longer than the requested 250 ms here; it must still add no bonus physics slices. + const normalStepBudget = Math.ceil((after.sampledAt - before.sampledAt) + / after.diagnostics.frameIntervalMs) + 1; + expect(after.diagnostics.steps - before.diagnostics.steps).toBeLessThanOrEqual(normalStepBudget); expect(after.diagnostics.steps).toBeGreaterThan(before.diagnostics.steps); expect(Math.hypot(after.phase[0] - before.phase[0], after.phase[1] - before.phase[1])) .toBeGreaterThan(0.01); diff --git a/tests/e2e/ledger.spec.js b/tests/e2e/ledger.spec.js index d66193e0..f10256d5 100644 --- a/tests/e2e/ledger.spec.js +++ b/tests/e2e/ledger.spec.js @@ -850,18 +850,18 @@ test('Ledger cache-busts a graph renderer that fetched but did not register', as await expect(page.locator('#graph-empty')).toContainText('Graph unavailable'); expect(rendererRequests).toHaveLength(1); const first = new URL(rendererRequests[0]); - expect(first.searchParams.get('v')).toBe('20260903-rotation-balance-1'); + expect(first.searchParams.get('v')).toBe('20260906-galaxy-boundaries-1'); expect(first.searchParams.has('retry')).toBe(false); await page.getByRole('button', { name: 'Reload data' }).click(); await expect(page.locator('#graph-count')).toContainText('3 entities · 1 relations'); expect(rendererRequests).toHaveLength(2); const second = new URL(rendererRequests[1]); - expect(second.searchParams.get('v')).toBe('20260903-rotation-balance-1'); + expect(second.searchParams.get('v')).toBe('20260906-galaxy-boundaries-1'); expect(second.searchParams.get('retry')).toBe('1'); }); -test('Ledger narrowly migrates only the legacy Galaxy spacing default', async ({ page }) => { +test('Ledger applies each Galaxy preference migration once', async ({ page }) => { const key = 'engraphis-ledger-graph-preferences-v1'; const writePreferences = preferences => page.evaluate(({ storageKey, value }) => { localStorage.setItem(storageKey, JSON.stringify(value)); @@ -932,13 +932,36 @@ test('Ledger narrowly migrates only the legacy Galaxy spacing default', async ({ 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: 5, preset: 'galaxy', tuning: { repel: 48, gravity: 0 }, - }); - await page.reload(); - await expect(page.locator('#graph-repel')).toHaveValue('48'); - expect((await readPreferences()).tuning.repel).toBe(48); + // A later migration must not reinterpret a previously versioned user choice. + for (const physicsVersion of [4, 5]) { + for (const repel of [48, 60]) { + await writePreferences({ + physicsVersion, preset: 'galaxy', tuning: { repel, gravity: 96 }, + }); + await page.reload(); + await expect(page.locator('#graph-repel')).toHaveValue(String(repel)); + const expectedGravity = physicsVersion < 5 ? 120 : 96; + await expect(page.locator('#graph-gravity')).toHaveValue(String(expectedGravity)); + const saved = await readPreferences(); + expect(saved.physicsVersion).toBe(5); + expect(saved.tuning.repel).toBe(repel); + expect(saved.tuning.gravity).toBe(expectedGravity); + } + } + + // The v3 reset applies only to snapshots older than v3, even after a v5 upgrade. + for (const physicsVersion of [3, 4]) { + const spacetimeTuning = { gravitationalConstant: 200, blackHoleMass: 500, + localGravitationalConstant: 200, damping: 0, springStiffness: 100 }; + await writePreferences({ physicsVersion, preset: 'galaxy', + tuning: { repel: 400, link: 80, gravity: 400 }, spacetimeTuning }); + await page.reload(); + await expect(page.locator('#graph-repel')).toHaveValue('400'); + await expect(page.locator('#graph-gravity')).toHaveValue('400'); + const saved = await readPreferences(); + expect(saved.tuning).toMatchObject({ repel: 400, link: 80, gravity: 400 }); + expect(saved.spacetimeTuning).toMatchObject(spacetimeTuning); + } }); test('Ledger deadline includes stalled graph assets and Reload data starts a fresh attempt', async ({ page }) => { diff --git a/tests/test_galaxy_operating_bounds.py b/tests/test_galaxy_operating_bounds.py new file mode 100644 index 00000000..3f3d0c90 --- /dev/null +++ b/tests/test_galaxy_operating_bounds.py @@ -0,0 +1,235 @@ +"""Exercise final painted/orbital bounds through the shipped graph engine.""" +import json + +import pytest + +from tests.test_graph_engine_asset import _run_engine, _run_node, requires_node + + +@requires_node +def test_equivalent_systems_keep_their_lanes_after_rigid_translation(): + report = _run_node(""" + const measure = (offset, reverse, rotation = 0, noise = 0) => { + const nodes = [{ id: 'bh', anchor_role: 'global', system_anchor_id: 'bh', + community_id: 'core', radius: 8, gravity_mass: 64, + x: offset, y: offset }]; + for (let index = 0; index < 24; index++) { + const id = 'system-' + String(index).padStart(2, '0'); + const angle = rotation + index * 2.399963229728653; + const x = offset + 100 * Math.cos(angle), y = offset + 100 * Math.sin(angle); + nodes.push({ id, anchor_role: 'community', system_anchor_id: id, + community_id: id, radius: 5, gravity_mass: 8, x, y }); + nodes.push({ id: id + '-planet', system_anchor_id: id, community_id: id, + orbit_radius: 53, radius: 2, gravity_mass: 1, + x: x + 53 * Math.cos(angle) + (index % 2 ? noise : -noise), + y: y + 53 * Math.sin(angle) }); + } + if (reverse) nodes.reverse(); + I.establishGalaxyCarrierLanes(nodes, { layoutSeed: 3031 }); + const anchor = nodes.find(node => node.id === 'bh'); + const systems = I.galaxySystemEnvelopes(nodes, { respectFixedCoordinates: false }) + .filter(system => system.anchor !== anchor); + let clearance = Infinity; + systems.forEach((left, index) => systems.slice(index + 1).forEach(right => { + clearance = Math.min(clearance, Math.hypot(left.x - right.x, left.y - right.y) + - left.radius - right.radius); + })); + const carriers = nodes.filter(node => node.anchor_role === 'community') + .sort((left, right) => left.id.localeCompare(right.id)); + return { clearance, positions: carriers.map(node => [ + node.x - anchor.x, node.y - anchor.y, node.__galaxyCarrierLaneRadius, + ]) }; + }; + emit([measure(0, false), measure(1000, false), measure(1000, true), + measure(-1000, true, Math.PI / 7), measure(1000, false, 0, 1e-12)]); + """) + for measured in report: + assert measured["clearance"] >= 0, measured + for expected, actual in zip(report[0]["positions"], measured["positions"]): + assert actual == pytest.approx(expected, rel=0, abs=1e-9), measured + + +@requires_node +@pytest.mark.parametrize("width,height", [(800, 600), (420, 800), (1600, 900)]) +def test_auto_fit_contains_complete_orbital_envelopes(width, height): + result = _run_engine( + f"el.clientWidth={width}; el.clientHeight={height};\n" + """ + const pending = new Map(); + let timerId = 0; + globalThis.setTimeout = fn => { pending.set(++timerId, fn); return timerId; }; + globalThis.clearTimeout = id => pending.delete(id); + store.getGraphBbox = { x: [-100, 100], y: [-100, 100] }; + const api = G.create(el, { settings: { mode: 'galaxy' }, collapse: 'never' }); + api.setData({ nodes: [ + { id: 'bh', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'bh', gravity_mass: 12, radius: 8, x: 0, y: 0 }, + { id: 'star', anchor_role: 'community', community_id: 'solar', + system_anchor_id: 'star', gravity_mass: 4, radius: 5, x: 100, y: 0 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', + gravity_mass: 1, radius: 2, orbit_radius: 20, x: 120, y: 0 }, + ], edges: [] }); + const timers = [...pending.values()]; pending.clear(); + timers.forEach(fn => fn()); + const nodes = store.graphData.nodes; + const anchor = nodes.find(node => node.anchor_role === 'global'); + const radius = I.galaxySystemEnvelopes(nodes, { + respectFixedCoordinates: false, + }).reduce((maximum, system) => Math.max(maximum, + Math.hypot(system.anchor.x - anchor.x, system.anchor.y - anchor.y) + + system.radius), 1); + const zoom = Array.isArray(store.zoom) ? store.zoom[0] : store.zoom; + emit({ available: Math.min(el.clientWidth, el.clientHeight) - 80, + diameter: radius * 2 * zoom, zoom, radius }); + api.destroy(); + """ + ) + assert result["diameter"] > 0 + assert result["diameter"] <= result["available"], result + + +@requires_node +@pytest.mark.parametrize("parent_speed,angle,timestep", [ + (12, 4.539601384437251, 0.032), + (47.999, 0.7037167544041136, 1), + (47.999, 0.7037167544041136, 2), + (0, 0.4, 2), +]) +def test_live_phase_and_emitted_velocity_share_a_safe_endpoint(parent_speed, angle, timestep): + result = _run_node( + "const probe = " + json.dumps({"speed": parent_speed, "angle": angle, "dt": timestep}) + ";\n" + """ + const a = probe.angle, r = 5; + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', gravity_mass: 16, radius: 8, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'star', anchor_role: 'community', community_id: 'solar', + system_anchor_id: 'star', orbit_tier: 0, gravity_mass: 100, radius: 1, + x: 1000, y: 0, vx: probe.speed, vy: 0 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', + orbit_tier: 1, orbit_radius: r, gravity_mass: 1, radius: .5, + x: 1000 + r * Math.cos(a), y: r * Math.sin(a), + vx: probe.speed - Math.sin(a), vy: Math.cos(a) }, + ]; + const before = Math.atan2(nodes[2].y - nodes[1].y, nodes[2].x - nodes[1].x); + I.applyGalaxyOrbitalSpeedControl(nodes, { + gravity: 48, softening: 1, centralSoftening: 40, + localGravitySetting: 48, localGravitationalConstant: 8, orbitalSpeed: 100, + layoutSeed: 19, timestep: probe.dt, speedLimit: 48, + }); + const planet = nodes[2], star = nodes[1]; + const radius = Math.hypot(planet.x - star.x, planet.y - star.y); + // The unwrapped clock avoids principal-angle aliasing at supported long steps. + const delta = Math.abs(planet.__galaxySpeedControlPhase.angle - before); + emit({ speed: Math.hypot(planet.vx, planet.vy), radius, + phaseSpeed: delta * radius / probe.dt, + relativeSpeed: Math.hypot(planet.vx - star.vx, planet.vy - star.vy) }); + """ + ) + assert result["speed"] <= 48, result + assert result["radius"] == pytest.approx(5), result + assert result["phaseSpeed"] == pytest.approx(result["relativeSpeed"], rel=1e-9, abs=1e-10), result + + +@requires_node +def test_live_black_hole_exclusion_preserves_parent_orbit(): + report = _run_engine(""" + let nextFrame = 1; + const queue = new Map(); + window.requestAnimationFrame = callback => { + const id = nextFrame++; queue.set(id, callback); return id; + }; + window.cancelAnimationFrame = id => queue.delete(id); + const flush = time => { + const callbacks = [...queue.values()]; queue.clear(); + callbacks.forEach(callback => callback(time)); + }; + let frames = 0, minRadius = Infinity; + let minParentClearance = Infinity, minBhClearance = Infinity, maxRadiusError = 0; + const sample = () => { + const nodes = store.graphData.nodes; + const byId = new Map(nodes.map(node => [node.id, node])); + const bh = byId.get('bh'), star = byId.get('star'), planet = byId.get('planet'); + const radius = Math.hypot(planet.x - star.x, planet.y - star.y); + const minimum = star.radius + planet.radius + 1.5; + const expected = Math.max(planet.orbit_radius, minimum); + minRadius = Math.min(minRadius, radius); + minParentClearance = Math.min(minParentClearance, radius - minimum); + maxRadiusError = Math.max(maxRadiusError, Math.abs(radius - expected)); + nodes.forEach(node => { + if (node === bh) return; + minBhClearance = Math.min(minBhClearance, + Math.hypot(node.x - bh.x, node.y - bh.y) - bh.radius * 2 - node.radius - 2.5); + }); + frames++; + }; + const api = G.create(el, { reducedMotion: () => false, onPhysicsFrame: sample }); + api.setPreset('galaxy'); + api.setSettings({ gravity: 48 }); + api.setData({ nodes: [ + { id: 'bh', anchor_role: 'global', system_anchor_id: 'bh', community_id: 'core', + gravity_mass: 64, visual_radius: 8, orbit_tier: 0, x: 0, y: 0 }, + { id: 'star', anchor_role: 'community', system_anchor_id: 'star', community_id: 'solar', + gravity_mass: 12, visual_radius: 8, orbit_tier: 0, x: 70.4, y: 0 }, + { id: 'planet', anchor_role: 'none', system_anchor_id: 'star', community_id: 'solar', + gravity_mass: 2, visual_radius: 8, orbit_tier: 1, orbit_radius: 19.2, x: 83.2, y: 14.4 }, + { id: 'other-star', anchor_role: 'community', system_anchor_id: 'other-star', + community_id: 'other', gravity_mass: 9, visual_radius: 8, orbit_tier: 0, x: -16, y: 113.6 }, + ], edges: [{ id: 'orbit', source: 'star', target: 'planet', relation: 'orbits', + rest_length: 19.2, spring_strength: .08 }], + communities: [ + { id: 'core', anchor_id: 'bh', mass: 64, member_count: 1 }, + { id: 'solar', anchor_id: 'star', mass: 14, member_count: 2 }, + { id: 'other', anchor_id: 'other-star', mass: 9, member_count: 1 }, + ], community_bridges: [], + meta: { algorithm_version: 'galaxy-v6', layout_seed: 91, total_nodes: 4, truncated: false }, + }); + for (let step = 0; step < 120; step++) flush(100 + step * (1000 / 30)); + const steps = api.physicsDiagnostics().steps; + api.destroy(); + emit({ frames, steps, minRadius, minParentClearance, minBhClearance, maxRadiusError }); + """) + assert report["frames"] == report["steps"] == 120 + assert report["minRadius"] > 8 + assert report["maxRadiusError"] < 1e-7 + assert report["minParentClearance"] >= -1e-7 + assert report["minBhClearance"] >= -1e-7 + + +@requires_node +@pytest.mark.parametrize("global_gravity", [0, 1]) +def test_kinematic_orbits_clear_the_painted_black_hole(global_gravity): + report = _run_node(f"const globalGravity = {global_gravity};\n" + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', gravity_mass: 16, radius: 8, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'star', anchor_role: 'community', community_id: 'solar', + system_anchor_id: 'star', gravity_mass: 4, radius: 2, + x: 20, y: 0, vx: 0, vy: 0 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', + orbit_tier: 1, orbit_radius: 6, gravity_mass: 1, radius: 1, + x: 26, y: 0, vx: 0, vy: 0 }, + ]; + let minimumClearance = Infinity, minimumParentClearance = Infinity, maximumRadiusError = 0; + for (let step = 0; step < 120; step++) { + I.advanceGalaxyKinematicOrbits(nodes, { + gravity: 48, softening: 12, centralSoftening: 40, localSoftening: 12, + gravitationalConstant: globalGravity, + orbitalSpeed: 100, layoutSeed: 19, timestep: .032, speedLimit: 48, + }); + for (const node of nodes.slice(1)) { + minimumClearance = Math.min(minimumClearance, + Math.hypot(node.x - nodes[0].x, node.y - nodes[0].y) + - nodes[0].radius * 2 - node.radius - 2.5); + } + minimumParentClearance = Math.min(minimumParentClearance, + Math.hypot(nodes[2].x - nodes[1].x, nodes[2].y - nodes[1].y) + - nodes[1].radius - nodes[2].radius - 1.5); + maximumRadiusError = Math.max(maximumRadiusError, + Math.abs(Math.hypot(nodes[2].x - nodes[1].x, nodes[2].y - nodes[1].y) - 6)); + } + emit({ minimumClearance, minimumParentClearance, maximumRadiusError }); + """) + assert report["minimumClearance"] >= -1e-7, report + assert report["minimumParentClearance"] >= -1e-7, report + assert report["maximumRadiusError"] < 1e-7, report diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py index 67a4fca7..1151b445 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -381,7 +381,7 @@ def test_graph_engine_deep_link_reaches_the_next_engine_after_a_lazy_load() -> N report = _run_routing("loads") assert report["appended"] == [ - "/v2-assets/engraphis-graph.js?v=20260903-rotation-balance-1" + "/v2-assets/engraphis-graph.js?v=20260906-galaxy-boundaries-1" ] # It waits rather than rendering something wrong in the meantime. assert report["beforeSettle"] == {"engine": 0, "classic": 0} @@ -396,7 +396,7 @@ def test_classic_route_reaches_the_canonical_engine_without_a_query_flag() -> No report = _run_routing("classic") assert report["appended"] == [ - "/v2-assets/engraphis-graph.js?v=20260903-rotation-balance-1" + "/v2-assets/engraphis-graph.js?v=20260906-galaxy-boundaries-1" ] assert report["beforeSettle"] == {"engine": 0, "classic": 0} assert report["engine"] == 1 @@ -10462,10 +10462,10 @@ def test_primary_graph_dependencies_are_lazy_retryable_and_csp_clean() -> None: d3 = loader.index("'/v2-assets/vendor/d3.min.js?v=20260727-final'") force_graph = loader.index("'/v2-assets/vendor/force-graph.min.js?v=20260727-final'") renderer = loader.index( - "'/v2-assets/engraphis-graph.js?v=20260903-rotation-balance-1'" + "'/v2-assets/engraphis-graph.js?v=20260906-galaxy-boundaries-1'" ) assert d3 < force_graph < renderer - assert '/v2-assets/ledger.js?v=20260906-lifecycle-1' in markup + assert '/v2-assets/ledger.js?v=20260906-galaxy-boundaries-1' in markup assert "if (graphAssetsPromise === attempt) releaseGraphAssetsAttempt(attempt)" in loader assert "graphAssetsRetry = Math.min(graphAssetsRetry + 1, 10)" in loader all_loader = source[source.index("function ensureGraphAllAsset()"):