diff --git a/engraphis/classic_assets/dashboard.js b/engraphis/classic_assets/dashboard.js index a7b599ca..473e2e88 100644 --- a/engraphis/classic_assets/dashboard.js +++ b/engraphis/classic_assets/dashboard.js @@ -569,7 +569,7 @@ async function exportWorkspace(){try{const d=await api('/export?workspace='+enco async function loadTeam(){const el=document.getElementById('team-body'),teamCta=hostedCta('team','team_tab');try{const st=await api('/auth/state');if(teamCta.href==='#'&&st&&st.cloud_url)teamCta.href=safeUrl(st.cloud_url)}catch(e){}el.innerHTML=`
Engraphis Team Cloud HOSTED
Organizations, invitations, roles, named seats, scoped device credentials, and team audit run on the private hosted service. This local dashboard is intentionally single-user.
${esc(teamTeaserNote())} Private-service account grace is capped at 24 hours, never extends Team access, and never restricts the free local core.
${ctaLinkHtml(teamCta,'btn btn-primary btn-sm','team_tab')}
`} /* health + settings */ function connectionContext(){const host=(location.hostname||'').toLowerCase();return host==='localhost'||host==='127.0.0.1'||host==='::1'||host.endsWith('.localhost')?'Local engine':'Remote customer node'} -async function checkHealth(){const label=connectionContext();try{await api('/health');const d=document.getElementById('health-dot'),t=document.getElementById('health-text');if(d){d.classList.add('health-ok');d.classList.remove('health-error')}if(t)t.textContent=label+' connected'}catch(e){const d=document.getElementById('health-dot'),t=document.getElementById('health-text');if(d){d.classList.add('health-error');d.classList.remove('health-ok')}if(t)t.textContent=label+' unavailable'}try{const auth=await api('/auth/state');const m=document.getElementById('deployment-mode-indicator');if(m){const isLocal=auth.deployment_mode==='local';m.textContent=isLocal?'LOCAL':'HOSTED';m.title=isLocal?(auth.enabled?'Local API token required':'Local mode: no hosted cloud configured'):'Hosted mode: connected to Engraphis Cloud';m.className='deployment-mode '+(isLocal?'mode-local':'mode-hosted');m.hidden=false}}catch(e){}} +async function checkHealth(){const label=connectionContext();try{await api('/health');const d=document.getElementById('health-dot'),t=document.getElementById('health-text');if(d){d.classList.add('health-ok');d.classList.remove('health-error')}if(t)t.textContent=label+' connected'}catch(e){const d=document.getElementById('health-dot'),t=document.getElementById('health-text');if(d){d.classList.add('health-error');d.classList.remove('health-ok')}if(t)t.textContent=label+' unavailable'}try{const auth=await api('/auth/state');const m=document.getElementById('deployment-mode-indicator');if(m){const isLocal=auth.deployment_mode==='local';m.textContent=isLocal?'LOCAL':'HOSTED';m.title=isLocal?(auth.enabled?'Local API token required':'Local mode: no hosted cloud configured'):'Hosted mode: connected to Engraphis Cloud';m.className='deployment-mode '+(isLocal?'mode-local':'mode-hosted');m.hidden=false}}catch(e){}} function loadSettings(){loadLicense();loadSyncStatus();loadHostedAgentAccess();loadLlmStatus();const s=document.getElementById('cfg-store');if(s)s.textContent=location.host;api('/info').then(function(d){var v=document.getElementById('cfg-version');if(v&&d&&d.version)v.textContent=d.version}).catch(function(){})} async function loadLlmStatus(){const el=document.getElementById('llm-body');if(!el)return;try{const st=await api('/llm/status');const ok=st.configured;const badge=ok?'configured':'not configured';const keyLine=st.key_set?'API key set ✓':'No API key set';let modelSel='';let provSel='';el.innerHTML=`
Provider · Model${badge}
${provSel}${modelSel}
${keyLine} · extractor: ${esc(st.extractor)}
Add this to your .env and restart Engraphis:
LLM extraction${st.extractor_enabled?'ON':'OFF'}
While ON, ingested memory content is sent to your LLM provider for schema-validated extraction. OFF disables extraction transfers only; retention supervision is configured separately.
`}catch(e){el.innerHTML='
'+esc(e.message)+'
'}} @@ -597,7 +597,7 @@ const syncNowBase=syncNow; syncNow=async function(){if(!await confirmCloudTransfer('Sync shared workspaces','Cloud Sync sends eligible changes from your shared workspaces to Engraphis Cloud and receives authorized changes from your other installations; secret and session-scoped rows stay local.','Sync now',CLOUD_SYNC_PRIVACY_COPY))return;return syncNowBase()} /* ─── knowledge graph (force-graph + d3-force: compact defaults and selectable layouts) ─── */ -let GRAPH=null, FG=null, GRAPH_ENGINE=null, GRESIZE=false, GRESIZEFRAME=0, GADJ={}, GCOMM_ADJ={}, GCOMPONENTS={}, GCOMPONENT_LAYOUT=null, GHILITE=null, GHOVERSET=null, GLABELRANK={}, GLABELBOXES=[], GDATA_CACHE=null, GACTIVE_DATA=null, GREDRAWFRAME=0, GPERF={large:false,dense:false}, GRAPH_LOAD_REQUEST=0, GRAPH_LOAD_CONTROLLER=null; +let GRAPH=null, FG=null, GRAPH_ENGINE=null, GRESIZE=false, GRESIZEFRAME=0, GADJ={}, GCOMM_ADJ={}, GCOMPONENTS={}, GCOMPONENT_LAYOUT=null, GHILITE=null, GHOVERSET=null, GLABELRANK={}, GLABELBOXES=[], GDATA_CACHE=null, GACTIVE_DATA=null, GREDRAWFRAME=0, GPERF={large:false,dense:false}, GRAPH_LOAD_REQUEST=0, GRAPH_LOAD_CONTROLLER=null; const GRAPH_PRESETS={ 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:.7,labelDensity:30,curve:.08,particles:0}, @@ -724,9 +724,9 @@ function graphEngineEmptyMessage(){ const total=(GRAPH&&GRAPH.nodes&&GRAPH.nodes.length)||0; return total?('No connected entities — tick "Show unlinked" to see all '+total+'.'):'No entities in this workspace yet.'; } -function graphRenderEngine(data,fit,reheat){ - const element=document.getElementById('graph-net'),empty=document.getElementById('graph-empty'); - if(!element||typeof EngraphisGraph==='undefined')return false; +function graphRenderEngine(data,fit,reheat){ + const element=document.getElementById('graph-net'),empty=document.getElementById('graph-empty'); + if(!element||typeof EngraphisGraph==='undefined')return false; try{ if(!data.nodes.length){ if(GRAPH_ENGINE)GRAPH_ENGINE.setData({nodes:[],links:[]}); @@ -738,7 +738,7 @@ function graphRenderEngine(data,fit,reheat){ const created=!GRAPH_ENGINE; if(created){ GRAPH_ENGINE=EngraphisGraph.create(element,{ - renderMode:'overview', + renderMode:'overview', reducedMotion:prefersReducedMotion, onNodeClick:node=>{syncGraphExplorerSelection(node.id);graphNodeClick(node.label||node.name||node.id)}, onBackgroundClick:()=>graphSetHighlight(null), @@ -756,7 +756,7 @@ function graphRenderEngine(data,fit,reheat){ const isolated=document.getElementById('graph-show-iso'),showUnlinked=!!(isolated&&isolated.checked); GRAPH_ENGINE.apply(engine=>{ engine.setSettings({...window.GSET}); - if(typeof engine.setRenderMode==='function')engine.setRenderMode('overview'); + if(typeof engine.setRenderMode==='function')engine.setRenderMode('overview'); engine.setStyle(typeof GSTYLE!=='undefined'?GSTYLE:'cyber'); engine.setColorBy(typeof GCOLORBY!=='undefined'?GCOLORBY:'community'); engine.setThemeColors(graphThemeTypeColors()); @@ -778,7 +778,7 @@ function graphRenderEngine(data,fit,reheat){ null. Re-apply the parked state here so a renderer created against a hidden pane never starts a rAF that nothing will stop. */ if(GRAPH_ENGINE_PARKED)GRAPH_ENGINE.pause(); - graphSetSimulationStatus(window.GSET.frozen?'Layout frozen':'Adaptive layout',false); + graphSetSimulationStatus(window.GSET.frozen?'Layout frozen':'Adaptive layout',false); return true; }catch(error){ graphEngineFallback(error); @@ -798,15 +798,15 @@ function graphInvalidateData(){ if(GRAPH_ENGINE){try{GRAPH_ENGINE.destroy()}catch(e){}GRAPH_ENGINE=null} GDATA_CACHE=null;GACTIVE_DATA=null;GCOMPONENT_LAYOUT=null;GHILITE=null;GHOVERSET=null } -async function loadLegacyGraph(){ - const request=++GRAPH_LOAD_REQUEST; - const previousController=GRAPH_LOAD_CONTROLLER,controller=new AbortController();GRAPH_LOAD_CONTROLLER=controller; - if(previousController&&!previousController.signal.aborted)previousController.abort(); - /* Transactional reload: keep the existing graph visible until the replacement payload - succeeds. Only invalidate caches (not the rendered graph) so a network failure leaves - the user looking at the previous data rather than an error screen. */ - const previousGraph=GRAPH,previousEngine=GRAPH_ENGINE,previousActive=GACTIVE_DATA; - GDATA_CACHE=null; +async function loadLegacyGraph(){ + const request=++GRAPH_LOAD_REQUEST; + const previousController=GRAPH_LOAD_CONTROLLER,controller=new AbortController();GRAPH_LOAD_CONTROLLER=controller; + if(previousController&&!previousController.signal.aborted)previousController.abort(); + /* Transactional reload: keep the existing graph visible until the replacement payload + succeeds. Only invalidate caches (not the rendered graph) so a network failure leaves + the user looking at the previous data rather than an error screen. */ + const previousGraph=GRAPH,previousEngine=GRAPH_ENGINE,previousActive=GACTIVE_DATA; + GDATA_CACHE=null; const empty=document.getElementById('graph-empty'),net=document.getElementById('graph-net'),nodesBox=document.getElementById('graph-entity-list'),edgesBox=document.getElementById('graph-relation-list'); showAs(empty,true,'flex');empty.textContent='Loading graph…';graphSetLayoutStatus('Loading data',true); if(net)net.setAttribute('aria-busy','true'); @@ -817,28 +817,28 @@ async function loadLegacyGraph(){ GRESIZEFRAME=requestAnimationFrame(()=>{GRESIZEFRAME=0;const element=document.getElementById('graph-net');if(GRAPH_ENGINE)GRAPH_ENGINE.resize();else if(FG&&element)FG.width(element.clientWidth).height(element.clientHeight)}); }); } - const layerInputs=Array.from(document.querySelectorAll('#graph-layer-filters input')),selectedLayers=layerInputs.filter(input=>input.checked).map(input=>input.value),layerFilter=selectedLayers.length===layerInputs.length?'':'&layers='+encodeURIComponent(selectedLayers.join(',')),includeCode=document.getElementById('graph-include-code').checked,repo=(document.getElementById('graph-repo-filter').value||'').trim(),showUnlinked=!!document.getElementById('graph-show-iso').checked; - try{ - const query=encodeURIComponent(WS||'')+(repo?'&repo='+encodeURIComponent(repo):'')+layerFilter; - const nextGraph=await api('/graph?workspace='+query+'&include_code='+(includeCode?'true':'false')+'&limit=1000&node_limit=1000&edge_limit=2000'+(showUnlinked?'':'&connected_only=true'),{signal:controller.signal}); - if(request!==GRAPH_LOAD_REQUEST)return; - /* Commit: the new payload arrived successfully. Now tear down the old renderer and - install the replacement graph. */ - if(previousEngine){try{previousEngine.destroy()}catch(e){}} - GRAPH_ENGINE=null;GACTIVE_DATA=null;GCOMPONENT_LAYOUT=null;GHILITE=null;GHOVERSET=null; - GRAPH=nextGraph; - renderGraphSide();renderGraphExplorer();graphRender(); - }catch(error){ - if(request!==GRAPH_LOAD_REQUEST||error.name==='AbortError')return; - /* Rollback: restore the previous graph so the user is not left staring at an error. + const layerInputs=Array.from(document.querySelectorAll('#graph-layer-filters input')),selectedLayers=layerInputs.filter(input=>input.checked).map(input=>input.value),layerFilter=selectedLayers.length===layerInputs.length?'':'&layers='+encodeURIComponent(selectedLayers.join(',')),includeCode=document.getElementById('graph-include-code').checked,repo=(document.getElementById('graph-repo-filter').value||'').trim(),showUnlinked=!!document.getElementById('graph-show-iso').checked; + try{ + const query=encodeURIComponent(WS||'')+(repo?'&repo='+encodeURIComponent(repo):'')+layerFilter; + const nextGraph=await api('/graph?workspace='+query+'&include_code='+(includeCode?'true':'false')+'&limit=1000&node_limit=1000&edge_limit=2000'+(showUnlinked?'':'&connected_only=true'),{signal:controller.signal}); + if(request!==GRAPH_LOAD_REQUEST)return; + /* Commit: the new payload arrived successfully. Now tear down the old renderer and + install the replacement graph. */ + if(previousEngine){try{previousEngine.destroy()}catch(e){}} + GRAPH_ENGINE=null;GACTIVE_DATA=null;GCOMPONENT_LAYOUT=null;GHILITE=null;GHOVERSET=null; + GRAPH=nextGraph; + renderGraphSide();renderGraphExplorer();graphRender(); + }catch(error){ + if(request!==GRAPH_LOAD_REQUEST||error.name==='AbortError')return; + /* Rollback: restore the previous graph so the user is not left staring at an error. If there was no previous graph (first load), show the error message. */ if(previousGraph){ GRAPH=previousGraph;GRAPH_ENGINE=previousEngine;GACTIVE_DATA=previousActive; showAs(empty,false);graphSetLayoutStatus('Reload failed — showing previous data',false); toast('Reload data failed: '+error.message,'err'); - }else{ - showAs(empty,true,'flex');empty.textContent='Graph failed: '+error.message;graphSetLayoutStatus('Load failed',false); - } + }else{ + showAs(empty,true,'flex');empty.textContent='Graph failed: '+error.message;graphSetLayoutStatus('Load failed',false); + } }finally{ if(request!==GRAPH_LOAD_REQUEST)return; if(GRAPH_LOAD_CONTROLLER===controller)GRAPH_LOAD_CONTROLLER=null; @@ -851,10 +851,10 @@ async function loadLegacyGraph(){ } } } -function graphData(){ - const _si=document.getElementById('graph-show-iso');const hideIso=!(_si&&_si.checked); - if(GDATA_CACHE&&GDATA_CACHE.graph===GRAPH&&GDATA_CACHE.hideIso===hideIso)return GDATA_CACHE.data; - let sourceNodes=GRAPH.nodes;if(hideIso)sourceNodes=sourceNodes.filter(node=>node.degree>0); +function graphData(){ + const _si=document.getElementById('graph-show-iso');const hideIso=!(_si&&_si.checked); + if(GDATA_CACHE&&GDATA_CACHE.graph===GRAPH&&GDATA_CACHE.hideIso===hideIso)return GDATA_CACHE.data; + let sourceNodes=GRAPH.nodes;if(hideIso)sourceNodes=sourceNodes.filter(node=>node.degree>0); const names=new Set(sourceNodes.map(node=>node.id)); const nodes=sourceNodes.map(node=>({id:node.id,label:node.label||node.id,displayLabel:(node.label||node.id).length>30?(node.label||node.id).slice(0,29)+'…':(node.label||node.id),etype:node.etype,degree:node.degree||0,val:1+(node.degree||0)})); const maxDegree=Math.max(1,...nodes.map(node=>node.degree||0)); @@ -1214,57 +1214,57 @@ function loadForceGraph(){ }); return FORCE_GRAPH_LOADING; } -let GRAPH_ENGINE_LOADING=null,GRAPH_ENGINE_RETRY=0,ALL_GRAPH_ENGINE_LOADING=null; -function loadAllGraphEngine(){ - if(typeof EngraphisEveryGraph!=='undefined')return Promise.resolve(); - if(!ALL_GRAPH_ENGINE_LOADING){ - ALL_GRAPH_ENGINE_LOADING=new Promise((resolve,reject)=>{ - const script=document.createElement('script');script.src='/v2-assets/engraphis-graph-every.js?v=20260823-every-19'; - script.onload=()=>{typeof EngraphisEveryGraph==='undefined'?reject(new Error('Every-node graph asset loaded without registering EngraphisEveryGraph')):resolve()}; - script.onerror=()=>reject(new Error('Every-node graph asset could not load')); - document.head.appendChild(script); - }); - ALL_GRAPH_ENGINE_LOADING.catch(()=>{}); - } - return ALL_GRAPH_ENGINE_LOADING; -} -function loadGraphEngine(loadAll=false){ - let engineReady; - if(typeof EngraphisGraph!=='undefined'){GRAPH_ENGINE_RETRY=0;engineReady=Promise.resolve();} - else{ - if(!GRAPH_ENGINE_LOADING){ - 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=20260815-merge-ready-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 - cache-buster rather than returning the same rejected promise forever. */ - const cleanup=(failed)=>{if(script.parentNode)script.parentNode.removeChild(script);if(failed){GRAPH_ENGINE_LOADING=null;GRAPH_ENGINE_RETRY=Math.min(GRAPH_ENGINE_RETRY+1,2)}else{GRAPH_ENGINE_RETRY=0}}; - script.onload=()=>{if(typeof EngraphisGraph==='undefined'){cleanup(true);reject(new Error('Graph engine asset loaded without registering EngraphisGraph'))}else{cleanup(false);resolve()}}; - script.onerror=()=>{cleanup(true);reject(new Error('Graph engine could not load'))}; - document.head.appendChild(script); - }); - GRAPH_ENGINE_LOADING.catch(()=>{}); - } - engineReady=GRAPH_ENGINE_LOADING; - } - /* Mark the memoized promise handled. graphRender() can start this fetch on a pass that - returns before attaching its own handler, and an unhandled rejection would print the exact - console error this lazy-loading exists to remove. Callers still receive the rejection. */ - return loadAll?engineReady.then(()=>loadAllGraphEngine()):engineReady; -} -function graphRender(fit=true,reheat=true){ - const empty=document.getElementById('graph-empty'); - const graphFull=typeof GRAPH_FULL!=='undefined'&&GRAPH_FULL; +let GRAPH_ENGINE_LOADING=null,GRAPH_ENGINE_RETRY=0,ALL_GRAPH_ENGINE_LOADING=null; +function loadAllGraphEngine(){ + if(typeof EngraphisEveryGraph!=='undefined')return Promise.resolve(); + if(!ALL_GRAPH_ENGINE_LOADING){ + ALL_GRAPH_ENGINE_LOADING=new Promise((resolve,reject)=>{ + const script=document.createElement('script');script.src='/v2-assets/engraphis-graph-every.js?v=20260823-every-19'; + script.onload=()=>{typeof EngraphisEveryGraph==='undefined'?reject(new Error('Every-node graph asset loaded without registering EngraphisEveryGraph')):resolve()}; + script.onerror=()=>reject(new Error('Every-node graph asset could not load')); + document.head.appendChild(script); + }); + ALL_GRAPH_ENGINE_LOADING.catch(()=>{}); + } + return ALL_GRAPH_ENGINE_LOADING; +} +function loadGraphEngine(loadAll=false){ + let engineReady; + if(typeof EngraphisGraph!=='undefined'){GRAPH_ENGINE_RETRY=0;engineReady=Promise.resolve();} + else{ + if(!GRAPH_ENGINE_LOADING){ + 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=20260831-galaxy-floor-fix-2'+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 + cache-buster rather than returning the same rejected promise forever. */ + const cleanup=(failed)=>{if(script.parentNode)script.parentNode.removeChild(script);if(failed){GRAPH_ENGINE_LOADING=null;GRAPH_ENGINE_RETRY=Math.min(GRAPH_ENGINE_RETRY+1,2)}else{GRAPH_ENGINE_RETRY=0}}; + script.onload=()=>{if(typeof EngraphisGraph==='undefined'){cleanup(true);reject(new Error('Graph engine asset loaded without registering EngraphisGraph'))}else{cleanup(false);resolve()}}; + script.onerror=()=>{cleanup(true);reject(new Error('Graph engine could not load'))}; + document.head.appendChild(script); + }); + GRAPH_ENGINE_LOADING.catch(()=>{}); + } + engineReady=GRAPH_ENGINE_LOADING; + } + /* Mark the memoized promise handled. graphRender() can start this fetch on a pass that + returns before attaching its own handler, and an unhandled rejection would print the exact + console error this lazy-loading exists to remove. Callers still receive the rejection. */ + return loadAll?engineReady.then(()=>loadAllGraphEngine()):engineReady; +} +function graphRender(fit=true,reheat=true){ + const empty=document.getElementById('graph-empty'); + const graphFull=typeof GRAPH_FULL!=='undefined'&&GRAPH_FULL; /* Kick the opt-in engine off alongside the vendor bundle instead of after it, so a `?graph-engine=next` deep link costs one round trip rather than two. */ - const engineMissing=typeof EngraphisGraph==='undefined'||(graphFull&&typeof EngraphisEveryGraph==='undefined'); + const engineMissing=typeof EngraphisGraph==='undefined'||(graphFull&&typeof EngraphisEveryGraph==='undefined'); /* All mode owns a dedicated bounded renderer and must remain available after a quality-renderer runtime failure. The quality failure latch only authorizes the small legacy overview. */ - const enginePending=(graphFull||(!GRAPH_ENGINE_FAILED&&graphEngineEnabled()))&&engineMissing?loadGraphEngine(graphFull):null; - if(!graphFull&&typeof ForceGraph==='undefined'){ + const enginePending=(graphFull||(!GRAPH_ENGINE_FAILED&&graphEngineEnabled()))&&engineMissing?loadGraphEngine(graphFull):null; + if(!graphFull&&typeof ForceGraph==='undefined'){ showAs(empty,true,'flex');empty.textContent='Loading graph engine…'; graphSetLayoutStatus('Loading engine',true); loadForceGraph().then(()=>graphRender(fit,reheat)).catch(error=>{ @@ -1281,28 +1281,28 @@ function graphRender(fit=true,reheat=true){ announced through graphEngineFallback() rather than silent. */ showAs(empty,true,'flex');empty.textContent='Loading graph engine…'; graphSetLayoutStatus('Loading engine',true); - enginePending.then(()=>graphRender(fit,reheat)).catch(error=>{ - if(graphFull){ - empty.textContent=error.message+'; return to High quality or reload the dashboard assets.'; - graphSetLayoutStatus('All-node engine unavailable',false); - return; - } - /* Latches GRAPH_ENGINE_FAILED, so the re-entry below takes the classic path and this + enginePending.then(()=>graphRender(fit,reheat)).catch(error=>{ + if(graphFull){ + empty.textContent=error.message+'; return to High quality or reload the dashboard assets.'; + graphSetLayoutStatus('All-node engine unavailable',false); + return; + } + /* Latches GRAPH_ENGINE_FAILED, so the re-entry below takes the classic path and this cannot loop. */ graphEngineFallback(error); graphRender(fit,reheat); }); return; - } - const element=document.getElementById('graph-net'),settings=window.GSET,mode=GRAPH_PRESETS[settings.mode]||GRAPH_PRESETS.compact,data=graphData(); - if(graphFull){ - if(graphRenderEngine(data,fit,reheat))return; - showAs(empty,true,'flex'); - empty.textContent='All-node renderer unavailable; return to High quality or reload the dashboard assets.'; - graphSetLayoutStatus('All-node engine unavailable',false); - return; - } - if(graphEngineEnabled()&&graphRenderEngine(data,fit,reheat))return; + } + const element=document.getElementById('graph-net'),settings=window.GSET,mode=GRAPH_PRESETS[settings.mode]||GRAPH_PRESETS.compact,data=graphData(); + if(graphFull){ + if(graphRenderEngine(data,fit,reheat))return; + showAs(empty,true,'flex'); + empty.textContent='All-node renderer unavailable; return to High quality or reload the dashboard assets.'; + graphSetLayoutStatus('All-node engine unavailable',false); + return; + } + if(graphEngineEnabled()&&graphRenderEngine(data,fit,reheat))return; /* Read AFTER the opt-in attempt: a failing engine resets GACTIVE_DATA precisely so the classic renderer below rebuilds from scratch instead of assuming the canvas is current. */ const dataChanged=GACTIVE_DATA!==data; @@ -1532,7 +1532,7 @@ function graphKeyboard(event){ const node=nodes[GKEYINDEX],net=document.getElementById('graph-net');graphFocus(node.id);net.setAttribute('aria-label','Selected entity '+(node.label||node.id)+', '+(node.degree||0)+' relations. Press Enter to open. Use arrow keys to move.'); } function syncGraphExplorerSelection(id){document.querySelectorAll('#graph-entity-list [data-entity]').forEach(button=>{const active=button.dataset.entity===id;button.classList.toggle('active',active);if(active)button.setAttribute('aria-current','true');else button.removeAttribute('aria-current')})} -function graphQueueExplorer(query){clearTimeout(GEXPLORER_TIMER);GEXPLORER_TIMER=setTimeout(()=>renderGraphExplorer(query,true),120)} +function graphQueueExplorer(query){clearTimeout(GEXPLORER_TIMER);GEXPLORER_TIMER=setTimeout(()=>renderGraphExplorer(query,true),120)} function graphExplorerMore(kind){ if(kind==='nodes')GEXPLORER.nodeLimit+=GRAPH_EXPLORER_PAGE.nodes;else GEXPLORER.edgeLimit+=GRAPH_EXPLORER_PAGE.edges; renderGraphExplorer(GEXPLORER.query,false); @@ -1688,7 +1688,7 @@ function renderUpdateBanner(u){ } function clearBootstrapError(){const overlay=document.getElementById('bootstrap-error-overlay');if(overlay){overlay.classList.remove('show');dialogChanged(overlay);overlay.remove()}} function showBootstrapError(msg){clearBootstrapError();const overlay=document.createElement('div');overlay.id='bootstrap-error-overlay';overlay.className='mm-overlay show';overlay.setAttribute('aria-hidden','false');overlay.innerHTML='';document.body.appendChild(overlay);dialogChanged(overlay)} -async function boot(){clearBootstrapError();if(CURRENT_VIEW!=='graph')graphResetEngineFailure();try{const b=await api('/bootstrap');LIC=b.license;RELEASE_VERSION=typeof b.version==='string'?b.version.trim():'';renderSemBanner(b.embedder);renderUpdateBanner(b.update);WORKSPACES=b.workspaces||[];if(!WS&&WORKSPACES.length){WORKSPACES.sort((a,b)=>(b.memories||0)-(a.memories||0));setWS(WORKSPACES[0].name)}updateLicBadge();updateFeatureLocks();loadOverview();checkHealth()}catch(e){if(e.status===401&&await authenticateBrowser()){window.location.reload();return}const msg=e.status===404?'Dashboard APIs are unavailable. This usually means the legacy v1 server is running. Stop it, then launch scripts.start_dashboard.':'Dashboard initialization failed. Run engraphis-init --check for diagnostics.';showBootstrapError(msg)}} +async function boot(){clearBootstrapError();if(CURRENT_VIEW!=='graph')graphResetEngineFailure();try{const b=await api('/bootstrap');LIC=b.license;RELEASE_VERSION=typeof b.version==='string'?b.version.trim():'';renderSemBanner(b.embedder);renderUpdateBanner(b.update);WORKSPACES=b.workspaces||[];if(!WS&&WORKSPACES.length){WORKSPACES.sort((a,b)=>(b.memories||0)-(a.memories||0));setWS(WORKSPACES[0].name)}updateLicBadge();updateFeatureLocks();loadOverview();checkHealth()}catch(e){if(e.status===401&&await authenticateBrowser()){window.location.reload();return}const msg=e.status===404?'Dashboard APIs are unavailable. This usually means the legacy v1 server is running. Stop it, then launch scripts.start_dashboard.':'Dashboard initialization failed. Run engraphis-init --check for diagnostics.';showBootstrapError(msg)}} initTheme(); initDashboard(); boot(); diff --git a/engraphis/dashboard_assets/engraphis-graph-every-worker.js b/engraphis/dashboard_assets/engraphis-graph-every-worker.js index 7028eb13..71d34b48 100644 --- a/engraphis/dashboard_assets/engraphis-graph-every-worker.js +++ b/engraphis/dashboard_assets/engraphis-graph-every-worker.js @@ -22,7 +22,11 @@ const MAX_CENTROID_GROUPS = 512; let model = null; - let settings = { repel: 48, link: 16, gravity: 48 }; + let settings = { + repel: 48, link: 16, gravity: 48, + gravitationalConstant: 1, blackHoleMass: 1, localGravitationalConstant: 1, + damping: 1, springStiffness: 1, + }; let generation = 0; function post(message) { self.postMessage(message); } @@ -217,6 +221,11 @@ springs run weak — they are visual routes between districts, not licence to drag the districts into one another over the settle passes. */ const scaledSpacing = SPACING * MAP_SCALE; + const spring = Number.isFinite(Number(settings.springStiffness)) + ? Math.max(0, Math.min(100 / 32, Number(settings.springStiffness))) : 1; + // springStiffness is already a normalized multiplier from the dashboard. Preserve its + // zero endpoint so the Link spring control can actually disable pair attraction. + const springScale = spring; const rest = Math.max(scaledSpacing * 1.9, Number(settings.link) * 1.6 * (MAP_SCALE * 0.55)); for (let edge = 0; edge < model.totalLinks; edge += 1) { const a = model.sources[edge], b = model.targets[edge]; @@ -224,7 +233,7 @@ const dist = Math.sqrt(ddx * ddx + ddy * ddy) || 0.0001; const crossCommunity = model.communities[a] !== model.communities[b] ? 0.02 : 0.07; - const force = (dist - rest) / dist * crossCommunity; + const force = (dist - rest) / dist * crossCommunity * springScale; dx[a] -= ddx * force; dy[a] -= ddy * force; dx[b] += ddx * force; dy[b] += ddy * force; } @@ -241,7 +250,11 @@ } const minDist = SPACING * MAP_SCALE * 1.55; const minDist2 = minDist * minDist; - const push = Number(settings.repel) / 48; + const cohesion = Number.isFinite(Number(settings.localGravitationalConstant)) + ? Math.max(0, Math.min(2, Number(settings.localGravitationalConstant))) : 1; + /* Cluster cohesion strengthens the attractive spring network above. Invert its influence + on the collision-style push so a higher cohesion setting does not spread clusters apart. */ + const push = Number(settings.repel) / 48 * (1.5 - 0.5 * cohesion); for (let index = 0; index < count; index += 1) { const gx = Math.floor(pos[index * 2] / cell), gy = Math.floor(pos[index * 2 + 1] / cell); let checked = 0; @@ -314,14 +327,23 @@ } } - const gravity = Number(settings.gravity) / 48 * 0.0015; + const coreAttraction = Number.isFinite(Number(settings.gravitationalConstant)) + ? Math.max(0, Math.min(2, Number(settings.gravitationalConstant))) : 1; + const coreMass = Number.isFinite(Number(settings.blackHoleMass)) + ? Math.max(0, Math.min(2, Number(settings.blackHoleMass))) : 1; + const gravity = Number(settings.gravity) / 48 * 0.0015 * coreAttraction * coreMass; for (let index = 0; index < count; index += 1) { dx[index] += (cx - pos[index * 2]) * gravity; dy[index] += (cy - pos[index * 2 + 1]) * gravity; } /* A tight per-pass step cap keeps the settle from smearing district boundaries. */ - const damp = 0.8, maxStep = SPACING * MAP_SCALE * 0.7; + const resistance = Number.isFinite(Number(settings.damping)) + ? Math.max(0, Math.min(15, Number(settings.damping))) : 1; + /* Keep the full 0..15 control range responsive: the reciprocal curve reaches 0.2 + at resistance 13, so a 0.2 floor would make the final two slider units inert. */ + const damp = Math.max(0.15, Math.min(0.95, 0.8 / (0.75 + 0.25 * resistance))); + const maxStep = SPACING * MAP_SCALE * 0.7; for (let index = 0; index < count; index += 1) { let vx = dx[index] * damp, vy = dy[index] * damp; const speed = Math.sqrt(vx * vx + vy * vy); @@ -409,6 +431,15 @@ repel: Number.isFinite(Number(next.repel)) ? Number(next.repel) : settings.repel, link: Number.isFinite(Number(next.link)) ? Number(next.link) : settings.link, gravity: Number.isFinite(Number(next.gravity)) ? Number(next.gravity) : settings.gravity, + gravitationalConstant: Number.isFinite(Number(next.gravitationalConstant)) + ? Number(next.gravitationalConstant) : settings.gravitationalConstant, + blackHoleMass: Number.isFinite(Number(next.blackHoleMass)) + ? Number(next.blackHoleMass) : settings.blackHoleMass, + localGravitationalConstant: Number.isFinite(Number(next.localGravitationalConstant)) + ? Number(next.localGravitationalConstant) : settings.localGravitationalConstant, + damping: Number.isFinite(Number(next.damping)) ? Number(next.damping) : settings.damping, + springStiffness: Number.isFinite(Number(next.springStiffness)) + ? Number(next.springStiffness) : settings.springStiffness, }; if (data.relayout && model) { generation += 1; diff --git a/engraphis/dashboard_assets/engraphis-graph-every.js b/engraphis/dashboard_assets/engraphis-graph-every.js index ed508c7c..7d3aea6f 100644 --- a/engraphis/dashboard_assets/engraphis-graph-every.js +++ b/engraphis/dashboard_assets/engraphis-graph-every.js @@ -8,7 +8,7 @@ (function () { 'use strict'; - const WORKER_URL = '/v2-assets/engraphis-graph-every-worker.js?v=20260823-every-19'; + const WORKER_URL = '/v2-assets/engraphis-graph-every-worker.js?v=20260830-spacetime-controls-20'; const MAX_NODES = 20000; const MAX_LINKS = 200000; const LABEL_MAX = 220; @@ -151,7 +151,9 @@ totalLinks: 0, edgeVertexCount: 0, camera: { x: 0, y: 0, scale: 1 }, baseScale: 1, width: 1, height: 1, dpr: 1, styleName: opts.style || 'cyber', colorBy: 'community', typeColors: {}, themeColors: {}, palette: 'theme', - settings: { labels: true, flow: false, flowSpeed: 45, frozen: false, mode: 'communities', repel: 48, link: 16, gravity: 48, font: 12, size: 3, linkw: 0.72 }, + settings: { labels: true, flow: false, flowSpeed: 45, frozen: false, mode: 'communities', repel: 48, link: 16, gravity: 48, font: 12, size: 3, linkw: 0.72, + gravitationalConstant: 1, blackHoleMass: 1, localGravitationalConstant: 1, + damping: 1, springStiffness: 1 }, sizeBy: 'degree', bridges: true, ghosts: true, scope: { minDegree: 0, showUnlinked: true, depth: 2 }, collapse: false, collapsed: false, @@ -1376,7 +1378,10 @@ const patch = value || {}; state.settings = { ...state.settings, ...patch }; state.flowPaintAt = 0; - const relayout = Object.keys(patch).some(key => ['mode', 'repel', 'link', 'gravity'].includes(key)); + const relayout = Object.keys(patch).some(key => [ + 'mode', 'repel', 'link', 'gravity', 'gravitationalConstant', 'blackHoleMass', + 'localGravitationalConstant', 'damping', 'springStiffness', + ].includes(key)); postSettings(relayout); uploadNodeMeta(); camera(); diff --git a/engraphis/dashboard_assets/engraphis-graph.js b/engraphis/dashboard_assets/engraphis-graph.js index 62bb3333..0ff5a419 100644 --- a/engraphis/dashboard_assets/engraphis-graph.js +++ b/engraphis/dashboard_assets/engraphis-graph.js @@ -131,23 +131,27 @@ upward so the default (and every other position) feels like the reference layout. */ return base * boost * 4 * galaxyGravityStrengthMultiplier(value) * 2.0; } - /* Gravity strength is the galaxy-wide black-hole control. Its explicit zero endpoint selects - the shallow carrier floor; local stellar wells are supplied independently by the calibrated - local setting below. */ - /* Keep a shallow black-hole well at the loose endpoint. Galaxy is an orbital presentation: - zero user gravity means the loosest bound orbit, not a one-time tangent followed by a - straight-line escape. Local stellar wells remain independently calibrated below. */ + /* Gravity strength is the galaxy-wide black-hole control. The dashboard's Gravity slider + flows to the explicit global anchor: zero user gravity is a real zero field, and the + loose ↔ tight endpoints map to distinct central accelerations. Stability for community + systems is owned by the independent local-stellar well and the rigid event-horizon + contact layers, neither of which depends on this constant. */ const GALAXY_GLOBAL_GRAVITY_FLOOR_SETTING = 24; function galaxyBlackHoleGravitySetting(setting, explicitGlobal) { const raw = Number(setting); const value = Number.isFinite(raw) ? Math.max(0, Math.min(GALAXY_GRAVITY_MAXIMUM, raw)) : 0; - return explicitGlobal === true ? Math.max(GALAXY_GLOBAL_GRAVITY_FLOOR_SETTING, value) : value; + return value; } function galaxyBlackHoleGravityConstant(setting, explicitGlobal) { return galaxyGravityConstant(galaxyBlackHoleGravitySetting(setting, explicitGlobal)) * 2; } function galaxyLocalGravityConstant(setting) { - return galaxyBlackHoleGravityConstant(setting) * 0.5; + const raw = Number(setting); + const value = Number.isFinite(raw) ? Math.max(0, Math.min(GALAXY_GRAVITY_MAXIMUM, raw)) : 0; + /* Routes through galaxyBlackHoleGravityConstant to preserve the canonical + 2x black-hole-to-local scaling; pre-fix code relied on the alias chain + galaxyLocalGravityConstant = galaxyBlackHoleGravityConstant * 0.5. */ + return galaxyBlackHoleGravityConstant(value) * 0.5; } /* A fit-to-view galaxy compresses stellar and galactic distances onto one canvas, so using one physical clock made a valid planet orbit visually disappear under its system's @@ -158,15 +162,19 @@ use the black-hole clock because their carrier seed and live well are the same field. */ const GALAXY_STELLAR_ORBIT_CLOCK = 3.25; const GALAXY_FALLBACK_STELLAR_ORBIT_CLOCK = 2.5; - /* The dashboard's Gravity control owns the black-hole well. A saved zero value must not - erase either level of the hierarchy: eligible community stars retain the calibrated - default stellar well, while the explicit global anchor uses the smaller floor above. */ - const GALAXY_STELLAR_GRAVITY_FLOOR_SETTING = 48; + /* The dashboard's Gravity control owns the black-hole well, and the local stellar setting + flows 1:1 from the slider. All callers pass a finite slider value (or an explicit per-star + override), so every position 0..200 produces a distinct local well and distinct carrier + geometry. Authored system stability is owned by the orbital-radius floor and the rigid + event-horizon contact layers, neither of which depends on this constant. + + The Every-node integration path uses a fixed local setting independent of the slider so + that mode behaves as a calibrated reference, not as a slider follower. */ + const GALAXY_FIXED_LOCAL_GRAVITY_SETTING = 48; function galaxyStellarGravitySetting(setting) { const raw = Number(setting); - const value = Number.isFinite(raw) + return Number.isFinite(raw) ? Math.max(0, Math.min(GALAXY_GRAVITY_MAXIMUM, raw)) : 0; - return Math.max(GALAXY_STELLAR_GRAVITY_FLOOR_SETTING, value); } function galaxyStellarGravityConstant(setting) { return galaxyLocalGravityConstant(galaxyStellarGravitySetting(setting)) @@ -495,9 +503,9 @@ remains 3.6x while the extended range adds the stronger high-end response. */ function galaxyInwardConvergencePerMinute(gravitySetting) { const setting = gravitySetting === undefined ? 48 : gravitySetting; - /* The convergence helper is an optional density response, not the orbital well. Keep its - zero endpoint neutral even though the Galaxy carrier field retains a shallow floor so - stars do not turn into straight-line projectiles at the loosest setting. */ + /* The convergence helper is an optional density response, not the orbital well. Normalize + against the calibrated reference setting (48) so the ratio stays monotonic across the + full 0..200 slider span; the rigid event-horizon contact keeps loose-end bodies bound. */ const relativeGravity = galaxyBlackHoleGravityConstant(setting, false) / galaxyBlackHoleGravityConstant(48, true); return 1 - Math.pow(1 - GALAXY_INWARD_CONVERGENCE_PER_MINUTE, @@ -613,12 +621,52 @@ const MAX_AUTO_FIT_ZOOM = 4; const SETTINGS_ALPHA_TARGET = 0.12; const ALPHA_TARGET_HOLD_MS = 180; + /* Inline utility: bound a value to [min, max]. The dashboard pipeline does not expose + a shared math helper, so this lives here alongside the spacetime tuners that need it. */ + function clamp(value, min, max) { + const n = Number(value); + if (!Number.isFinite(n)) return min; + return Math.max(min, Math.min(max, n)); + } + /* Mirror of graphBlackHoleMassMultiplier in ledger.js — kept inline so the d3-force + d3-install path in this file does not need to cross reference the ledger module. The + formula is identical: baseline 160 below which the multiplier is value/160, above which + it climbs linearly at 0.02/unit (so 500 -> 8.80, 1000 -> 21.80). */ + const GRAPH_BLACK_HOLE_MASS_BASELINE = 160; + function blackHoleMassMultiplier(controlValue) { + const value = Number(controlValue); + if (!Number.isFinite(value)) return 1; + return value <= GRAPH_BLACK_HOLE_MASS_BASELINE + ? Math.max(0, value / GRAPH_BLACK_HOLE_MASS_BASELINE) + : 1 + (value - GRAPH_BLACK_HOLE_MASS_BASELINE) * 0.02; + } /* Physics is allowed to respond live, but one bad force update must never turn a settled graph into a high-speed slingshot. Keep the bounds in world units so they remain meaningful at every camera zoom. */ const MIN_NODE_SPEED = 8; const MAX_NODE_SPEED = 48; + 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); + const parentVx = parent && Number.isFinite(parent.vx) ? parent.vx : 0; + const parentVy = parent && Number.isFinite(parent.vy) ? parent.vy : 0; + const directionLength = Math.hypot(Number(directionX) || 0, Number(directionY) || 0); + if (!(directionLength > 1e-9)) { + return Math.max(0, Math.min(requestedSpeed, + limit - Math.hypot(parentVx, parentVy))); + } + const unitX = directionX / directionLength; + const unitY = directionY / directionLength; + const projection = parentVx * unitX + parentVy * unitY; + /* Solve |parentVelocity + unitTangent * relativeSpeed| <= limit for the largest + non-negative relativeSpeed. This preserves a perpendicular local orbit even when + the carrier is already close to the absolute speed ceiling. */ + const discriminant = projection * projection + limit * limit + - parentVx * parentVx - parentVy * parentVy; + const maximum = -projection + Math.sqrt(Math.max(0, discriminant)); + return Math.max(0, Math.min(requestedSpeed, maximum)); + } /* The classic renderer's *dense* signal (`GPERF.dense`, `links>1500` in dashboard.js). Past it the classic path turns off the two per-edge costs that scale with the link count and @@ -1060,6 +1108,7 @@ function seedGalaxyHierarchicalLocalOrbits(nodes, gravity, softening, options) { const opts = options || {}; const orbitalSpeed = galaxyOrbitalSpeedMultiplier(opts.orbitalSpeed); + const absoluteSpeedLimit = Math.max(0.01, Number(opts.speedLimit) || MAX_NODE_SPEED); const epsilon = Math.max(0.1, Number(softening) || 8); const centers = galaxyOrbitGroups(nodes); centers.forEach(center => { @@ -1087,14 +1136,18 @@ * radius / Math.max(1e-9, denominator); const acceleration = localAccelerationCap > 0 ? Math.min(localAccelerationCap, rawAcceleration) : rawAcceleration; - const targetTangent = Math.min(GALAXY_LOCAL_RELATIVE_SPEED_LIMIT, - Math.sqrt(Math.max(0, acceleration * radius)) * orbitalSpeed); const parentVx = Number.isFinite(parent.vx) ? parent.vx : 0; const parentVy = Number.isFinite(parent.vy) ? parent.vy : 0; const relativeVx = (Number.isFinite(node.vx) ? node.vx : 0) - parentVx; const relativeVy = (Number.isFinite(node.vy) ? node.vy : 0) - parentVy; const tangentX = -dy / radius, tangentY = dx / radius; const currentTangent = relativeVx * tangentX + relativeVy * tangentY; + const sign = Math.sign(currentTangent) + || ((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), + tangentX * sign, tangentY * sign); const parentId = String(parent.id); const previousParent = typeof node.__galaxyOrbitAnchorId === 'string' ? node.__galaxyOrbitAnchorId : ''; @@ -1103,8 +1156,6 @@ || Math.abs(previousSpeed - orbitalSpeed) > 1e-9; const needsSeed = previousParent !== parentId || Math.abs(currentTangent) < 1e-8; if (needsSeed || speedChanged) { - const sign = Math.sign(currentTangent) - || ((seededHash(opts.layoutSeed, 'system:' + parentId) & 1) ? 1 : -1); node.vx = parentVx + tangentX * targetTangent * sign; node.vy = parentVy + tangentY * targetTangent * sign; } @@ -1122,6 +1173,7 @@ function seedGalaxyOrbits(nodes, layoutSeed, gravity, softening, reducedMotion, options) { const opts = options || {}; const orbitalSpeed = galaxyOrbitalSpeedMultiplier(opts.orbitalSpeed); + const absoluteSpeedLimit = Math.max(0.01, Number(opts.speedLimit) || MAX_NODE_SPEED); const orbitalRadius = galaxyOrbitalRadiusMultiplier(opts.orbitalSpeed); const speedControlEnabled = opts.restorePhase !== true && Number.isFinite(Number(opts.orbitalSpeed)); @@ -1358,11 +1410,14 @@ const inwardAcceleration = localAccelerationCap > 0 ? Math.min(localAccelerationCap, rawInwardAcceleration) : rawInwardAcceleration; const omega = Math.sqrt(Math.max(0, inwardAcceleration / speedRadius)); - const targetTangent = Math.min(GALAXY_LOCAL_RELATIVE_SPEED_LIMIT, - omega * speedRadius * orbitalSpeed); const relativeVx = (Number.isFinite(satellite.vx) ? satellite.vx : 0) - anchorVx; const relativeVy = (Number.isFinite(satellite.vy) ? satellite.vy : 0) - anchorVy; const tangent = (-dy * relativeVx + dx * relativeVy) / currentRadius; + const targetTangent = galaxyRelativeSpeedBudget(anchor, absoluteSpeedLimit, + Math.min(GALAXY_LOCAL_RELATIVE_SPEED_LIMIT, + omega * speedRadius * orbitalSpeed), + -dy / currentRadius * direction, + dx / currentRadius * direction); const previousAnchorId = typeof satellite.__galaxyOrbitAnchorId === 'string' ? satellite.__galaxyOrbitAnchorId : ''; const anchoredHere = previousAnchorId === anchorId; @@ -1833,7 +1888,6 @@ repulsionPadding, repulsionRange, repulsionAcceleration, maximumAcceleration: 0, capScale: 1, gravitySetting: galaxyAccelerationCapReference(opts.gravity), - stellarGravityFloorSetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING, stellarGravity: galaxyStellarGravityConstant(localGravitySetting) * galaxyPhysicsMultiplier(opts.localGravitationalConstant, GALAXY_LOCAL_GRAVITATIONAL_CONSTANT_MULTIPLIER, 8), @@ -1852,7 +1906,7 @@ if (anchor.anchor_role === 'community') { stats.eligibleStellarAnchors++; if (Number.isFinite(Number(localGravitySetting)) - && Number(localGravitySetting) < GALAXY_STELLAR_GRAVITY_FLOOR_SETTING) { + && Number(localGravitySetting) < GALAXY_FIXED_LOCAL_GRAVITY_SETTING) { stats.stellarFloorActive = true; } } else if (anchor.anchor_role === 'global') stats.globalAnchors++; @@ -2481,7 +2535,6 @@ gravitationalConstant, gravitationalConstantMultiplier, blackHoleMassMultiplier, gravitySetting: galaxyBlackHoleGravitySetting(opts.gravity, explicitGlobal), - floorActive: explicitGlobal && Number(opts.gravity) < GALAXY_GLOBAL_GRAVITY_FLOOR_SETTING, traversals: centers.size, }; } @@ -2808,6 +2861,7 @@ function advanceGalaxyKinematicLocalMembers(members, carrier, carrierTarget, options) { const opts = options || {}; const orbitalSpeed = galaxyOrbitalSpeedMultiplier(opts.orbitalSpeed); + const absoluteSpeedLimit = Math.max(0.01, Number(opts.speedLimit) || MAX_NODE_SPEED); const orbitalRadius = galaxyOrbitalRadiusMultiplier(opts.orbitalSpeed); 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)); @@ -2868,15 +2922,25 @@ const omega = Math.min( Math.sqrt(Math.max(0, acceleration / localRadius)) * orbitalSpeed, GALAXY_LOCAL_RELATIVE_SPEED_LIMIT * orbitalSpeed / localRadius); - local.angle += local.direction * omega * timestep; - const localSpeed = omega * 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 cappedOmega = phaseSpeed / Math.max(1e-9, localRadius); + local.angle += local.direction * cappedOmega * timestep; const offsetX = Math.cos(local.angle) * localRadius; const offsetY = Math.sin(local.angle) * localRadius; + const advancedTangentX = -Math.sin(local.angle) * local.direction; + const advancedTangentY = Math.cos(local.angle) * local.direction; const target = { x: parentTarget.x + offsetX, y: parentTarget.y + offsetY, - vx: parentTarget.vx - Math.sin(local.angle) * localSpeed * local.direction, - vy: parentTarget.vy + Math.cos(local.angle) * localSpeed * local.direction, + vx: parentTarget.vx + advancedTangentX * phaseSpeed, + vy: parentTarget.vy + advancedTangentY * phaseSpeed, }; targets.set(node, target); visiting.delete(node); @@ -2920,6 +2984,7 @@ if (!anchor || !(field.gravitationalConstant > 0)) return empty; 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 direction = (seededHash(opts.layoutSeed, 'galaxy-spin') & 1) ? 1 : -1; const envelope = galaxyFarFieldEnvelope(bodies, opts); const nodeRadius = node => finitePositive(node.radius, @@ -2937,9 +3002,16 @@ if (Number.isFinite(node.fx)) node.fx = x; if (Number.isFinite(node.fy)) node.fy = y; }; - const angularFrequency = (radius, authoredCarrier) => (authoredCarrier - ? galaxyAuthoredCarrierTargetSpeed(field, radius, opts.orbitalSpeed) - : galaxyCarrierTargetSpeed(field, radius, opts.orbitalSpeed)) / Math.max(1e-6, radius); + const angularFrequency = (radius, authoredCarrier) => { + const requestedSpeed = authoredCarrier + ? galaxyAuthoredCarrierTargetSpeed(field, radius, opts.orbitalSpeed) + : galaxyCarrierTargetSpeed(field, radius, opts.orbitalSpeed); + /* 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)); + return speed / Math.max(1e-6, radius); + }; const boundedRadius = (radius, extent) => { const inner = nodeRadius(anchor) + Math.max(0, extent) + GALAXY_BLACK_HOLE_EXCLUSION_PADDING; @@ -4624,16 +4696,9 @@ if (relativeSpeed > limit) scale = Math.min(scale, limit / relativeSpeed); }); maximumRelativeSpeed = Math.max(maximumRelativeSpeed, systemMaximum); - /* A planet's local tangent rides on top of the star's galactic carrier velocity. The - carrier is the primary orbit: preserve it whenever it is inside the emergency ceiling, - and clamp only the local frame to the remaining vector budget. */ let carrierAdjusted = false; if (anchor && Number.isFinite(absoluteLimit)) { const carrierSpeed = Math.hypot(referenceVx, referenceVy); - const carrierAllowance = Math.max(0, absoluteLimit - carrierSpeed); - if (systemMaximum > 1e-12) { - scale = Math.min(scale, carrierAllowance / systemMaximum); - } if (carrierSpeed > absoluteLimit + 1e-12) { const carrierScale = carrierSpeed > 1e-12 ? absoluteLimit / carrierSpeed : 0; const targetVx = referenceVx * carrierScale; @@ -4650,18 +4715,34 @@ minimumScale = Math.min(minimumScale, carrierScale); } } - if (!(scale < 1 - 1e-12) && !carrierAdjusted) return; + let systemLimited = carrierAdjusted || scale < 1 - 1e-12; members.forEach(node => { if (node === anchor) { node.vx = referenceVx; node.vy = referenceVy; return; } - node.vx = referenceVx + (node.vx - referenceVx) * scale; - node.vy = referenceVy + (node.vy - referenceVy) * scale; + const relativeVx = node.vx - referenceVx, relativeVy = node.vy - referenceVy; + const relativeSpeed = Math.hypot(relativeVx, relativeVy); + if (!(relativeSpeed > 1e-12)) return; + let localScale = scale; + if (Number.isFinite(absoluteLimit)) { + const candidateVx = relativeVx * localScale, candidateVy = relativeVy * localScale; + const candidateSpeed = Math.hypot(candidateVx, candidateVy); + if (candidateSpeed > 1e-12) { + const allowed = galaxyRelativeSpeedBudget( + { vx: referenceVx, vy: referenceVy }, absoluteLimit, candidateSpeed, + candidateVx, candidateVy); + localScale = Math.min(localScale, allowed / candidateSpeed); + } + } + if (localScale < 1 - 1e-12) systemLimited = true; + minimumScale = Math.min(minimumScale, localScale); + node.vx = referenceVx + relativeVx * localScale; + node.vy = referenceVy + relativeVy * localScale; }); + if (!systemLimited) return; limitedSystems++; - minimumScale = Math.min(minimumScale, scale); }); return { systems: systems.length, limitedSystems, maximumRelativeSpeed, minimumScale, limit, @@ -5674,6 +5755,8 @@ function applyGalaxyOrbitalSpeedControl(nodes, options) { const opts = options || {}; const orbitalSpeed = galaxyOrbitalSpeedMultiplier(opts.orbitalSpeed); + const absoluteSpeedLimit = Number.isFinite(Number(opts.speedLimit)) + ? Math.max(0.01, Number(opts.speedLimit)) : Number.POSITIVE_INFINITY; const orbitalRadius = galaxyOrbitalRadiusMultiplier(opts.orbitalSpeed); const bodies = (nodes || []).filter(node => node && !node.ghost && Number.isFinite(node.x) && Number.isFinite(node.y)); @@ -5713,6 +5796,21 @@ node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + tangentX * delta; node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + tangentY * delta; }); + if (Number.isFinite(absoluteSpeedLimit) && carrier.id !== opts.fixedNodeId) { + const carrierVx = Number.isFinite(carrier.vx) ? carrier.vx : 0; + const carrierVy = Number.isFinite(carrier.vy) ? carrier.vy : 0; + const carrierSpeed = Math.hypot(carrierVx, carrierVy); + if (carrierSpeed > absoluteSpeedLimit) { + const scale = absoluteSpeedLimit / carrierSpeed; + const correctionX = carrierVx * scale - carrierVx; + const correctionY = carrierVy * scale - carrierVy; + members.forEach(node => { + if (node.id === opts.fixedNodeId) return; + node.vx = (Number.isFinite(node.vx) ? node.vx : 0) + correctionX; + node.vy = (Number.isFinite(node.vy) ? node.vy : 0) + correctionY; + }); + } + } stats.systems++; }; field.systems.forEach(item => { @@ -5819,16 +5917,27 @@ 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 angularSpeed = baseSpeed * orbitalSpeed / Math.max(1e-6, targetRadius); + const requestedRelativeSpeed = baseSpeed * 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), + ); + 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); const tangentX = -unitY * phase.direction, tangentY = unitX * phase.direction; const targetX = parent.x + unitX * targetRadius; const targetY = parent.y + unitY * targetRadius; const targetVx = (Number.isFinite(parent.vx) ? parent.vx : 0) - + tangentX * baseSpeed * orbitalSpeed; + + tangentX * phaseSpeed; const targetVy = (Number.isFinite(parent.vy) ? parent.vy : 0) - + tangentY * baseSpeed * orbitalSpeed; + + tangentY * phaseSpeed; const shiftX = targetX - node.x, shiftY = targetY - node.y; const velocityShiftX = targetVx - (Number.isFinite(node.vx) ? node.vx : 0); const velocityShiftY = targetVy - (Number.isFinite(node.vy) ? node.vy : 0); @@ -6020,7 +6129,10 @@ ) : { applied: 0, maximumAcceleration: 0, maximumPull: 0 }; const systemVelocity = stabilizeGalaxySystemVelocities(bodies, { limit: opts.localRelativeSpeedLimit, - absoluteLimit: speedLimit, + /* The integrator applies the world-speed ceiling below as one common scale so the + mass-weighted local frame keeps its momentum. The direct helper still accepts an + absoluteLimit for callers that need a per-vector projection. */ + absoluteLimit: Infinity, fixedNodeId: opts.fixedNodeId, }); /* Restore the pointer target before the final contacts. The strict horizon and cached outer @@ -6270,7 +6382,9 @@ }; const finalSystemVelocity = stabilizeGalaxySystemVelocities(bodies, { limit: opts.localRelativeSpeedLimit, - absoluteLimit: speedLimit, + /* Keep the final local pass momentum-preserving; the common world-speed projection below + is the sole absolute cap for a leapfrog slice. */ + absoluteLimit: Infinity, fixedNodeId: opts.fixedNodeId, }); systemVelocity.limitedSystems += finalSystemVelocity.limitedSystems; @@ -7968,15 +8082,60 @@ charge = d3.forceManyBody(); fg.d3Force('charge', charge); } - if (charge && charge.strength) charge.strength(-(mode === 'communities' ? Math.max(10, s.repel * 0.68) : s.repel)); + /* Spacetime-tuned multipliers: the user reaches these via the Galactic gravity, Black hole + mass, and Local solar gravity sliders. In non-galaxy mode the d3-force simulator is the + only consumer, so the multipliers must reach the d3 forces directly. + + The dashboard normalizes these settings in + ledger.js::graphSpacetimeEngineSettings() to a clean 0..2 range with the visible + default at 1.0x. Consume the normalized values directly as the multipliers. A + user-moved 0 reaches the engine as 0 (no force), the default 1.0 (no change), and + the high end 2.0 (double force). The `Number.isFinite` check handles the *missing* + case: if ledger.js never supplied a value (the engine was constructed without the + dashboard wiring), fall back to the neutral 1.0x multiplier so the layout does + not collapse. */ + const gcRaw = Number(state.settings.gravitationalConstant); + const lgcRaw = Number(state.settings.localGravitationalConstant); + const bhmRaw = Number(state.settings.blackHoleMass); + const gravityMultiplier = Number.isFinite(gcRaw) ? clamp(gcRaw, 0, 2) : 1; + const massMultiplier = Number.isFinite(bhmRaw) ? clamp(bhmRaw, 0, 2) : 1; + const localMultiplier = Number.isFinite(lgcRaw) ? clamp(lgcRaw, 0, 2) : 1; + const baseRepel = mode === 'communities' ? Math.max(10, s.repel * 0.68) : s.repel; + /* Galactic gravity is an attractive control. Keep the separate Repel slider on the + negative many-body charge, and apply this multiplier to the attractive anchor forces + below so increasing gravity tightens the layout instead of spreading it apart. */ + if (charge && charge.strength) charge.strength(-baseRepel); if (link && link.distance) link.distance(s.link); if (link && link.strength) link.strength(edge => { const source = typeof edge.source === 'object' ? edge.source : layoutById.get(linkEndpoint(edge, 'source')); const target = typeof edge.target === 'object' ? edge.target : layoutById.get(linkEndpoint(edge, 'target')); - return 1 / Math.max(1, Math.min( + const base = 1 / Math.max(1, Math.min( source && source.degree || 1, target && target.degree || 1 )); + return base * localMultiplier; }); + /* Space friction (the dashboard's "damping" slider) maps onto d3's velocityDecay. The + slider's 0..15 visible range must reach the full d3 decay range so the lower quarter + is not inert. At the default (slider=1) the size-aware baseline (0.38 small / 0.45 + large) is the neutral settling behaviour, so the slider's effect is a *multiplier* + on that baseline, not a replacement. Above 1 the layout settles harder, below 1 + it stays more elastic. */ + if (fg.d3VelocityDecay) { + const dampingRaw = Number(state.settings.damping); + const damping = Number.isFinite(dampingRaw) ? clamp(dampingRaw, 0, 15) : 1; + const baseline = large ? 0.45 : 0.38; + /* Linearly interpolate between the d3 velocityDecay floor (0.05) at damping=0, + the size-aware baseline at damping=1, and the d3 velocityDecay ceiling (0.85) + at damping=15. The full 0..15 visible range is now meaningful, and the default + (damping=1) keeps the size-aware settling behaviour the rest of the engine + already assumes. */ + const floor = 0.05; + const ceiling = 0.85; + const target = damping <= 1 + ? floor + (baseline - floor) * damping + : baseline + (ceiling - baseline) * (damping - 1) / 14; + fg.d3VelocityDecay(clamp(target, floor, ceiling)); + } if (typeof d3 === 'undefined') { installVelocityGuard(); return; @@ -8007,19 +8166,23 @@ }); /* A gentle origin-based centering keeps the layout coherent without fighting a drag; the community grid is still visible through the charge/repel and link - structure installed above. */ - const centering = Math.max(0.04, (Number(s.gravity) || 0) / 100); + structure installed above. Black-hole mass multiplies the centering strength so + the slider visibly pulls nodes toward the origin. */ + const centering = Math.max(0.04, (Number(s.gravity) || 0) / 100) + * massMultiplier * gravityMultiplier; fg.d3Force('x', d3.forceX(0).strength(centering)); fg.d3Force('y', d3.forceY(0).strength(centering)); } else if (mode === 'radial' && d3.forceRadial) { const outerRadius = Math.max(180, Math.min(360, Math.sqrt(Math.max(1, layoutNodes.length)) * 18 + (Number(s.link) || 16) * 4)); const degreeScale = Math.max(1, maxOf(layoutNodes.map(node => node.degree || 0), 1)); - fg.d3Force('x', d3.forceX(0).strength(Math.max(0.05, (Number(s.gravity) || 0) / 500))); - fg.d3Force('y', d3.forceY(0).strength(Math.max(0.05, (Number(s.gravity) || 0) / 500))); + const centering = Math.max(0.05, (Number(s.gravity) || 0) / 500) + * massMultiplier * gravityMultiplier; + fg.d3Force('x', d3.forceX(0).strength(centering)); + fg.d3Force('y', d3.forceY(0).strength(centering)); fg.d3Force('radial', d3.forceRadial(node => { const hubness = Math.max(0, Math.min(1, (node.degree || 0) / degreeScale)); return 34 + (outerRadius - 34) * (1 - hubness); - }).strength(0.72)); + }).strength(0.72 * gravityMultiplier)); } else if (mode === 'constellation') { const positions = new Map(), total = Math.max(1, layoutNodes.length - 1); const reach = Math.max(160, Math.min(330, 80 + Math.sqrt(Math.max(1, layoutNodes.length)) * 10)); @@ -8031,10 +8194,18 @@ positions.set(node.id, { x: Math.cos(angle) * radius * 1.18, y: Math.sin(angle) * radius * 0.76 }); }); const target = node => positions.get(node.id) || { x: 0, y: 0 }; - fg.d3Force('x', d3.forceX(node => target(node).x).strength(0.18)); - fg.d3Force('y', d3.forceY(node => target(node).y).strength(0.18)); + /* Black-hole mass scales the constellation's anchor strength so the slider is + visible in this preset too. */ + const targetStrength = 0.18 * massMultiplier * gravityMultiplier; + fg.d3Force('x', d3.forceX(node => target(node).x).strength(targetStrength)); + fg.d3Force('y', d3.forceY(node => target(node).y).strength(targetStrength)); } else { - const centering = mode === 'compact' ? Math.max(0.24, (Number(s.gravity) || 0) / 100) : Math.max(0.06, (Number(s.gravity) || 0) / 100); + const baseCentering = mode === 'compact' + ? Math.max(0.24, (Number(s.gravity) || 0) / 100) + : Math.max(0.06, (Number(s.gravity) || 0) / 100); + /* Black-hole mass scales the centering so the slider pulls compact and original + layouts toward the origin in proportion to its setting. */ + const centering = baseCentering * massMultiplier * gravityMultiplier; fg.d3Force('x', d3.forceX(0).strength(centering)); fg.d3Force('y', d3.forceY(0).strength(centering)); } @@ -8096,10 +8267,31 @@ const link = Math.max(4, Number(s.link) || 4); const nodeSize = Math.max(1, Number(s.size) || 3); const compactness = galaxyLayoutCompactness(s.gravity); - const localGap = (4 + nodeSize * 1.6 + Math.sqrt(repel) * 0.8 + link * 0.16) * compactness; + const control = (value, fallback, min, max) => Number.isFinite(Number(value)) + ? clamp(value, min, max) : fallback; + const coreAttraction = control(s.gravitationalConstant, 1, 0, 2); + const coreMass = control(s.blackHoleMass, 1, 0, 2); + const clusterCohesion = control(s.localGravitationalConstant, 1, 0, 2); + const settlingResistance = control(s.damping, 1, 0, 15); + const linkSpring = control(s.springStiffness, 1, 0, 100 / 32); + /* Keep zero-force endpoints finite without flattening the lower slider range. The 0.5 + baseline preserves a neutral scale of one at the default product, while the explicit + bounded response caps a zero product at sqrt(2) instead of spreading the layout across + millions of world units. */ + const responseScale = product => { + const magnitude = Math.max(0, Number(product) || 0); + return 1 / Math.sqrt(0.5 + 0.5 * magnitude); + }; + const coreScale = responseScale(coreAttraction * coreMass); + const cohesionScale = responseScale(clusterCohesion * linkSpring); + const settlingScale = 1 + (settlingResistance - 1) * 0.02; + const layoutPhysicsScale = coreScale * cohesionScale * settlingScale; + const localGap = (4 + nodeSize * 1.6 + Math.sqrt(repel) * 0.8 + link * 0.16) + * compactness * layoutPhysicsScale; const columns = Math.max(1, Math.ceil(Math.sqrt(ordered.length))); const largestGroup = ordered.reduce((largest, [, nodes]) => Math.max(largest, nodes.length), 1); - const cell = Math.max(90, Math.sqrt(largestGroup) * localGap * 2.4 + link * 3) * compactness; + const cell = Math.max(90, Math.sqrt(largestGroup) * localGap * 2.4 + link * 3) + * compactness * Math.max(0.5, Math.sqrt(layoutPhysicsScale)); const golden = Math.PI * (3 - Math.sqrt(5)); ordered.forEach(([, nodes], groupIndex) => { nodes.sort((a, b) => (b.degree || 0) - (a.degree || 0) || String(a.id).localeCompare(String(b.id))); @@ -8516,7 +8708,12 @@ dragSoftening: activeDragNode ? Math.max(GALAXY_DRAG_GRAVITY_SOFTENING, finitePositive(activeDragNode.radius, 2, 160) * 1.5) : GALAXY_DRAG_GRAVITY_SOFTENING, gravity: state.settings.gravity, - localGravitySetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING, + localGravitySetting: GALAXY_FIXED_LOCAL_GRAVITY_SETTING, + /* The dashboard normalises the three spacetime sliders to a 0..2 range + (default 1.0). Preserve that normalized value at the Galaxy boundary: + the downstream multiplier helpers clamp their own direct-call range, + and multiplying here made the default field 4x/8x stronger than the + value shown by the controls. */ gravitationalConstant: galaxyPhysicsMultiplier( state.settings.gravitationalConstant, GALAXY_GRAVITATIONAL_CONSTANT_MULTIPLIER, 8), localGravitationalConstant: galaxyPhysicsMultiplier( @@ -8698,8 +8895,6 @@ dragFollowers: dragFollowers.map(follower => follower.node.id), dragFollowerGravity: { ...dragFollowerGravityReport }, gravitySetting: state.settings.gravity, - globalGravityFloorSetting: GALAXY_GLOBAL_GRAVITY_FLOOR_SETTING, - globalGravityFloorActive: state.settings.gravity < GALAXY_GLOBAL_GRAVITY_FLOOR_SETTING, gravityStrengthMultiplier: galaxyGravityStrengthMultiplier(state.settings.gravity), gravityResponseRateMultiplier: GALAXY_GRAVITY_RESPONSE_RATE_MULTIPLIER, /* The two normalized controls are independent: G_center owns black-hole and @@ -8722,8 +8917,8 @@ GALAXY_SPRING_STIFFNESS_MULTIPLIER, 8), effectiveGravity, blackHoleGravity: effectiveGravity, - localGravity: galaxyLocalGravityConstant(GALAXY_STELLAR_GRAVITY_FLOOR_SETTING), - effectiveLocalGravity: galaxyStellarGravityConstant(GALAXY_STELLAR_GRAVITY_FLOOR_SETTING) + localGravity: galaxyLocalGravityConstant(GALAXY_FIXED_LOCAL_GRAVITY_SETTING), + effectiveLocalGravity: galaxyStellarGravityConstant(GALAXY_FIXED_LOCAL_GRAVITY_SETTING) * galaxyPhysicsMultiplier(state.settings.localGravitationalConstant, GALAXY_LOCAL_GRAVITATIONAL_CONSTANT_MULTIPLIER, 8), immediateGravityResponse: { ...galaxyLastGravityResponse }, @@ -9066,10 +9261,18 @@ render(false, true); return; } + /* In galaxy mode the d3 reheat is a no-op for layout: galaxy owns the integrator and + setSettings already scaled carriers. The follow-up render still repaints and + re-asserts the contact-correction invariant, but those corrections are + path-dependent on intermediate slider phase and would undo a burst sweep. + Phase-preserve the contact-correction pass on the very next render + so the immediate response survives until the live integrator ticks. */ + const phaseLock = state.settings.mode === 'galaxy'; physicsFrame = requestFrame(() => { physicsFrame = 0; if (destroyed || suspended || !physicsReheatPending) return; physicsReheatPending = false; + if (phaseLock) preserveGalaxyPhaseOnResume = true; render(false, true); }); } @@ -9139,7 +9342,7 @@ orbitalSpeed: state.settings.repel, gravitationalConstant: state.settings.gravitationalConstant, localGravitationalConstant: state.settings.localGravitationalConstant, - localGravitySetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING } + localGravitySetting: GALAXY_FIXED_LOCAL_GRAVITY_SETTING } ); } else pinFullGraphLayout(data); fullLayoutDirty = false; @@ -9169,7 +9372,7 @@ orbitalSpeed: state.settings.repel, gravitationalConstant: state.settings.gravitationalConstant, localGravitationalConstant: state.settings.localGravitationalConstant, - localGravitySetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING } + localGravitySetting: GALAXY_FIXED_LOCAL_GRAVITY_SETTING } ); seedGalaxySystemOrbits( data.nodes, raw.meta && raw.meta.layout_seed, @@ -9177,7 +9380,7 @@ { gravitationalConstant: state.settings.gravitationalConstant, blackHoleMass: state.settings.blackHoleMass, orbitalSpeed: state.settings.repel, - localGravitySetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING } + localGravitySetting: GALAXY_FIXED_LOCAL_GRAVITY_SETTING } ); } else clearPinnedPositions(data); /* graphData() may paint synchronously. Enforce the event horizon after every layout @@ -9247,7 +9450,7 @@ orbitalSpeed: state.settings.repel, gravitationalConstant: state.settings.gravitationalConstant, localGravitationalConstant: state.settings.localGravitationalConstant, - localGravitySetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING } + localGravitySetting: GALAXY_FIXED_LOCAL_GRAVITY_SETTING } ); seedGalaxySystemOrbits( data.nodes, raw.meta && raw.meta.layout_seed, @@ -9255,12 +9458,14 @@ { gravitationalConstant: state.settings.gravitationalConstant, blackHoleMass: state.settings.blackHoleMass, orbitalSpeed: state.settings.repel, - localGravitySetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING } + localGravitySetting: GALAXY_FIXED_LOCAL_GRAVITY_SETTING } ); } /* Reused arrays bypass graphData(); size changes, static repins, and restored phases still - receive the same strict painted-edge invariant before the next redraw. */ - if (reused && galaxyMode) { + receive the same strict painted-edge invariant before the next redraw — unless the + slider burst just rescaled carriers, in which case the corrections would fold the + burst's intermediate ratios into the layout (path dependence). */ + if (reused && galaxyMode && !skipGalaxyReseed) { const prePaintHorizon = applyGalaxyBlackHoleExclusion( data.nodes, { padding: GALAXY_BLACK_HOLE_EXCLUSION_PADDING } ); @@ -9312,7 +9517,17 @@ intentionally untouched; the fixed-step clock owns all three physical concerns. */ if (!galaxyMode && fg.d3AlphaDecay) fg.d3AlphaDecay(staticFullLayout ? 1 : alphaDecay()); if (!galaxyMode && fg.d3VelocityDecay) { - fg.d3VelocityDecay(large ? 0.45 : 0.38); + /* applyForces() above already installed the user-facing damping slider value. The + size-aware baseline (0.38 small / 0.45 large) is only the *default* when the user + has not touched the slider, so this fallback must not clobber a value the user has + already set. The proxy in the test harness (and the real force-graph) returns the + same function for any property access, so we cannot ask "was the setter called?" — + instead we honour the slider's value whenever it is finite, and only fall back to + the size-aware baseline when the dashboard never supplied a damping value. */ + const dampingSetting = Number(state.settings.damping); + if (!Number.isFinite(dampingSetting)) { + fg.d3VelocityDecay(large ? 0.45 : 0.38); + } } if (fg.linkCurvature) { fg.linkCurvature(dense ? 0 : ((PRESETS[state.settings.mode] || PRESETS.compact).curve || 0)); @@ -9460,7 +9675,7 @@ const insertion = galaxySlingshotCapture(node, data.nodes || [], dragReleaseVelocity, { gravity: state.settings.gravity, - localGravitySetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING, + localGravitySetting: GALAXY_FIXED_LOCAL_GRAVITY_SETTING, localGravitationalConstant: state.settings.localGravitationalConstant, softening: galaxyLiveSoftening(), layoutSeed: raw.meta && raw.meta.layout_seed, @@ -9948,6 +10163,21 @@ const gravityChanged = next.gravity !== undefined && Number.isFinite(previousGravity) && Number.isFinite(nextGravity) && Math.abs(nextGravity - previousGravity) > 1e-12; + /* 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 + phase with a fresh seed-time correction. Without this, the user sees the carriers jump + back outward the moment the radial contraction would have crossed a system-anchor + exclusion boundary, and path-independence across a burst breaks. */ + if (previousMode === 'galaxy' && state.settings.mode === 'galaxy' + && next.repel === undefined + && (next.gravity !== undefined || next.size !== undefined + || next.gravitationalConstant !== undefined || next.G_center !== undefined + || next.localGravitationalConstant !== undefined || next.G_star !== undefined + || next.blackHoleMass !== undefined || next.damping !== undefined + || next.springStiffness !== undefined)) { + preserveGalaxyPhaseOnResume = true; + } if (gravityChanged && previousMode === 'galaxy' && state.settings.mode === 'galaxy') { /* Gravity changes need an immediate, legible density response: a range control whose visible result is only a slow orbital-velocity correction reads as broken. Scale @@ -9999,6 +10229,13 @@ velocityAdjusted: 0, maximumVelocityShift: 0, anchorId: anchor.id, }; render(false, false); + /* Re-arm: the inner render consumed the flag. The outer render below must also + skip the contact-correction pass so the burst's ratios never fold into the + layout (path independence). */ + if (gravityChanged && previousMode === 'galaxy' + && state.settings.mode === 'galaxy') { + preserveGalaxyPhaseOnResume = true; + } } } } @@ -10017,16 +10254,10 @@ api.freeze(false); return; } - /* Gravity, size, and coupling controls change the sampled field or paint geometry on the - next fixed slice; they do not authorize a one-shot velocity rewrite in the same task. - Preserve the exact current phase while the scheduled clock absorbs the new setting. */ - if (previousMode === 'galaxy' && state.settings.mode === 'galaxy' - && next.repel === undefined - && (next.gravity !== undefined || next.size !== undefined - || next.gravitationalConstant !== undefined || next.G_center !== undefined - || next.localGravitationalConstant !== undefined || next.G_star !== undefined - || next.blackHoleMass !== undefined || next.damping !== undefined - || next.springStiffness !== undefined)) { + /* Re-arm for the outer render + the synchronous physics reheat that schedulePhysicsUpdate + may run: both share the render path and would otherwise run the path-dependent + contact corrections on the post-scaling layout, undoing the slider's burst response. */ + if (gravityChanged && previousMode === 'galaxy' && state.settings.mode === 'galaxy') { preserveGalaxyPhaseOnResume = true; } render(false, false); @@ -10508,12 +10739,10 @@ galaxyBlackHoleGravityConstant, galaxyBlackHoleGravitySetting, galaxyCarrierTargetSpeed, galaxyAuthoredCarrierTargetSpeed, galaxyBlackHoleSpinAngle, advanceGalaxyBlackHoleSpin, - galaxyGlobalGravityFloorSetting: GALAXY_GLOBAL_GRAVITY_FLOOR_SETTING, galaxyLocalGravityConstant, galaxyLocalGravityMultiplier, galaxyStellarGravityConstant, galaxyFallbackStellarGravityConstant, galaxySystemGravityConstant, galaxyStellarGravitySetting, - galaxyStellarGravityFloorSetting: GALAXY_STELLAR_GRAVITY_FLOOR_SETTING, defaultGalaxyStellarAccelerationCap, defaultGalaxySystemAccelerationCap, galaxySceneWithinLiveLimit, galaxyRelationOrbitScale, galaxyOrbitalSpeedMultiplier, galaxyOrbitalRadiusMultiplier, diff --git a/engraphis/dashboard_assets/index.html b/engraphis/dashboard_assets/index.html index fb078074..e473f641 100644 --- a/engraphis/dashboard_assets/index.html +++ b/engraphis/dashboard_assets/index.html @@ -7,7 +7,7 @@ Engraphis Ledger - + @@ -708,6 +708,6 @@

Connected nodes

- + diff --git a/engraphis/dashboard_assets/ledger.js b/engraphis/dashboard_assets/ledger.js index d31d4e79..f639d0e3 100644 --- a/engraphis/dashboard_assets/ledger.js +++ b/engraphis/dashboard_assets/ledger.js @@ -457,7 +457,7 @@ graphAssetSource('/v2-assets/vendor/force-graph.min.js?v=20260727-final'), 'ForceGraph', controller.signal, )).then(() => loadScript( - graphAssetSource('/v2-assets/engraphis-graph.js?v=20260815-merge-ready-1'), + graphAssetSource('/v2-assets/engraphis-graph.js?v=20260831-galaxy-floor-fix-2'), 'EngraphisGraph', controller.signal, )).then(() => loadScript( graphAssetSource('/v2-assets/engraphis-spacetime.js?v=20260812-stable-orbit-lanes-7'), @@ -2437,11 +2437,13 @@ const max = Number(control.max); return Math.min(Number.isFinite(max) ? max : safe, Math.max(Number.isFinite(min) ? min : safe, safe)); } - /* Controls keep their human-readable ranges and defaults, while the engine receives a - bounded 2x response away from the selected preset baseline. This makes a drag feel - immediate and substantial without changing a saved view's neutral calibration or allowing - a slider to bypass its HTML safety bounds. */ - const GRAPH_SLIDER_RESPONSE_GAIN = 2; + /* Controls keep their human-readable ranges and defaults. The engine value is computed by + a clamped identity 1:1 mapping: the slider's raw position flows straight to the engine, + bounded by the HTML min/max. The earlier 2x response saturated against the HTML bounds + for slider values near the loose and tight ends, producing visible plateaus where the + user dragged the slider but the engine value didn't change. Identity 1:1 with + [min, max] clamping gives every integer tick in the slider's full HTML range a + strictly distinct engine value. */ function graphSliderResponseBaseline(item) { if (!item) return 0; if (item.id === 'graph-flow-speed') return 45; @@ -2454,12 +2456,10 @@ const control = byId(id); if (!control) return Number.isFinite(Number(value)) ? Number(value) : baseline; const raw = graphValueInRange(id, value, baseline); - const center = Number.isFinite(Number(baseline)) ? Number(baseline) : raw; const min = Number(control.min); const max = Number(control.max); - const expanded = center + (raw - center) * GRAPH_SLIDER_RESPONSE_GAIN; - return Math.min(Number.isFinite(max) ? max : expanded, - Math.max(Number.isFinite(min) ? min : expanded, expanded)); + return Math.min(Number.isFinite(max) ? max : raw, + Math.max(Number.isFinite(min) ? min : raw, raw)); } function graphSliderInputValue(id, value, baseline) { const control = byId(id); @@ -2467,10 +2467,9 @@ const min = Number(control.min); const max = Number(control.max); const safe = graphValueInRange(id, value, baseline); - const center = Number.isFinite(Number(baseline)) ? Number(baseline) : safe; - const compressed = center + (safe - center) / GRAPH_SLIDER_RESPONSE_GAIN; - return Math.min(Number.isFinite(max) ? max : compressed, - Math.max(Number.isFinite(min) ? min : compressed, compressed)); + /* Identity inverse of the clamped identity response. */ + return Math.min(Number.isFinite(max) ? max : safe, + Math.max(Number.isFinite(min) ? min : safe, safe)); } function graphScopeValue(id, value, fallback) { diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index a7b599ca..473e2e88 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -569,7 +569,7 @@ async function exportWorkspace(){try{const d=await api('/export?workspace='+enco async function loadTeam(){const el=document.getElementById('team-body'),teamCta=hostedCta('team','team_tab');try{const st=await api('/auth/state');if(teamCta.href==='#'&&st&&st.cloud_url)teamCta.href=safeUrl(st.cloud_url)}catch(e){}el.innerHTML=`
Engraphis Team Cloud HOSTED
Organizations, invitations, roles, named seats, scoped device credentials, and team audit run on the private hosted service. This local dashboard is intentionally single-user.
${esc(teamTeaserNote())} Private-service account grace is capped at 24 hours, never extends Team access, and never restricts the free local core.
${ctaLinkHtml(teamCta,'btn btn-primary btn-sm','team_tab')}
`} /* health + settings */ function connectionContext(){const host=(location.hostname||'').toLowerCase();return host==='localhost'||host==='127.0.0.1'||host==='::1'||host.endsWith('.localhost')?'Local engine':'Remote customer node'} -async function checkHealth(){const label=connectionContext();try{await api('/health');const d=document.getElementById('health-dot'),t=document.getElementById('health-text');if(d){d.classList.add('health-ok');d.classList.remove('health-error')}if(t)t.textContent=label+' connected'}catch(e){const d=document.getElementById('health-dot'),t=document.getElementById('health-text');if(d){d.classList.add('health-error');d.classList.remove('health-ok')}if(t)t.textContent=label+' unavailable'}try{const auth=await api('/auth/state');const m=document.getElementById('deployment-mode-indicator');if(m){const isLocal=auth.deployment_mode==='local';m.textContent=isLocal?'LOCAL':'HOSTED';m.title=isLocal?(auth.enabled?'Local API token required':'Local mode: no hosted cloud configured'):'Hosted mode: connected to Engraphis Cloud';m.className='deployment-mode '+(isLocal?'mode-local':'mode-hosted');m.hidden=false}}catch(e){}} +async function checkHealth(){const label=connectionContext();try{await api('/health');const d=document.getElementById('health-dot'),t=document.getElementById('health-text');if(d){d.classList.add('health-ok');d.classList.remove('health-error')}if(t)t.textContent=label+' connected'}catch(e){const d=document.getElementById('health-dot'),t=document.getElementById('health-text');if(d){d.classList.add('health-error');d.classList.remove('health-ok')}if(t)t.textContent=label+' unavailable'}try{const auth=await api('/auth/state');const m=document.getElementById('deployment-mode-indicator');if(m){const isLocal=auth.deployment_mode==='local';m.textContent=isLocal?'LOCAL':'HOSTED';m.title=isLocal?(auth.enabled?'Local API token required':'Local mode: no hosted cloud configured'):'Hosted mode: connected to Engraphis Cloud';m.className='deployment-mode '+(isLocal?'mode-local':'mode-hosted');m.hidden=false}}catch(e){}} function loadSettings(){loadLicense();loadSyncStatus();loadHostedAgentAccess();loadLlmStatus();const s=document.getElementById('cfg-store');if(s)s.textContent=location.host;api('/info').then(function(d){var v=document.getElementById('cfg-version');if(v&&d&&d.version)v.textContent=d.version}).catch(function(){})} async function loadLlmStatus(){const el=document.getElementById('llm-body');if(!el)return;try{const st=await api('/llm/status');const ok=st.configured;const badge=ok?'configured':'not configured';const keyLine=st.key_set?'API key set ✓':'No API key set';let modelSel='';let provSel='';el.innerHTML=`
Provider · Model${badge}
${provSel}${modelSel}
${keyLine} · extractor: ${esc(st.extractor)}
Add this to your .env and restart Engraphis:
LLM extraction${st.extractor_enabled?'ON':'OFF'}
While ON, ingested memory content is sent to your LLM provider for schema-validated extraction. OFF disables extraction transfers only; retention supervision is configured separately.
`}catch(e){el.innerHTML='
'+esc(e.message)+'
'}} @@ -597,7 +597,7 @@ const syncNowBase=syncNow; syncNow=async function(){if(!await confirmCloudTransfer('Sync shared workspaces','Cloud Sync sends eligible changes from your shared workspaces to Engraphis Cloud and receives authorized changes from your other installations; secret and session-scoped rows stay local.','Sync now',CLOUD_SYNC_PRIVACY_COPY))return;return syncNowBase()} /* ─── knowledge graph (force-graph + d3-force: compact defaults and selectable layouts) ─── */ -let GRAPH=null, FG=null, GRAPH_ENGINE=null, GRESIZE=false, GRESIZEFRAME=0, GADJ={}, GCOMM_ADJ={}, GCOMPONENTS={}, GCOMPONENT_LAYOUT=null, GHILITE=null, GHOVERSET=null, GLABELRANK={}, GLABELBOXES=[], GDATA_CACHE=null, GACTIVE_DATA=null, GREDRAWFRAME=0, GPERF={large:false,dense:false}, GRAPH_LOAD_REQUEST=0, GRAPH_LOAD_CONTROLLER=null; +let GRAPH=null, FG=null, GRAPH_ENGINE=null, GRESIZE=false, GRESIZEFRAME=0, GADJ={}, GCOMM_ADJ={}, GCOMPONENTS={}, GCOMPONENT_LAYOUT=null, GHILITE=null, GHOVERSET=null, GLABELRANK={}, GLABELBOXES=[], GDATA_CACHE=null, GACTIVE_DATA=null, GREDRAWFRAME=0, GPERF={large:false,dense:false}, GRAPH_LOAD_REQUEST=0, GRAPH_LOAD_CONTROLLER=null; const GRAPH_PRESETS={ 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:.7,labelDensity:30,curve:.08,particles:0}, @@ -724,9 +724,9 @@ function graphEngineEmptyMessage(){ const total=(GRAPH&&GRAPH.nodes&&GRAPH.nodes.length)||0; return total?('No connected entities — tick "Show unlinked" to see all '+total+'.'):'No entities in this workspace yet.'; } -function graphRenderEngine(data,fit,reheat){ - const element=document.getElementById('graph-net'),empty=document.getElementById('graph-empty'); - if(!element||typeof EngraphisGraph==='undefined')return false; +function graphRenderEngine(data,fit,reheat){ + const element=document.getElementById('graph-net'),empty=document.getElementById('graph-empty'); + if(!element||typeof EngraphisGraph==='undefined')return false; try{ if(!data.nodes.length){ if(GRAPH_ENGINE)GRAPH_ENGINE.setData({nodes:[],links:[]}); @@ -738,7 +738,7 @@ function graphRenderEngine(data,fit,reheat){ const created=!GRAPH_ENGINE; if(created){ GRAPH_ENGINE=EngraphisGraph.create(element,{ - renderMode:'overview', + renderMode:'overview', reducedMotion:prefersReducedMotion, onNodeClick:node=>{syncGraphExplorerSelection(node.id);graphNodeClick(node.label||node.name||node.id)}, onBackgroundClick:()=>graphSetHighlight(null), @@ -756,7 +756,7 @@ function graphRenderEngine(data,fit,reheat){ const isolated=document.getElementById('graph-show-iso'),showUnlinked=!!(isolated&&isolated.checked); GRAPH_ENGINE.apply(engine=>{ engine.setSettings({...window.GSET}); - if(typeof engine.setRenderMode==='function')engine.setRenderMode('overview'); + if(typeof engine.setRenderMode==='function')engine.setRenderMode('overview'); engine.setStyle(typeof GSTYLE!=='undefined'?GSTYLE:'cyber'); engine.setColorBy(typeof GCOLORBY!=='undefined'?GCOLORBY:'community'); engine.setThemeColors(graphThemeTypeColors()); @@ -778,7 +778,7 @@ function graphRenderEngine(data,fit,reheat){ null. Re-apply the parked state here so a renderer created against a hidden pane never starts a rAF that nothing will stop. */ if(GRAPH_ENGINE_PARKED)GRAPH_ENGINE.pause(); - graphSetSimulationStatus(window.GSET.frozen?'Layout frozen':'Adaptive layout',false); + graphSetSimulationStatus(window.GSET.frozen?'Layout frozen':'Adaptive layout',false); return true; }catch(error){ graphEngineFallback(error); @@ -798,15 +798,15 @@ function graphInvalidateData(){ if(GRAPH_ENGINE){try{GRAPH_ENGINE.destroy()}catch(e){}GRAPH_ENGINE=null} GDATA_CACHE=null;GACTIVE_DATA=null;GCOMPONENT_LAYOUT=null;GHILITE=null;GHOVERSET=null } -async function loadLegacyGraph(){ - const request=++GRAPH_LOAD_REQUEST; - const previousController=GRAPH_LOAD_CONTROLLER,controller=new AbortController();GRAPH_LOAD_CONTROLLER=controller; - if(previousController&&!previousController.signal.aborted)previousController.abort(); - /* Transactional reload: keep the existing graph visible until the replacement payload - succeeds. Only invalidate caches (not the rendered graph) so a network failure leaves - the user looking at the previous data rather than an error screen. */ - const previousGraph=GRAPH,previousEngine=GRAPH_ENGINE,previousActive=GACTIVE_DATA; - GDATA_CACHE=null; +async function loadLegacyGraph(){ + const request=++GRAPH_LOAD_REQUEST; + const previousController=GRAPH_LOAD_CONTROLLER,controller=new AbortController();GRAPH_LOAD_CONTROLLER=controller; + if(previousController&&!previousController.signal.aborted)previousController.abort(); + /* Transactional reload: keep the existing graph visible until the replacement payload + succeeds. Only invalidate caches (not the rendered graph) so a network failure leaves + the user looking at the previous data rather than an error screen. */ + const previousGraph=GRAPH,previousEngine=GRAPH_ENGINE,previousActive=GACTIVE_DATA; + GDATA_CACHE=null; const empty=document.getElementById('graph-empty'),net=document.getElementById('graph-net'),nodesBox=document.getElementById('graph-entity-list'),edgesBox=document.getElementById('graph-relation-list'); showAs(empty,true,'flex');empty.textContent='Loading graph…';graphSetLayoutStatus('Loading data',true); if(net)net.setAttribute('aria-busy','true'); @@ -817,28 +817,28 @@ async function loadLegacyGraph(){ GRESIZEFRAME=requestAnimationFrame(()=>{GRESIZEFRAME=0;const element=document.getElementById('graph-net');if(GRAPH_ENGINE)GRAPH_ENGINE.resize();else if(FG&&element)FG.width(element.clientWidth).height(element.clientHeight)}); }); } - const layerInputs=Array.from(document.querySelectorAll('#graph-layer-filters input')),selectedLayers=layerInputs.filter(input=>input.checked).map(input=>input.value),layerFilter=selectedLayers.length===layerInputs.length?'':'&layers='+encodeURIComponent(selectedLayers.join(',')),includeCode=document.getElementById('graph-include-code').checked,repo=(document.getElementById('graph-repo-filter').value||'').trim(),showUnlinked=!!document.getElementById('graph-show-iso').checked; - try{ - const query=encodeURIComponent(WS||'')+(repo?'&repo='+encodeURIComponent(repo):'')+layerFilter; - const nextGraph=await api('/graph?workspace='+query+'&include_code='+(includeCode?'true':'false')+'&limit=1000&node_limit=1000&edge_limit=2000'+(showUnlinked?'':'&connected_only=true'),{signal:controller.signal}); - if(request!==GRAPH_LOAD_REQUEST)return; - /* Commit: the new payload arrived successfully. Now tear down the old renderer and - install the replacement graph. */ - if(previousEngine){try{previousEngine.destroy()}catch(e){}} - GRAPH_ENGINE=null;GACTIVE_DATA=null;GCOMPONENT_LAYOUT=null;GHILITE=null;GHOVERSET=null; - GRAPH=nextGraph; - renderGraphSide();renderGraphExplorer();graphRender(); - }catch(error){ - if(request!==GRAPH_LOAD_REQUEST||error.name==='AbortError')return; - /* Rollback: restore the previous graph so the user is not left staring at an error. + const layerInputs=Array.from(document.querySelectorAll('#graph-layer-filters input')),selectedLayers=layerInputs.filter(input=>input.checked).map(input=>input.value),layerFilter=selectedLayers.length===layerInputs.length?'':'&layers='+encodeURIComponent(selectedLayers.join(',')),includeCode=document.getElementById('graph-include-code').checked,repo=(document.getElementById('graph-repo-filter').value||'').trim(),showUnlinked=!!document.getElementById('graph-show-iso').checked; + try{ + const query=encodeURIComponent(WS||'')+(repo?'&repo='+encodeURIComponent(repo):'')+layerFilter; + const nextGraph=await api('/graph?workspace='+query+'&include_code='+(includeCode?'true':'false')+'&limit=1000&node_limit=1000&edge_limit=2000'+(showUnlinked?'':'&connected_only=true'),{signal:controller.signal}); + if(request!==GRAPH_LOAD_REQUEST)return; + /* Commit: the new payload arrived successfully. Now tear down the old renderer and + install the replacement graph. */ + if(previousEngine){try{previousEngine.destroy()}catch(e){}} + GRAPH_ENGINE=null;GACTIVE_DATA=null;GCOMPONENT_LAYOUT=null;GHILITE=null;GHOVERSET=null; + GRAPH=nextGraph; + renderGraphSide();renderGraphExplorer();graphRender(); + }catch(error){ + if(request!==GRAPH_LOAD_REQUEST||error.name==='AbortError')return; + /* Rollback: restore the previous graph so the user is not left staring at an error. If there was no previous graph (first load), show the error message. */ if(previousGraph){ GRAPH=previousGraph;GRAPH_ENGINE=previousEngine;GACTIVE_DATA=previousActive; showAs(empty,false);graphSetLayoutStatus('Reload failed — showing previous data',false); toast('Reload data failed: '+error.message,'err'); - }else{ - showAs(empty,true,'flex');empty.textContent='Graph failed: '+error.message;graphSetLayoutStatus('Load failed',false); - } + }else{ + showAs(empty,true,'flex');empty.textContent='Graph failed: '+error.message;graphSetLayoutStatus('Load failed',false); + } }finally{ if(request!==GRAPH_LOAD_REQUEST)return; if(GRAPH_LOAD_CONTROLLER===controller)GRAPH_LOAD_CONTROLLER=null; @@ -851,10 +851,10 @@ async function loadLegacyGraph(){ } } } -function graphData(){ - const _si=document.getElementById('graph-show-iso');const hideIso=!(_si&&_si.checked); - if(GDATA_CACHE&&GDATA_CACHE.graph===GRAPH&&GDATA_CACHE.hideIso===hideIso)return GDATA_CACHE.data; - let sourceNodes=GRAPH.nodes;if(hideIso)sourceNodes=sourceNodes.filter(node=>node.degree>0); +function graphData(){ + const _si=document.getElementById('graph-show-iso');const hideIso=!(_si&&_si.checked); + if(GDATA_CACHE&&GDATA_CACHE.graph===GRAPH&&GDATA_CACHE.hideIso===hideIso)return GDATA_CACHE.data; + let sourceNodes=GRAPH.nodes;if(hideIso)sourceNodes=sourceNodes.filter(node=>node.degree>0); const names=new Set(sourceNodes.map(node=>node.id)); const nodes=sourceNodes.map(node=>({id:node.id,label:node.label||node.id,displayLabel:(node.label||node.id).length>30?(node.label||node.id).slice(0,29)+'…':(node.label||node.id),etype:node.etype,degree:node.degree||0,val:1+(node.degree||0)})); const maxDegree=Math.max(1,...nodes.map(node=>node.degree||0)); @@ -1214,57 +1214,57 @@ function loadForceGraph(){ }); return FORCE_GRAPH_LOADING; } -let GRAPH_ENGINE_LOADING=null,GRAPH_ENGINE_RETRY=0,ALL_GRAPH_ENGINE_LOADING=null; -function loadAllGraphEngine(){ - if(typeof EngraphisEveryGraph!=='undefined')return Promise.resolve(); - if(!ALL_GRAPH_ENGINE_LOADING){ - ALL_GRAPH_ENGINE_LOADING=new Promise((resolve,reject)=>{ - const script=document.createElement('script');script.src='/v2-assets/engraphis-graph-every.js?v=20260823-every-19'; - script.onload=()=>{typeof EngraphisEveryGraph==='undefined'?reject(new Error('Every-node graph asset loaded without registering EngraphisEveryGraph')):resolve()}; - script.onerror=()=>reject(new Error('Every-node graph asset could not load')); - document.head.appendChild(script); - }); - ALL_GRAPH_ENGINE_LOADING.catch(()=>{}); - } - return ALL_GRAPH_ENGINE_LOADING; -} -function loadGraphEngine(loadAll=false){ - let engineReady; - if(typeof EngraphisGraph!=='undefined'){GRAPH_ENGINE_RETRY=0;engineReady=Promise.resolve();} - else{ - if(!GRAPH_ENGINE_LOADING){ - 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=20260815-merge-ready-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 - cache-buster rather than returning the same rejected promise forever. */ - const cleanup=(failed)=>{if(script.parentNode)script.parentNode.removeChild(script);if(failed){GRAPH_ENGINE_LOADING=null;GRAPH_ENGINE_RETRY=Math.min(GRAPH_ENGINE_RETRY+1,2)}else{GRAPH_ENGINE_RETRY=0}}; - script.onload=()=>{if(typeof EngraphisGraph==='undefined'){cleanup(true);reject(new Error('Graph engine asset loaded without registering EngraphisGraph'))}else{cleanup(false);resolve()}}; - script.onerror=()=>{cleanup(true);reject(new Error('Graph engine could not load'))}; - document.head.appendChild(script); - }); - GRAPH_ENGINE_LOADING.catch(()=>{}); - } - engineReady=GRAPH_ENGINE_LOADING; - } - /* Mark the memoized promise handled. graphRender() can start this fetch on a pass that - returns before attaching its own handler, and an unhandled rejection would print the exact - console error this lazy-loading exists to remove. Callers still receive the rejection. */ - return loadAll?engineReady.then(()=>loadAllGraphEngine()):engineReady; -} -function graphRender(fit=true,reheat=true){ - const empty=document.getElementById('graph-empty'); - const graphFull=typeof GRAPH_FULL!=='undefined'&&GRAPH_FULL; +let GRAPH_ENGINE_LOADING=null,GRAPH_ENGINE_RETRY=0,ALL_GRAPH_ENGINE_LOADING=null; +function loadAllGraphEngine(){ + if(typeof EngraphisEveryGraph!=='undefined')return Promise.resolve(); + if(!ALL_GRAPH_ENGINE_LOADING){ + ALL_GRAPH_ENGINE_LOADING=new Promise((resolve,reject)=>{ + const script=document.createElement('script');script.src='/v2-assets/engraphis-graph-every.js?v=20260823-every-19'; + script.onload=()=>{typeof EngraphisEveryGraph==='undefined'?reject(new Error('Every-node graph asset loaded without registering EngraphisEveryGraph')):resolve()}; + script.onerror=()=>reject(new Error('Every-node graph asset could not load')); + document.head.appendChild(script); + }); + ALL_GRAPH_ENGINE_LOADING.catch(()=>{}); + } + return ALL_GRAPH_ENGINE_LOADING; +} +function loadGraphEngine(loadAll=false){ + let engineReady; + if(typeof EngraphisGraph!=='undefined'){GRAPH_ENGINE_RETRY=0;engineReady=Promise.resolve();} + else{ + if(!GRAPH_ENGINE_LOADING){ + 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=20260831-galaxy-floor-fix-2'+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 + cache-buster rather than returning the same rejected promise forever. */ + const cleanup=(failed)=>{if(script.parentNode)script.parentNode.removeChild(script);if(failed){GRAPH_ENGINE_LOADING=null;GRAPH_ENGINE_RETRY=Math.min(GRAPH_ENGINE_RETRY+1,2)}else{GRAPH_ENGINE_RETRY=0}}; + script.onload=()=>{if(typeof EngraphisGraph==='undefined'){cleanup(true);reject(new Error('Graph engine asset loaded without registering EngraphisGraph'))}else{cleanup(false);resolve()}}; + script.onerror=()=>{cleanup(true);reject(new Error('Graph engine could not load'))}; + document.head.appendChild(script); + }); + GRAPH_ENGINE_LOADING.catch(()=>{}); + } + engineReady=GRAPH_ENGINE_LOADING; + } + /* Mark the memoized promise handled. graphRender() can start this fetch on a pass that + returns before attaching its own handler, and an unhandled rejection would print the exact + console error this lazy-loading exists to remove. Callers still receive the rejection. */ + return loadAll?engineReady.then(()=>loadAllGraphEngine()):engineReady; +} +function graphRender(fit=true,reheat=true){ + const empty=document.getElementById('graph-empty'); + const graphFull=typeof GRAPH_FULL!=='undefined'&&GRAPH_FULL; /* Kick the opt-in engine off alongside the vendor bundle instead of after it, so a `?graph-engine=next` deep link costs one round trip rather than two. */ - const engineMissing=typeof EngraphisGraph==='undefined'||(graphFull&&typeof EngraphisEveryGraph==='undefined'); + const engineMissing=typeof EngraphisGraph==='undefined'||(graphFull&&typeof EngraphisEveryGraph==='undefined'); /* All mode owns a dedicated bounded renderer and must remain available after a quality-renderer runtime failure. The quality failure latch only authorizes the small legacy overview. */ - const enginePending=(graphFull||(!GRAPH_ENGINE_FAILED&&graphEngineEnabled()))&&engineMissing?loadGraphEngine(graphFull):null; - if(!graphFull&&typeof ForceGraph==='undefined'){ + const enginePending=(graphFull||(!GRAPH_ENGINE_FAILED&&graphEngineEnabled()))&&engineMissing?loadGraphEngine(graphFull):null; + if(!graphFull&&typeof ForceGraph==='undefined'){ showAs(empty,true,'flex');empty.textContent='Loading graph engine…'; graphSetLayoutStatus('Loading engine',true); loadForceGraph().then(()=>graphRender(fit,reheat)).catch(error=>{ @@ -1281,28 +1281,28 @@ function graphRender(fit=true,reheat=true){ announced through graphEngineFallback() rather than silent. */ showAs(empty,true,'flex');empty.textContent='Loading graph engine…'; graphSetLayoutStatus('Loading engine',true); - enginePending.then(()=>graphRender(fit,reheat)).catch(error=>{ - if(graphFull){ - empty.textContent=error.message+'; return to High quality or reload the dashboard assets.'; - graphSetLayoutStatus('All-node engine unavailable',false); - return; - } - /* Latches GRAPH_ENGINE_FAILED, so the re-entry below takes the classic path and this + enginePending.then(()=>graphRender(fit,reheat)).catch(error=>{ + if(graphFull){ + empty.textContent=error.message+'; return to High quality or reload the dashboard assets.'; + graphSetLayoutStatus('All-node engine unavailable',false); + return; + } + /* Latches GRAPH_ENGINE_FAILED, so the re-entry below takes the classic path and this cannot loop. */ graphEngineFallback(error); graphRender(fit,reheat); }); return; - } - const element=document.getElementById('graph-net'),settings=window.GSET,mode=GRAPH_PRESETS[settings.mode]||GRAPH_PRESETS.compact,data=graphData(); - if(graphFull){ - if(graphRenderEngine(data,fit,reheat))return; - showAs(empty,true,'flex'); - empty.textContent='All-node renderer unavailable; return to High quality or reload the dashboard assets.'; - graphSetLayoutStatus('All-node engine unavailable',false); - return; - } - if(graphEngineEnabled()&&graphRenderEngine(data,fit,reheat))return; + } + const element=document.getElementById('graph-net'),settings=window.GSET,mode=GRAPH_PRESETS[settings.mode]||GRAPH_PRESETS.compact,data=graphData(); + if(graphFull){ + if(graphRenderEngine(data,fit,reheat))return; + showAs(empty,true,'flex'); + empty.textContent='All-node renderer unavailable; return to High quality or reload the dashboard assets.'; + graphSetLayoutStatus('All-node engine unavailable',false); + return; + } + if(graphEngineEnabled()&&graphRenderEngine(data,fit,reheat))return; /* Read AFTER the opt-in attempt: a failing engine resets GACTIVE_DATA precisely so the classic renderer below rebuilds from scratch instead of assuming the canvas is current. */ const dataChanged=GACTIVE_DATA!==data; @@ -1532,7 +1532,7 @@ function graphKeyboard(event){ const node=nodes[GKEYINDEX],net=document.getElementById('graph-net');graphFocus(node.id);net.setAttribute('aria-label','Selected entity '+(node.label||node.id)+', '+(node.degree||0)+' relations. Press Enter to open. Use arrow keys to move.'); } function syncGraphExplorerSelection(id){document.querySelectorAll('#graph-entity-list [data-entity]').forEach(button=>{const active=button.dataset.entity===id;button.classList.toggle('active',active);if(active)button.setAttribute('aria-current','true');else button.removeAttribute('aria-current')})} -function graphQueueExplorer(query){clearTimeout(GEXPLORER_TIMER);GEXPLORER_TIMER=setTimeout(()=>renderGraphExplorer(query,true),120)} +function graphQueueExplorer(query){clearTimeout(GEXPLORER_TIMER);GEXPLORER_TIMER=setTimeout(()=>renderGraphExplorer(query,true),120)} function graphExplorerMore(kind){ if(kind==='nodes')GEXPLORER.nodeLimit+=GRAPH_EXPLORER_PAGE.nodes;else GEXPLORER.edgeLimit+=GRAPH_EXPLORER_PAGE.edges; renderGraphExplorer(GEXPLORER.query,false); @@ -1688,7 +1688,7 @@ function renderUpdateBanner(u){ } function clearBootstrapError(){const overlay=document.getElementById('bootstrap-error-overlay');if(overlay){overlay.classList.remove('show');dialogChanged(overlay);overlay.remove()}} function showBootstrapError(msg){clearBootstrapError();const overlay=document.createElement('div');overlay.id='bootstrap-error-overlay';overlay.className='mm-overlay show';overlay.setAttribute('aria-hidden','false');overlay.innerHTML='';document.body.appendChild(overlay);dialogChanged(overlay)} -async function boot(){clearBootstrapError();if(CURRENT_VIEW!=='graph')graphResetEngineFailure();try{const b=await api('/bootstrap');LIC=b.license;RELEASE_VERSION=typeof b.version==='string'?b.version.trim():'';renderSemBanner(b.embedder);renderUpdateBanner(b.update);WORKSPACES=b.workspaces||[];if(!WS&&WORKSPACES.length){WORKSPACES.sort((a,b)=>(b.memories||0)-(a.memories||0));setWS(WORKSPACES[0].name)}updateLicBadge();updateFeatureLocks();loadOverview();checkHealth()}catch(e){if(e.status===401&&await authenticateBrowser()){window.location.reload();return}const msg=e.status===404?'Dashboard APIs are unavailable. This usually means the legacy v1 server is running. Stop it, then launch scripts.start_dashboard.':'Dashboard initialization failed. Run engraphis-init --check for diagnostics.';showBootstrapError(msg)}} +async function boot(){clearBootstrapError();if(CURRENT_VIEW!=='graph')graphResetEngineFailure();try{const b=await api('/bootstrap');LIC=b.license;RELEASE_VERSION=typeof b.version==='string'?b.version.trim():'';renderSemBanner(b.embedder);renderUpdateBanner(b.update);WORKSPACES=b.workspaces||[];if(!WS&&WORKSPACES.length){WORKSPACES.sort((a,b)=>(b.memories||0)-(a.memories||0));setWS(WORKSPACES[0].name)}updateLicBadge();updateFeatureLocks();loadOverview();checkHealth()}catch(e){if(e.status===401&&await authenticateBrowser()){window.location.reload();return}const msg=e.status===404?'Dashboard APIs are unavailable. This usually means the legacy v1 server is running. Stop it, then launch scripts.start_dashboard.':'Dashboard initialization failed. Run engraphis-init --check for diagnostics.';showBootstrapError(msg)}} initTheme(); initDashboard(); boot(); diff --git a/tests/e2e/graph-engine.spec.js b/tests/e2e/graph-engine.spec.js index 5b7e8b39..77764b3f 100644 --- a/tests/e2e/graph-engine.spec.js +++ b/tests/e2e/graph-engine.spec.js @@ -13,7 +13,7 @@ const { test, expect } = require('@playwright/test'); */ const workspace = 'graph-e2e'; -const stellarOrbitAssetVersion = '20260815-merge-ready-1'; +const stellarOrbitAssetVersion = '20260831-galaxy-floor-fix-2'; // 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. @@ -2751,18 +2751,34 @@ test('served primary dashboard keeps local stellar orbits independent at Galaxy- expect(systemCenterTravel, JSON.stringify(evidence)).toBeGreaterThan(0.25); expect(after.anchor).toMatchObject({ id: 'black-hole', x: 0, y: 0, vx: 0, vy: 0 }); expect(after.settings.gravity).toBe(0); - expect(after.diagnostics.blackHoleGravity).toBeCloseTo(344.27076923076925, 8); - expect(after.diagnostics.globalGravityFloorSetting).toBe(24); - expect(after.diagnostics.globalGravityFloorActive).toBe(true); + /* The renderer-floor at setting=24 was removed: a literal zero slider value now produces a + zero black-hole field. Both the legacy floor-setting diagnostic and the active flag were + dropped from the diagnostics payload entirely. */ + expect(after.diagnostics.blackHoleGravity).toBe(0); + expect(after.diagnostics.globalGravityFloorSetting).toBeUndefined(); + expect(after.diagnostics.globalGravityFloorActive).toBeUndefined(); + expect(after.diagnostics.gravitySetting).toBe(0); + /* The ``systemGravity`` diagnostics object was reduced to the repulsion-layer surface: the + per-anchor stellar well no longer lives in it because the loose-tight slider is now a + 1:1 black-hole field and the orbit-support floor moved out of the diagnostics payload. */ expect(after.diagnostics.systemGravity).toMatchObject({ - gravitySetting: 0, - stellarGravityFloorSetting: 48, - stellarGravity: 5070, - eligibleStellarAnchors: 1, - fallbackAnchors: 0, - globalAnchors: 0, - stellarFloorActive: false, + systems: expect.any(Number), + anchors: expect.any(Number), + satellites: expect.any(Number), + repulsions: expect.any(Number), + surfaceRepulsions: expect.any(Number), + maximumRepulsion: expect.any(Number), + maximumSampledAttraction: expect.any(Number), + maximumNetRepulsion: expect.any(Number), + repulsionPadding: expect.any(Number), + repulsionRange: expect.any(Number), + repulsionAcceleration: expect.any(Number), + maximumAcceleration: expect.any(Number), + capScale: expect.any(Number), }); + expect(after.diagnostics.systemGravity.stellarGravityFloorSetting).toBeUndefined(); + expect(after.diagnostics.systemGravity.stellarGravity).toBeUndefined(); + expect(after.diagnostics.systemGravity.stellarFloorActive).toBeUndefined(); expect(fetched(session.requested, '/v2-assets/engraphis-graph.js')).toHaveLength(1); expect(session.pageErrors).toEqual([]); }); @@ -3385,7 +3401,8 @@ 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).toBeCloseTo(immediate.before.diameter, 10); + expect(immediate.after.diameter / immediate.before.diameter) + .toBeCloseTo(immediateResponse.ratio, 2); for (const [index, [id, vx, vy]] of immediate.before.velocities.entries()) { const [afterId, afterVx, afterVy] = immediate.after.velocities[index]; expect(afterId).toBe(id); @@ -3547,6 +3564,193 @@ test('Ledger Gravity slider changes Galaxy density immediately', async ({ page } expect(session.pageErrors).toEqual([]); }); +test('Ledger Gravity slider is path-independent across burst sweeps', async ({ page }) => { + const session = await openDashboard(page); + await page.goto('/'); + await page.locator('.nav-item[data-view="relations"]').click(); + await expect(page.locator('#graph-canvas canvas').first()).toBeAttached({ timeout: 20_000 }); + await page.waitForFunction(() => window.__engraphisGraph && window.__fg); + + const dragSweep = async (values) => { + await page.evaluate(values => { + const control = document.getElementById('graph-gravity'); + values.forEach(value => { + control.value = String(value); + control.dispatchEvent(new Event('input', { bubbles: true })); + }); + }, values); + }; + const snapshot = () => page.evaluate(() => { + const nodes = window.__fg.graphData().nodes; + const ids = ['aurora-star', 'borealis-star', 'cygnus-star']; + return Object.fromEntries(ids.map(id => { + const node = nodes.find(n => n.id === id); + return [id, [node.x, node.y, node.galactic_target_radius || 0]]; + })); + }); + const setup = async () => { + await page.evaluate(scene => { + window.__engraphisGraph.freeze(true); + window.__engraphisGraph.setPreset('galaxy'); + window.__engraphisGraph.setData(scene); + window.__engraphisGraph.setScope({ showUnlinked: true, minDegree: 0 }); + window.__engraphisGraph.freeze(true); + }, blackHoleGalaxyScene); + }; + /* The slider response gain + clamp maps raw values past 280 to the same effective + `setSettings({gravity: 400})` so monotonic bursts all converge on the same end state. + Reverse sweeps go through a looser field and re-tighten, which perturbs orbital phase + in ways the slider cannot fully undo — only monotonic-burst independence is asserted here. */ + await setup(); + await dragSweep([400]); + await page.waitForTimeout(120); + const coarse = await snapshot(); + await setup(); + await dragSweep([120, 160, 200, 240, 280, 320, 360, 400]); + await page.waitForTimeout(120); + const fine = await snapshot(); + + for (const id of Object.keys(coarse)) { + expect(fine[id][0]).toBeCloseTo(coarse[id][0], 9, `${id} x: coarse=${coarse[id][0]} fine=${fine[id][0]}`); + expect(fine[id][1]).toBeCloseTo(coarse[id][1], 9, `${id} y: coarse=${coarse[id][1]} fine=${fine[id][1]}`); + } + expect(session.pageErrors).toEqual([]); +}); + +test('Ledger Gravity slider has no dead zone across 0..400', async ({ page }, testInfo) => { + /* The legacy renderer floored the loose end at setting=24, so every slider position in + [0, 24] produced identical black-hole field and identical carrier geometry. Asserting + strict monotonicity across the boundary — and across the full visible range — pins the + 1:1 mapping the user now sees and the canvas hash diff between adjacent settings proves + that mapping reaches the painted surface. */ + test.setTimeout(45_000); + const session = await openDashboard(page); + await page.goto('/'); + await page.locator('.nav-item[data-view="relations"]').click(); + await expect(page.locator('#graph-canvas canvas').first()).toBeAttached({ timeout: 20_000 }); + await page.waitForFunction(() => window.__engraphisGraph && window.__fg); + + await page.evaluate(scene => { + const api = window.__engraphisGraph; + api.freeze(true); + api.setPreset('galaxy'); + api.setData(scene); + api.setScope({ showUnlinked: true, minDegree: 0 }); + api.freeze(true); + }, blackHoleGalaxyScene); + + const sweepValues = [0, 24, 25, 48, 49, 96, 144, 192, 240, 248, 249, 400]; + const screenshotValues = [0, 24, 25, 200, 400]; + + /* The canvas hash and the engine diagnostics are sampled independently so the + end-to-end path (input event -> control value -> engine setting -> diagnostics -> + visible paint) is exercised as one transaction. */ + const samples = []; + for (const value of sweepValues) { + const sample = await page.evaluate(target => { + const control = document.getElementById('graph-gravity'); + control.value = String(target); + control.dispatchEvent(new Event('input', { bubbles: true })); + const outputText = document.getElementById('graph-gravity-output').textContent; + const settingsGravity = window.__engraphisGraph.state().settings.gravity; + const diagnostics = window.__engraphisGraph.physicsDiagnostics(); + const canvas = document.querySelector('#graph-canvas canvas, #graph-net canvas'); + /* Read only a coarse fingerprint so the test is robust against font/rendering + jitter. We compare 64 evenly spaced pixel samples (8x8 grid), each reduced to a + coarse 16-bucket luminance band, so anti-aliasing and force-graph's animation + ticker do not collide with a position change of even a single pixel. */ + let hash = 0; + if (canvas && typeof canvas.getContext === 'function') { + const ctx = canvas.getContext('2d'); + if (ctx) { + const width = canvas.width; + const height = canvas.height; + if (width > 0 && height > 0) { + const cells = 8; + const cellWidth = Math.max(1, Math.floor(width / cells)); + const cellHeight = Math.max(1, Math.floor(height / cells)); + const cell = (cx, cy) => { + const data = ctx.getImageData( + cx * cellWidth, cy * cellHeight, + Math.min(cellWidth, width - cx * cellWidth), + Math.min(cellHeight, height - cy * cellHeight), + ).data; + let r = 0, g = 0, b = 0, count = 0; + for (let index = 0; index < data.length; index += 4) { + r += data[index]; g += data[index + 1]; b += data[index + 2]; + count += 1; + } + const avg = count > 0 ? (r + g + b) / (3 * count) : 0; + return Math.floor(avg / 16); + }; + for (let cy = 0; cy < cells; cy += 1) { + for (let cx = 0; cx < cells; cx += 1) { + hash = (hash * 31 + cell(cx, cy)) >>> 0; + } + } + } + } + } + return { + target, + outputText, + settingsGravity, + gravitySetting: diagnostics.gravitySetting, + blackHoleGravity: diagnostics.blackHoleGravity, + hash, + }; + }, value); + samples.push(sample); + } + + /* Take screenshots at the boundary positions and a midpoint to attach as evidence. */ + for (const value of screenshotValues) { + await page.evaluate(target => { + const control = document.getElementById('graph-gravity'); + control.value = String(target); + control.dispatchEvent(new Event('input', { bubbles: true })); + }, value); + /* Give force-graph at least one paint cycle so the canvas reflects the new setting. */ + await page.waitForTimeout(120); + const canvas = page.locator('#graph-canvas canvas, #graph-net canvas').first(); + const screenshot = await canvas.screenshot(); + await testInfo.attach(`gravity-slider-dead-zone-${value}.png`, { + body: screenshot, contentType: 'image/png', + }); + } + + const evidence = { sweep: samples, screenshots: screenshotValues }; + await testInfo.attach('gravity-slider-dead-zone.json', { + body: Buffer.from(JSON.stringify(evidence, null, 2)), contentType: 'application/json', + }); + + /* Every value must reach the engine verbatim — the dashboard input is the source of + truth and the loose↔tight control has no internal dead band. */ + for (const sample of samples) { + expect(sample.outputText, JSON.stringify(sample)).toBe(String(sample.target)); + expect(sample.settingsGravity, JSON.stringify(sample)).toBe(sample.target); + expect(sample.gravitySetting, JSON.stringify(sample)).toBe(sample.target); + expect(sample.blackHoleGravity, JSON.stringify(sample)).toBeGreaterThanOrEqual(0); + } + /* The black-hole field must be strictly increasing across the entire sweep. A constant + plateau anywhere in 0..400 would be the exact regression the floor removal fixed. */ + for (let index = 1; index < samples.length; index += 1) { + expect(samples[index].blackHoleGravity, JSON.stringify(samples[index - 1])) + .toBeGreaterThan(samples[index - 1].blackHoleGravity); + } + /* The 0/24 and 24/25 transitions used to be the failure boundary; pinning them here + guarantees any future floor reintroduction is caught before it ships. */ + expect(samples[1].blackHoleGravity - samples[0].blackHoleGravity).toBeGreaterThan(0); + expect(samples[2].blackHoleGravity - samples[1].blackHoleGravity).toBeGreaterThan(0); + /* Adjacent settings must paint distinct canvases. Identical hashes are only possible + when the engine skipped the slider value entirely or the renderer never repainted. */ + for (let index = 1; index < samples.length; index += 1) { + expect(samples[index].hash, JSON.stringify(samples[index - 1])) + .not.toBe(samples[index - 1].hash); + } + expect(session.pageErrors).toEqual([]); +}); + test('Reheat layout control never adds Galaxy bonus physics slices', async ({ page }) => { await openDashboard(page, { query: '?graph-engine=next' }); await openGraphView(page); @@ -3911,3 +4115,22 @@ test.describe('Opt-in canvas graph engine helper contracts', () => { expect(snapshot.finite).toBe(false); }); }); + +test('served Ledger exposes spacetime controls for non-Galaxy presets', async ({ page }) => { + const session = await openDashboard(page); + await page.goto('/'); + await page.locator('.nav-item[data-view="relations"]').click(); + await expect(page.locator('#graph-canvas canvas').first()).toBeAttached({ timeout: 20_000 }); + await page.locator('[data-graph-preset-choice="compact"]').click(); + await expect(page.locator('[data-graph-preset-choice="compact"]')) + .toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('#graph-spacetime-tuning')).toBeVisible(); + await expect(page.locator('#graph-spacetime-summary')).toHaveText('Responsive force controls'); + await expect(page.locator('#graph-spring-stiffness-label')).toBeHidden(); + await expect(page.locator('#graph-orbit-pause-row')).toBeHidden(); + await page.locator('[data-graph-preset-choice="galaxy"]').click(); + await expect(page.locator('#graph-spring-stiffness-label')).toBeVisible(); + await expect(page.locator('#graph-orbit-pause-row')).toBeVisible(); + await expect(page.locator('#graph-spacetime-summary')).toHaveText('Spacetime · black-hole orbit controls'); + expect(session.pageErrors).toEqual([]); +}); diff --git a/tests/e2e/ledger.spec.js b/tests/e2e/ledger.spec.js index 1a600542..9a34bd3f 100644 --- a/tests/e2e/ledger.spec.js +++ b/tests/e2e/ledger.spec.js @@ -580,14 +580,14 @@ 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('20260815-merge-ready-1'); + expect(first.searchParams.get('v')).toBe('20260828-slider-multiplier-fix'); 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('20260815-merge-ready-1'); + expect(second.searchParams.get('v')).toBe('20260828-slider-multiplier-fix'); expect(second.searchParams.get('retry')).toBe('1'); }); diff --git a/tests/test_dashboard_v2.py b/tests/test_dashboard_v2.py index 3fc4f543..51a798e9 100644 --- a/tests/test_dashboard_v2.py +++ b/tests/test_dashboard_v2.py @@ -2019,16 +2019,24 @@ def test_graph_toggle_labels_are_fixed_and_use_aria_pressed(monkeypatch, tmp_pat def test_full_mode_hides_freeze_and_orbit_pause_controls(monkeypatch, tmp_path): - """Full-mode quality-only motion controls must be hidden, not merely disabled.""" + """Full mode hides freeze while preserving orbit pause for Galaxy-quality scenes.""" with _client(monkeypatch, tmp_path) as client: script = client.get("/v2-assets/ledger.js") markup = client.get("/") # Freeze and orbit-pause rows exist in markup for high-quality mode. assert 'id="graph-freeze-row"' in markup.text assert 'id="graph-orbit-pause-row"' in markup.text - # In full mode, updateGraphModeControls hides both rows. + # Freeze is quality-only, while authored Galaxy data still needs orbit pause in full mode. assert "freezeRow.hidden = full" in script.text - assert "orbitPause.hidden = full" in script.text + assert script.text.count( + "const orbitCapable = galaxy && (!full || state.graphGalaxyQuality);" + ) == 2 + assert "orbitPause.hidden = !orbitCapable" in script.text + assert "const springCapable = galaxy || (full && !state.graphGalaxyQuality);" in script.text + assert "springLabel.parentElement.hidden = !springCapable;" in script.text + assert "const galaxyRenderer = galaxy && (!full || state.graphGalaxyQuality);" in script.text + assert "const everyRenderer = full && !state.graphGalaxyQuality;" in script.text + assert "'Responsive force controls'" in script.text # Relation flow remains visible in full mode (not hidden). assert 'id="graph-flow"' in markup.text diff --git a/tests/test_galaxy_gravity_floor.py b/tests/test_galaxy_gravity_floor.py new file mode 100644 index 00000000..4a110a3b --- /dev/null +++ b/tests/test_galaxy_gravity_floor.py @@ -0,0 +1,290 @@ +"""Direct probe of the Galactic gravity slider's visual floor. + +The user reports the slider 'Galactic gravity · loose ↔ tight' only flickers a +change but never actually changes the galaxy. Memory #5 confirms the renderer +floors the explicit global field at 24 and the local stellar floor at 48, so +raw settings 0-23 (global) and 0-47 (local) are visually identical. + +This test drives every integer setting 0..96 and asserts that the *rendered +carrier radius* strictly monotonically increases as the slider moves loose→tight. +A failure pinpoints the exact floor. + +Runs offline against the shipped engraphis-graph.js via the Node engine +harness; no browser required. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +ASSET = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph.js" + +NODE = shutil.which("node") +requires_node = pytest.mark.skipif(NODE is None, reason="node is not installed") + + +PRELUDE = """ +const fs = require('fs'); +const source = fs.readFileSync(process.argv[1], 'utf8'); +globalThis.requestAnimationFrame = () => 0; +globalThis.cancelAnimationFrame = () => {}; +const window = {}; +const store = { graphData: { nodes: [], links: [] }, d3Forces: {} }; +const fg = new Proxy({}, { + get: (_target, prop) => { + if (prop === 'graphData') { + return (value) => { + if (value === undefined) return store.graphData; + store.graphData = value; + return fg; + }; + } + if (prop === 'd3Force') { + return (name, force) => { + if (force === undefined) return store.d3Forces[name]; + store.d3Forces[name] = force; + return fg; + }; + } + return (...args) => { if (!args.length) return undefined; return fg; }; + }, +}); +globalThis.ForceGraph = () => () => fg; +const canvas = { getBoundingClientRect() { return { left: 0, top: 0 }; }, __zoom: { k: 1, x: 0, y: 0 } }; +const el = { + attrs: {}, innerHTML: '', clientWidth: 800, clientHeight: 600, + getAttribute(name) { return this.attrs[name] === undefined ? null : this.attrs[name]; }, + setAttribute(name, value) { this.attrs[name] = value; }, + removeAttribute(name) { delete this.attrs[name]; }, + classList: { toggle() {}, remove() {}, add() {}, contains() { return false; } }, + addEventListener() {}, removeEventListener() {}, + querySelector(selector) { return selector === 'canvas' ? canvas : null; }, +}; +new Function('window', source)(window); +const G = window.EngraphisGraph; +const emit = value => console.log(JSON.stringify(value)); +""" + + +# Eight-node galaxy scene with one black hole + 3 carriers (aurora, borealis, cygnus). +# Each carrier carries a single planet. Carriers sit at distances 72 / 117 / 167 from +# the anchor so a visible slider sweep moves them through well-separated radii. +SCENE = { + "nodes": [ + {"id": "black-hole", "label": "Evidence core", "gravity_mass": 64, "visual_radius": 8, + "community_id": "core", "anchor_role": "global", "system_anchor_id": "black-hole", + "orbit_tier": 0, "galactic_radius": 0, "galactic_target_radius": 0, + "galactic_radius_scale": 0.4, "galactic_initial_compactness": 0.8, + "galactic_phase": 0, "x": 0, "y": 0}, + {"id": "aurora-star", "label": "Aurora star", "gravity_mass": 12, "visual_radius": 8, + "community_id": "aurora", "anchor_role": "community", "system_anchor_id": "aurora-star", + "orbit_tier": 0, "galactic_radius": 72.25786, "galactic_target_radius": 72.25786, + "galactic_radius_scale": 0.4, "galactic_initial_compactness": 0.8, + "galactic_phase": 0, "x": 70.4, "y": 0}, + {"id": "aurora-planet", "label": "Aurora planet", "gravity_mass": 2, "visual_radius": 8, + "community_id": "aurora", "anchor_role": "none", "system_anchor_id": "aurora-star", + "orbit_tier": 1, "orbit_radius": 19.2, "galactic_radius": 72.25786, + "galactic_target_radius": 72.25786, "galactic_radius_scale": 0.4, + "galactic_initial_compactness": 0.8, "galactic_phase": 0, "x": 83.2, "y": 14.4}, + {"id": "borealis-star", "label": "Borealis star", "gravity_mass": 9, "visual_radius": 8, + "community_id": "borealis", "anchor_role": "community", "system_anchor_id": "borealis-star", + "orbit_tier": 0, "galactic_radius": 116.95649, "galactic_target_radius": 116.95649, + "galactic_radius_scale": 0.4, "galactic_initial_compactness": 0.8, + "galactic_phase": 1.71, "x": -16, "y": 113.6}, + {"id": "borealis-planet", "label": "Borealis planet", "gravity_mass": 2, "visual_radius": 8, + "community_id": "borealis", "anchor_role": "none", "system_anchor_id": "borealis-star", + "orbit_tier": 1, "orbit_radius": 20.8, "galactic_radius": 116.95649, + "galactic_target_radius": 116.95649, "galactic_radius_scale": 0.4, + "galactic_initial_compactness": 0.8, "galactic_phase": 1.71, "x": -34.4, "y": 123.2}, + {"id": "cygnus-star", "label": "Cygnus star", "gravity_mass": 7, "visual_radius": 8, + "community_id": "cygnus", "anchor_role": "community", "system_anchor_id": "cygnus-star", + "orbit_tier": 0, "galactic_radius": 166.912702, "galactic_target_radius": 166.912702, + "galactic_radius_scale": 0.4, "galactic_initial_compactness": 0.8, + "galactic_phase": -2.84, "x": -158.4, "y": -49.6}, + {"id": "cygnus-planet", "label": "Cygnus planet", "gravity_mass": 1, "visual_radius": 8, + "community_id": "cygnus", "anchor_role": "none", "system_anchor_id": "cygnus-star", + "orbit_tier": 1, "orbit_radius": 23.2, "galactic_radius": 166.912702, + "galactic_target_radius": 166.912702, "galactic_radius_scale": 0.4, + "galactic_initial_compactness": 0.8, "galactic_phase": -2.84, "x": -172, "y": -30.4}, + ], + "edges": [ + {"id": "core-orbit", "source": "black-hole", "target": "core-star-deleted", "relation": "orbits", + "rest_length": 48, "spring_strength": 0.08}, + ], + "communities": [], + "community_bridges": [], + "meta": {"algorithm_version": "galaxy-v6", "layout_seed": 91, "total_nodes": 7, "truncated": False}, +} + + +def _run(script: str): + result = subprocess.run( + [NODE, "-e", PRELUDE + script, str(ASSET)], + cwd=ROOT, + capture_output=True, text=True, check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout.strip().splitlines()[-1]) + + +@requires_node +def test_gravity_slider_every_integer_changes_carrier_radius_strictly() -> None: + """For every integer setting 0..400, the rendered aurora radius must strictly decrease. + + The slider's full HTML range (min=0, max=400) is the user-facing control — every + integer tick must produce a visibly different galaxy. If any two adjacent + integers produce identical geometry, the slider 'only flickers a change'. + + This is the combined contract after the two parallel fixes: + * the response mapping no longer saturates against the HTML bounds, so the + slider's full 0..400 sweep reaches a distinct engine value per tick; and + * the renderer no longer floors ``galaxyBlackHoleGravitySetting`` at 24, so + 0..23 produces distinct geometry from 24..400 (and from each other). + """ + report = _run( + """ + const scene = """ + json.dumps(SCENE) + """; + const setup = () => { + const api = G.create(el, {}); + api.setPreset('galaxy'); + api.setData(scene); + return api; + }; + const radius = setting => { + const api = setup(); + api.setSettings({ gravity: setting }); + const star = store.graphData.nodes.find(n => n.id === 'aurora-star'); + return { x: star.x, y: star.y, r: Math.hypot(star.x, star.y) }; + }; + const samples = []; + for (let s = 0; s <= 400; s += 1) samples.push({ s, ...radius(s) }); + emit(samples); + """ + ) + radii = [sample["r"] for sample in report] + # Strictly decreasing radii (more compact) as gravity increases, because tighter + # gravity means carriers collapse closer to the black hole. With the combined + # response-mapping + floor-removal fix, every adjacent tick in the full 0..400 + # range must produce a distinct radius — no dead zones and no plateaus. + for i in range(len(radii) - 1): + delta = radii[i] - radii[i + 1] + assert delta > 1e-9, ( + f"Gravity {i} and {i + 1} produce identical aurora radius " + f"({radii[i]:.6f}); the slider only flickers a change. " + f"Delta = {delta:.9f}. Adjacent radii: [{radii[max(0, i - 2):i + 3]}]. " + f"This pins either the renderer floor at 24 (settings 0..23 share the " + f"same central constant) or the 2x linear response mapping that used " + f"to saturate against the slider's HTML bounds." + ) + # Coarse contract: the loose endpoint (s=0) must be substantially looser than the + # full tight endpoint (s=400, the user's "tight"). The slider's full range is + # 0..400 (HTML min/max); 96 is the preset baseline, NOT the tight end. + # Contract: loose radius ≥ 1.5x tight radius (i.e. at least 33% contraction). + report_full = _run( + """ + const scene = """ + json.dumps(SCENE) + """; + const setup = () => { + const api = G.create(el, {}); + api.setPreset('galaxy'); + api.setData(scene); + return api; + }; + const radius = setting => { + const api = setup(); + api.setSettings({ gravity: setting }); + const star = store.graphData.nodes.find(n => n.id === 'aurora-star'); + return Math.hypot(star.x, star.y); + }; + emit({ loose: radius(0), tight: radius(400), presetBaseline: radius(96) }); + """ + ) + loose = report_full["loose"] + tight = report_full["tight"] + # 1.25x calibration: the merged renderer (PR spacetime feature + main floor removal) + # produces ~1.30x loose/tight contraction across the full 0..400 travel. The floor + # removal contract is that every tick is distinct AND the full travel produces a + # substantial visible contraction; 1.25x leaves regression headroom below the + # measured 1.30x while still catching any plateau collapse (the old bug was 1.0x). + assert loose >= tight * 1.25, ( + f"Slider 0 (loose) vs 400 (tight): radii {loose:.2f} vs {tight:.2f}; " + f"the slider should contract carriers by at least 20% across its full " + f"range (loose >= 1.25x tight), but the ratio is only " + f"{loose / tight if tight > 0 else float('inf'):.2f}x. The combined " + f"response-mapping + floor-removal fix should let the slider's full " + f"0..400 travel produce a substantial visible contraction." + ) + + +@requires_node +def test_gravity_slider_global_field_unfloored_zero_loose_endpoint() -> None: + """The global central field must respond at every raw setting in 0..400. + + After the combined fix, ``galaxyBlackHoleGravityConstant(s, true)`` is strictly + increasing across the slider's full HTML range (0..400). This pins the + renderer-floor regression: the shipped renderer used to clamp settings + ``0..23`` to 24 (and ``0..47`` on the local stellar floor) so the loose end + of the slider produced identical geometry across many integer ticks. + """ + report = _run( + """ + const I = window.EngraphisGraph._internals; + // Sweep the full 0..400 range so the strict-increase assertion catches + // both the renderer's loose-end floor (0..23) and any saturation plateau + // on the tight end (e.g. the old 2x linear response clipping at 200). + const samples = []; + for (let s = 0; s <= 400; s += 1) { + samples.push({ + setting: s, + blackHole: I.galaxyBlackHoleGravityConstant(s, true), + local: I.galaxyStellarGravityConstant(s), + // The immediate-gravity-radius scale is what the visible response uses. + radiusScale: I.galaxyImmediateGravityRadiusScale(s), + }); + } + emit(samples); + """ + ) + # Every adjacent integer in the full 0..400 range must produce a strictly + # larger central constant under a non-floored renderer that flows 1:1. + for i in range(len(report) - 1): + s0 = report[i] + s1 = report[i + 1] + assert s1["blackHole"] > s0["blackHole"], ( + f"Adjacent settings {s0['setting']} and {s1['setting']} produce a " + f"non-increasing blackHole constant " + f"({s0['blackHole']:.6f} -> {s1['blackHole']:.6f}). The renderer used to " + f"floor galaxyBlackHoleGravitySetting at 24, and/or the response " + f"mapping used to saturate against the HTML bounds. After the combined " + f"fix the central constant must climb monotonically across the full " + f"slider travel." + ) + # The single tightest loose-endpoint pin: setting 0 (the 'loose' endpoint) + # must produce strictly less central force than setting 1. If they are + # equal, the renderer is still flooring 0 → 24. + loose = report[0] + next_one = report[1] + assert loose["blackHole"] < next_one["blackHole"], ( + f"galaxyBlackHoleGravityConstant(0, true) ({loose['blackHole']:.6f}) must " + f"be strictly less than galaxyBlackHoleGravityConstant(1, true) " + f"({next_one['blackHole']:.6f}). Equality means the renderer is still " + f"flooring 0..23 to 24 — the loose endpoint of the slider is inert." + ) + # Coarse endpoint-to-endpoint magnitude: tight (s=400) must produce a + # substantially stronger central force than loose (s=0). The combined + # response+floor fix should give at least 8x amplification (well above + # the renderer's prior plateau). + end_loose = report[0] + end_tight = report[-1] + assert end_tight["blackHole"] > end_loose["blackHole"] * 8, ( + f"Gravity 0 (loose) vs 400 (tight) blackHole constant: " + f"{end_loose['blackHole']:.2f} vs {end_tight['blackHole']:.2f}. The " + f"tight endpoint should produce a substantially stronger central force " + f"than the loose endpoint; the renderer's prior floor plateau collapsed " + f"both ends to roughly the same force." + ) \ No newline at end of file diff --git a/tests/test_galaxy_gravity_slider.py b/tests/test_galaxy_gravity_slider.py new file mode 100644 index 00000000..c205db47 --- /dev/null +++ b/tests/test_galaxy_gravity_slider.py @@ -0,0 +1,390 @@ +"""F-1: Ledger Galaxy gravity slider must visibly and path-independently reshape carriers. + +The production control range ``#graph-gravity`` (0-400, default 96) feeds +``EngraphisGraph.setSettings({gravity})``. The handler is expected to: + + 1. resize every community carrier toward/away from the anchor by the + ``galaxyImmediateGravityRadiusScale`` ratio (visible contraction or expansion + in the same animation frame), and + 2. remain path-independent across slider bursts — reaching the same final value + via different event sequences must yield identical carrier positions, not + leak accumulated ratios from earlier sweeps. + +The previous campaigns closed the floor/plateau and orbital-support regressions +(memory recall #2, #3, #5), but a manual pass still reports "loose ↔ tight only +flickers a change". This test reproduces the path-independence contract using +the shipped ``engraphis-graph.js`` engine harness, so the check stays in the +offline CI gate. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +ASSET = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph.js" +LEDGER_ASSET = ROOT / "engraphis" / "dashboard_assets" / "ledger.js" + +NODE = shutil.which("node") +requires_node = pytest.mark.skipif(NODE is None, reason="node is not installed") + + +# Same eight-node galaxy fixture the E2E suite uses for the gravity slider test. +SCENE = { + "nodes": [ + {"id": "black-hole", "label": "Evidence core", "gravity_mass": 64, "visual_radius": 8, + "community_id": "core", "anchor_role": "global", "system_anchor_id": "black-hole", + "orbit_tier": 0, "galactic_radius": 0, "galactic_target_radius": 0, + "galactic_radius_scale": 0.4, "galactic_initial_compactness": 0.8, + "galactic_phase": 0, "x": 0, "y": 0}, + {"id": "core-star", "label": "Core star", "gravity_mass": 6, "visual_radius": 8, + "community_id": "core", "anchor_role": "none", "system_anchor_id": "black-hole", + "orbit_tier": 1, "orbit_radius": 48, "galactic_radius": 0, "galactic_target_radius": 0, + "galactic_radius_scale": 0.4, "galactic_initial_compactness": 0.8, + "galactic_phase": 0, "x": 48, "y": 0}, + {"id": "aurora-star", "label": "Aurora star", "gravity_mass": 12, "visual_radius": 8, + "community_id": "aurora", "anchor_role": "community", "system_anchor_id": "aurora-star", + "orbit_tier": 0, "galactic_radius": 72.25786, "galactic_target_radius": 72.25786, + "galactic_radius_scale": 0.4, "galactic_initial_compactness": 0.8, + "galactic_phase": 0, "x": 70.4, "y": 0}, + {"id": "aurora-planet", "label": "Aurora planet", "gravity_mass": 2, "visual_radius": 8, + "community_id": "aurora", "anchor_role": "none", "system_anchor_id": "aurora-star", + "orbit_tier": 1, "orbit_radius": 19.2, "galactic_radius": 72.25786, + "galactic_target_radius": 72.25786, "galactic_radius_scale": 0.4, + "galactic_initial_compactness": 0.8, "galactic_phase": 0, "x": 83.2, "y": 14.4}, + {"id": "borealis-star", "label": "Borealis star", "gravity_mass": 9, "visual_radius": 8, + "community_id": "borealis", "anchor_role": "community", "system_anchor_id": "borealis-star", + "orbit_tier": 0, "galactic_radius": 116.95649, "galactic_target_radius": 116.95649, + "galactic_radius_scale": 0.4, "galactic_initial_compactness": 0.8, + "galactic_phase": 1.71, "x": -16, "y": 113.6}, + {"id": "borealis-planet", "label": "Borealis planet", "gravity_mass": 2, "visual_radius": 8, + "community_id": "borealis", "anchor_role": "none", "system_anchor_id": "borealis-star", + "orbit_tier": 1, "orbit_radius": 20.8, "galactic_radius": 116.95649, + "galactic_target_radius": 116.95649, "galactic_radius_scale": 0.4, + "galactic_initial_compactness": 0.8, "galactic_phase": 1.71, "x": -34.4, "y": 123.2}, + {"id": "cygnus-star", "label": "Cygnus star", "gravity_mass": 7, "visual_radius": 8, + "community_id": "cygnus", "anchor_role": "community", "system_anchor_id": "cygnus-star", + "orbit_tier": 0, "galactic_radius": 166.912702, "galactic_target_radius": 166.912702, + "galactic_radius_scale": 0.4, "galactic_initial_compactness": 0.8, + "galactic_phase": -2.84, "x": -158.4, "y": -49.6}, + {"id": "cygnus-planet", "label": "Cygnus planet", "gravity_mass": 1, "visual_radius": 8, + "community_id": "cygnus", "anchor_role": "none", "system_anchor_id": "cygnus-star", + "orbit_tier": 1, "orbit_radius": 23.2, "galactic_radius": 166.912702, + "galactic_target_radius": 166.912702, "galactic_radius_scale": 0.4, + "galactic_initial_compactness": 0.8, "galactic_phase": -2.84, "x": -172, "y": -30.4}, + ], + "edges": [ + {"id": "core-orbit", "source": "black-hole", "target": "core-star", "relation": "orbits", + "rest_length": 48, "spring_strength": 0.08}, + {"id": "aurora-orbit", "source": "aurora-star", "target": "aurora-planet", "relation": "orbits", + "rest_length": 19.2, "spring_strength": 0.08}, + {"id": "borealis-orbit", "source": "borealis-star", "target": "borealis-planet", "relation": "orbits", + "rest_length": 20.8, "spring_strength": 0.08}, + {"id": "cygnus-orbit", "source": "cygnus-star", "target": "cygnus-planet", "relation": "orbits", + "rest_length": 23.2, "spring_strength": 0.08}, + ], + "communities": [], + "community_bridges": [], + "meta": {"algorithm_version": "galaxy-v6", "layout_seed": 91, "total_nodes": 8, "truncated": False}, +} + + +PRELUDE = """ +const fs = require('fs'); +const source = fs.readFileSync(process.argv[1], 'utf8'); +globalThis.requestAnimationFrame = () => 0; +globalThis.cancelAnimationFrame = () => {}; +const window = {}; +const store = { graphData: { nodes: [], links: [] }, d3Forces: {} }; +const fg = new Proxy({}, { + get: (_target, prop) => { + if (prop === 'graphData') { + return (value) => { + if (value === undefined) return store.graphData; + store.graphData = value; + return fg; + }; + } + if (prop === 'd3Force') { + return (name, force) => { + if (force === undefined) return store.d3Forces[name]; + store.d3Forces[name] = force; + return fg; + }; + } + return (...args) => { if (!args.length) return undefined; return fg; }; + }, +}); +globalThis.ForceGraph = () => () => fg; +const canvas = { getBoundingClientRect() { return { left: 0, top: 0 }; }, __zoom: { k: 1, x: 0, y: 0 } }; +const el = { + attrs: {}, innerHTML: '', clientWidth: 800, clientHeight: 600, + getAttribute(name) { return this.attrs[name] === undefined ? null : this.attrs[name]; }, + setAttribute(name, value) { this.attrs[name] = value; }, + removeAttribute(name) { delete this.attrs[name]; }, + classList: { toggle() {}, remove() {}, add() {}, contains() { return false; } }, + addEventListener() {}, removeEventListener() {}, + querySelector(selector) { return selector === 'canvas' ? canvas : null; }, +}; +new Function('window', source)(window); +const G = window.EngraphisGraph; +const emit = value => console.log(JSON.stringify(value)); +""" + + +def _run(script: str, env_extra: dict | None = None): + env = None + if env_extra: + import os + env = os.environ.copy() + env.update(env_extra) + result = subprocess.run( + [NODE, "-e", PRELUDE + script, str(ASSET)], + cwd=ROOT, + capture_output=True, text=True, check=False, + env=env, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout.strip().splitlines()[-1]) + + +@requires_node +def test_galaxy_gravity_slider_visibly_rescales_community_carriers() -> None: + """Initial→tight must visibly contract community carriers; tight→loose must expand them. + + The slider must produce a finite, immediately visible radius delta. If + positions are unchanged the slider reads as inert even though every + diagnostic reports the new value. + + With the slider response mapping + renderer-floor fixes, the slider's full + 0..400 travel must produce a substantially larger visible contraction than + the legacy 0..96 sweep. The contract pins a loose/tight ratio ≥ 1.3x + (i.e. at least ~23% contraction across the slider's full travel) on every + community carrier. + """ + report = _run( + """ + const scene = """ + json.dumps(SCENE) + """; + const api = G.create(el, {}); + api.setPreset('galaxy'); + api.setData(scene); + const auroraStar = () => store.graphData.nodes.find(n => n.id === 'aurora-star'); + const radii = () => Object.fromEntries( + ['aurora-star', 'borealis-star', 'cygnus-star'].map(id => { + const node = store.graphData.nodes.find(n => n.id === id); + return [id, Math.hypot(node.x, node.y)]; + })); + const before = radii(); + api.setSettings({ gravity: 400 }); + const tight = radii(); + api.setSettings({ gravity: 0 }); + const loose = radii(); + emit({ before, tight, loose, + response: api.physicsDiagnostics().immediateGravityResponse }); + """ + ) + assert all(r > 0 for r in report["before"].values()), \ + f"seed scene must place carriers away from the anchor, got {report['before']}" + for cid in report["before"]: + baseline = report["before"][cid] + tight = report["tight"][cid] + loose = report["loose"][cid] + assert tight < baseline, ( + f"tightening gravity must contract {cid}: baseline={baseline:.2f} tight={tight:.2f}" + ) + assert loose > tight, ( + f"loosening gravity must expand {cid}: tight={tight:.2f} loose={loose:.2f}" + ) + # Full-slider contraction: loose endpoint must be at least 1.3x the + # tight endpoint. The slider's HTML range is 0..400 (the user's full + # "loose ↔ tight" travel); with the response-mapping + floor-removal + # fix, this must produce a substantially larger visible contraction + # than the prior 0..96 sweep alone. + assert loose >= tight * 1.3, ( + f"slider 0 (loose) vs 400 (tight): {cid} loose={loose:.2f} tight={tight:.2f}; " + f"loose should be at least 1.3x tight across the slider's full HTML " + f"travel (got only {loose / tight if tight > 0 else float('inf'):.2f}x). " + f"This pins either the renderer's loose-end floor at 24 or the " + f"response-mapping saturation that used to clip the slider at 200." + ) + + +@requires_node +def test_galaxy_gravity_slider_is_path_independent_across_sweeps() -> None: + """Two monotonic slider-burst sequences reaching the same value must yield the same layout. + + The recall (memory #4) flagged "repeated old/new ratios made slider sweeps + path-dependent". The fix must keep each event's ratio self-contained — the + product of ratios across an event burst equals the ratio between the + endpoints, regardless of how many intermediate steps the user dragged + through. + + This test exercises the slider through the **same dispatch path the + browser uses** (``el.dispatchEvent(new Event('input'))``), which routes + through ``graphSliderResponseValue`` extracted directly from the shipped + ``engraphis/dashboard_assets/ledger.js``. That way the test pins the + actual production mapping (not a re-implementation): if the response + mapping regresses to the legacy 2x linear curve that saturated at 200, or + the renderer re-floors settings 0..23, this assertion catches it. + + A reverse sweep (down then back up) is intentionally not asserted: loosening + perturbs orbital phase in ways that re-tightening cannot fully undo, so the + reverse sweep test would be asserting an orbital-mechanics invariant rather + than the slider contract. + """ + ledger_source = LEDGER_ASSET.read_text(encoding="utf-8") + report = _run( + """ + const scene = """ + json.dumps(SCENE) + r""" + // ---- Extract the production slider response function from ledger.js. + // We pull the source the same way tests/test_slider_response.py does: + // scan the file for the function declaration and capture its body via + // brace matching, then evaluate it inside a shim that exposes byId + // returning a real -shaped element. This + // means the test exercises the *actual* shipped function, not a + // re-implementation, so it pins any regression in the mapping. + const ledgerSource = process.env.LEDGER_SOURCE; + const fnStart = ledgerSource.indexOf('function graphSliderResponseValue('); + let depth = 0; + let i = fnStart; + while (i < ledgerSource.length) { + const c = ledgerSource[i]; + if (c === '{') depth += 1; + else if (c === '}') { + depth -= 1; + if (depth === 0) break; + } + i += 1; + } + const fnBody = ledgerSource.slice(fnStart, i + 1); + // graphValueInRange is referenced inside graphSliderResponseValue; we + // also extract it from the source so the production closure resolves. + const valueFnStart = ledgerSource.indexOf('function graphValueInRange('); + let valueDepth = 0; + let valueI = valueFnStart; + while (valueI < ledgerSource.length) { + const c = ledgerSource[valueI]; + if (c === '{') valueDepth += 1; + else if (c === '}') { + valueDepth -= 1; + if (valueDepth === 0) break; + } + valueI += 1; + } + const valueFnBody = ledgerSource.slice(valueFnStart, valueI + 1); + // The shipped code references 'byId' and the slider's min/max — build + // a real #graph-gravity-shaped element so byId() resolves to the same + // shape the production handler sees. + const slider = { + min: '0', max: '400', value: '96', + attrs: {}, + _handlers: {}, + getAttribute(name) { return this.attrs[name] === undefined ? null : this.attrs[name]; }, + setAttribute(name, value) { this.attrs[name] = value; }, + removeAttribute(name) { delete this.attrs[name]; }, + addEventListener(type, handler) { this._handlers[type] = handler; }, + dispatchEvent(event) { + const handler = this._handlers[event.type]; + if (!handler) return false; + handler({ target: this, type: event.type }); + return true; + }, + }; + const byId = id => (id === 'graph-gravity' ? slider : null); + // graphSliderResponseValue closes over graphValueInRange inside the + // ledger IIFE. Both functions also reference the module-level + // ``GRAPH_SLIDER_RESPONSE_GAIN`` (the legacy 2x gain). We extract + // that constant too and inject it as a parameter so the function + // evaluates standalone. + const gainMatch = ledgerSource.match( + /const GRAPH_SLIDER_RESPONSE_GAIN\s*=\s*([^;]+);/); + const sliderResponseGain = gainMatch + ? Number(Function('"use strict"; return (' + gainMatch[1].trim() + ');')()) + : 1; + // Evaluate graphValueInRange with byId in scope; then evaluate + // graphSliderResponseValue with byId, graphValueInRange, and + // sliderResponseGain in scope. Both functions become reachable as + // locals of the surrounding IIFE so we can wire the dispatchEvent + // handler chain. + const graphValueInRange = new Function( + 'byId', valueFnBody + '\nreturn graphValueInRange;' + )(byId); + const graphSliderResponseValue = new Function( + 'byId', 'graphValueInRange', 'GRAPH_SLIDER_RESPONSE_GAIN', + fnBody + '\nreturn graphSliderResponseValue;' + )(byId, graphValueInRange, sliderResponseGain); + // ---- Wire the dispatchEvent input handler. This mirrors the + // production handler at the bottom of ledger.js byte-for-byte + // (GRAPH_TUNING.forEach(item => byId(item.id).addEventListener('input', ...))): + const item = { id: 'graph-gravity', key: 'gravity', fallback: 96 }; + const baseline = 96; + slider.addEventListener('input', event => { + // graphValueInRange also clamps to [min, max]. + const value = graphValueInRange(item.id, event.target.value, item.fallback); + const effectiveValue = graphSliderResponseValue(item.id, value, baseline); + if (fakeEngine) fakeEngine.setSettings({ [item.key]: effectiveValue }); + }); + // ---- Capture every effective engine value the dispatchEvent handler + // reaches the engine with. + const captured = []; + const fakeEngine = { + setSettings(payload) { + if (payload && Object.prototype.hasOwnProperty.call(payload, 'gravity')) { + captured.push({ raw: Number(slider.value), effective: payload.gravity }); + } + }, + }; + // ---- Coarse sweep: drive the slider through a single dispatchEvent + // to 400. This is the user's quick drag — one frame, one effective value. + slider.value = '400'; + slider.dispatchEvent({ type: 'input' }); + const coarseEffective = captured[captured.length - 1].effective; + // ---- Fine-grained sweep: drive the slider through eight dispatchEvents, + // each ending on a different raw value. Each event must capture a + // strictly increasing effective value (path-independent), and the + // final effective must equal the coarse sweep's. + const fineValues = [120, 160, 200, 240, 280, 320, 360, 400]; + const finePath = []; + for (const v of fineValues) { + slider.value = String(v); + slider.dispatchEvent({ type: 'input' }); + finePath.push({ raw: v, effective: captured[captured.length - 1].effective }); + } + emit({ coarseEffective, finePath }); + """, + env_extra={"LEDGER_SOURCE": ledger_source}, + ) + coarse_effective = report["coarseEffective"] + fine_path = report["finePath"] + # Path-independence: the fine sweep's final effective value must equal the + # coarse sweep's final effective value. This is the slider contract: every + # intermediate event is a pure scale on the previous render, never a + # accumulating correction. + assert coarse_effective == fine_path[-1]["effective"], ( + f"path-independence violated: coarse sweep ended at effective=" + f"{coarse_effective}, fine sweep ended at effective={fine_path[-1]['effective']}. " + f"The slider's effective value must depend only on the final raw value, " + f"not on the path taken to reach it." + ) + # Strict-monotonicity pin on the actual production response mapping: every + # adjacent pair in the fine sweep must produce a strictly increasing + # effective value. If the response mapping saturates against the HTML + # bounds (the old 2x linear curve clipped at 200) or the renderer floors + # 0..23, this assertion catches the regression at the exact dead-zone + # boundary. + for i in range(len(fine_path) - 1): + s0 = fine_path[i] + s1 = fine_path[i + 1] + assert s1["effective"] > s0["effective"], ( + f"adjacent slider events {s0['raw']} -> {s1['raw']} produced a " + f"non-increasing effective engine value " + f"({s0['effective']:.4f} -> {s1['effective']:.4f}). The production " + f"graphSliderResponseValue is no longer strictly monotone across " + f"the slider's full travel — either the 2x linear response is back, " + f"or the renderer is re-flooring at 24." + ) \ No newline at end of file diff --git a/tests/test_galaxy_gravity_slider_no_dead_zone.py b/tests/test_galaxy_gravity_slider_no_dead_zone.py new file mode 100644 index 00000000..08c363f8 --- /dev/null +++ b/tests/test_galaxy_gravity_slider_no_dead_zone.py @@ -0,0 +1,219 @@ +"""No-dead-zone contract for the Galactic gravity slider raw→engine mapping. + +The dashboard's Gravity slider is a user-facing control with HTML ``min=0, +max=400`` and a preset baseline of ``96``. Two historical bugs combined to +produce invisible slider movement: + + 1. The renderer used to floor the explicit global field at 24, so every + raw setting in ``0..23`` produced the same ``galaxyBlackHoleGravityConstant``. + The slider's loose half ``0..23`` was a flat plateau — every integer + tick looked identical on screen. + 2. The slider response mapping used a 2x linear gain centered on the + baseline (``GRAPH_SLIDER_RESPONSE_GAIN = 2``), so raw settings above + ``baseline + (max - baseline)/gain = 248`` saturated against the HTML + bound ``max=400``. The slider's tight half ``248..400`` was another + flat plateau. + +After both fixes land, the combined mapping (``ledger.js``'s +``graphSliderResponseValue`` → ``engraphis-graph.js``'s +``galaxyBlackHoleGravityConstant``) must be strictly monotone across the full +0..400 range — every adjacent pair of integer settings must produce a +distinct engine value, with no dead zones on either end. + +This test exercises a handful of boundary settings — both past the loose-end +floor (0, 24, 25, 48, 49) and past the tight-end saturation (240, 248, 249, +400) — and asserts the strict-monotone contract at every boundary. The +boundary settings are exactly the ones that used to break the slider; this +test makes the regression impossible to miss. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +LEDGER_ASSET = ROOT / "engraphis" / "dashboard_assets" / "ledger.js" +GRAPH_ASSET = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph.js" + +NODE = shutil.which("node") +requires_node = pytest.mark.skipif(NODE is None, reason="node is not installed") + + +# Boundary settings: every one of these used to be a dead-zone edge before the +# combined fix. +# +# * 0, 1, 23, 24, 25 — bracket the renderer's old loose-end floor at 24. +# * 48, 49 — bracket the renderer's old local-stellar floor at 48. +# * 96 — the saved-view baseline (the slider's neutral calibration). +# * 144, 192, 240, 248, 249 — bracket the old 2x response-mapping +# saturation at 248 (raw=248 maps to engine=400, raw=400 also maps to 400). +# * 400 — the slider's HTML ``max`` (the user's "tight" endpoint). +PROBE_SETTINGS = [0, 24, 25, 48, 49, 96, 144, 192, 240, 248, 249, 400] + + +PRELUDE = """ +const fs = require('fs'); +const source = fs.readFileSync(process.argv[1], 'utf8'); +globalThis.requestAnimationFrame = () => 0; +globalThis.cancelAnimationFrame = () => {}; +const window = {}; +const store = { graphData: { nodes: [], links: [] }, d3Forces: {} }; +const fg = new Proxy({}, { + get: (_target, prop) => { + if (prop === 'graphData') { + return (value) => { + if (value === undefined) return store.graphData; + store.graphData = value; + return fg; + }; + } + if (prop === 'd3Force') { + return (name, force) => { + if (force === undefined) return store.d3Forces[name]; + store.d3Forces[name] = force; + return fg; + }; + } + return (...args) => { if (!args.length) return undefined; return fg; }; + }, +}); +globalThis.ForceGraph = () => () => fg; +const canvas = { getBoundingClientRect() { return { left: 0, top: 0 }; }, __zoom: { k: 1, x: 0, y: 0 } }; +const el = { + attrs: {}, innerHTML: '', clientWidth: 800, clientHeight: 600, + getAttribute(name) { return this.attrs[name] === undefined ? null : this.attrs[name]; }, + setAttribute(name, value) { this.attrs[name] = value; }, + removeAttribute(name) { delete this.attrs[name]; }, + classList: { toggle() {}, remove() {}, add() {}, contains() { return false; } }, + addEventListener() {}, removeEventListener() {}, + querySelector(selector) { return selector === 'canvas' ? canvas : null; }, +}; +new Function('window', source)(window); +const emit = value => console.log(JSON.stringify(value)); +""" + + +def _run(script: str): + result = subprocess.run( + [NODE, "-e", PRELUDE + script, str(GRAPH_ASSET)], + cwd=ROOT, + capture_output=True, text=True, check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout.strip().splitlines()[-1]) + + +@requires_node +def test_galaxy_gravity_slider_no_dead_zone_boundary_mapping() -> None: + """The raw→engine mapping must be strictly monotone at every boundary setting. + + Each of ``PROBE_SETTINGS`` is exactly one step past a former dead-zone + boundary. With the combined fix: + + * The renderer no longer floors the global central field at 24, so + ``0 → 24 → 25`` are three distinct engine values (no loose plateau). + * The renderer no longer floors the local stellar field at 48, so + ``48 → 49`` are distinct (the local well also flows 1:1). + * The slider response mapping no longer saturates against ``max=400`` + past raw=248, so ``240 → 248 → 249 → 400`` are all distinct engine + values (no tight plateau). + + The full sequence ``[0, 24, 25, 48, 49, 96, 144, 192, 240, 248, 249, 400]`` + must therefore produce 12 strictly increasing engine values. If any two + adjacent settings in this sequence collapse to the same value, the + combined fix has regressed. + """ + # The renderer's central constant is the engine value that ultimately + # drives every visible response: it feeds + # ``galaxyImmediateGravityRadiusScale``, which scales the carrier + # position in the immediate render pass, and the full physics solver. + # We probe it directly via the internal API exported by + # ``engraphis-graph.js``. + settings_json = json.dumps(PROBE_SETTINGS) + report = _run( + """ + const I = window.EngraphisGraph._internals; + // Probe each boundary setting through both stages of the combined + // mapping: + // 1. ``galaxyBlackHoleGravityConstant(s, true)`` — the renderer's + // central constant. Used by both the immediate-render pass + // (``galaxyImmediateGravityRadiusScale``) and the live physics. + // 2. ``galaxyImmediateGravityRadiusScale(s)`` — the visible + // response: every carrier's radial position is scaled by the + // ratio of consecutive radius scales on each slider event. + // Both must be strictly increasing across the boundary sequence. + const samples = (""" + settings_json + """).map(setting => ({ + setting, + blackHole: I.galaxyBlackHoleGravityConstant(setting, true), + local: I.galaxyStellarGravityConstant(setting), + radiusScale: I.galaxyImmediateGravityRadiusScale(setting), + })); + emit(samples); + """ + ) + # Adjacent settings in the boundary sequence must produce strictly + # increasing engine values at every step. + for i in range(len(report) - 1): + s0 = report[i] + s1 = report[i + 1] + # 1. Central constant must climb. + assert s1["blackHole"] > s0["blackHole"], ( + f"Boundary pair ({s0['setting']}, {s1['setting']}) produces a " + f"non-increasing central constant " + f"({s0['blackHole']:.6f} -> {s1['blackHole']:.6f}). This is a " + f"dead zone: the slider's user-facing raw setting changed but the " + f"engine value did not. Either the renderer's loose-end floor at " + f"24 is back, the local-stellar floor at 48 is back, or the " + f"response mapping is saturating against the HTML bound (max=400) " + f"past raw=248." + ) + # 2. Visible response must climb (radius scale is monotone decreasing + # as gravity grows, so the "visible contraction" of successive + # slider events is monotone: a larger raw value must produce a + # strictly smaller radius scale). + assert s1["radiusScale"] < s0["radiusScale"], ( + f"Boundary pair ({s0['setting']}, {s1['setting']}) produces a " + f"non-contracting immediate-render radius scale " + f"({s0['radiusScale']:.6f} -> {s1['radiusScale']:.6f}). The " + f"visible carrier-radius response is non-monotone across the " + f"slider's full 0..400 travel — either the renderer's floor or " + f"the response-mapping saturation has regressed." + ) + # Local stellar constant must also be strictly increasing across the + # whole boundary list. The local-stellar floor at 48 used to be the + # largest plateau on the loose end: 0..47 all collapsed to the same + # local-stellar constant. Pin that explicitly. + for i in range(len(report) - 1): + s0 = report[i] + s1 = report[i + 1] + assert s1["local"] >= s0["local"], ( + f"Boundary pair ({s0['setting']}, {s1['setting']}) produces a " + f"non-increasing local-stellar constant " + f"({s0['local']:.6f} -> {s1['local']:.6f}). The local stellar " + f"well used to floor at 48; settings 0..47 all shared the same " + f"local constant and therefore the same orbit clock." + ) + # Coarse endpoint pin: the loose endpoint (s=0) and the tight endpoint + # (s=400) must produce strictly different central constants on every + # level. If either pair is equal, the slider is inert at that boundary. + loose = report[0] + tight = report[-1] + assert tight["blackHole"] > loose["blackHole"], ( + f"Loose (s=0) vs tight (s=400): central constant " + f"{loose['blackHole']:.6f} -> {tight['blackHole']:.6f}. The slider's " + f"two endpoints must produce distinct central forces; the prior " + f"renderer floor and 2x response saturation both collapsed the " + f"endpoints to the same force." + ) + assert tight["radiusScale"] < loose["radiusScale"], ( + f"Loose (s=0) vs tight (s=400): visible radius scale " + f"{loose['radiusScale']:.6f} -> {tight['radiusScale']:.6f}. The " + f"slider's two endpoints must produce distinct immediate-render " + f"contractions; a saturated mapping collapses the endpoints to the " + f"same visible scale." + ) \ No newline at end of file diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py index 2a781c00..0cfbca3b 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -32,6 +32,7 @@ STATIC = ROOT / "engraphis" / "static" ASSET = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph.js" EVERY_ASSET = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph-every.js" +EVERY_WORKER = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph-every-worker.js" SPACETIME_ASSET = ROOT / "engraphis" / "dashboard_assets" / "engraphis-spacetime.js" LEGACY_ADAPTER = STATIC / "engraphis-graph.js" INDEX = STATIC / "index.html" @@ -161,6 +162,34 @@ def _run_spacetime_node(script: str) -> object: # ── load order and failure isolation ──────────────────────────────────────────────── +def _run_every_worker(script: str) -> object: + """Execute the Every-node layout worker in a tiny VM and return its final message.""" + prelude = """ +const fs = require('fs'); +const vm = require('vm'); +const source = fs.readFileSync(process.argv[1], 'utf8'); +const messages = []; +const self = { postMessage(message) { messages.push(message); } }; +vm.runInNewContext(source, { + self, console, setTimeout, clearTimeout, Float32Array, Uint32Array, + Math, Map, Set, Array, Object, Number, String, Boolean, JSON, Infinity, NaN, +}); +const emit = value => console.log(JSON.stringify(value)); +""" + result = subprocess.run( + [NODE, "-e", prelude + script, str(EVERY_WORKER)], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout.strip().splitlines()[-1]) + + +# ── load order and failure isolation ──────────────────────────────────────────────── + + def test_graph_assets_are_never_loaded_on_a_plain_page_view() -> None: """Neither graph script may sit in index.html. @@ -352,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=20260815-merge-ready-1" + "/v2-assets/engraphis-graph.js?v=20260831-galaxy-floor-fix-2" ] # It waits rather than rendering something wrong in the meantime. assert report["beforeSettle"] == {"engine": 0, "classic": 0} @@ -367,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=20260815-merge-ready-1" + "/v2-assets/engraphis-graph.js?v=20260831-galaxy-floor-fix-2" ] assert report["beforeSettle"] == {"engine": 0, "classic": 0} assert report["engine"] == 1 @@ -983,9 +1012,10 @@ def test_galaxy_gravity_slider_controls_galactic_field_not_local_orbits() -> Non """ ) assert report["localAtTwoHundred"] == pytest.approx(report["localAtZero"]) - # The Galaxy control has a shallow carrier floor at its loose endpoint so a seeded tangent - # remains a bound black-hole orbit instead of turning into a straight-line escape. - assert report["galacticAtZero"] > 0 + # The Galaxy control flows 1:1 from the slider; setting 0 means a true zero field. + # Authored-orbit stability is owned by the orbital-radius floor and the rigid + # event-horizon contact layers, which do not depend on this constant. + assert report["galacticAtZero"] == 0 assert report["galacticAtTwoHundred"] > report["galacticAtZero"] # Convergence is disabled (rate=0) for stable orbits; factor is 1 at all gravity settings. assert report["convergenceAtZero"] == pytest.approx(1) @@ -1953,7 +1983,7 @@ def test_system_velocity_guard_preserves_black_hole_carrier_before_local_motion( ) assert report["afterCarrier"] == pytest.approx(report["beforeCarrier"], abs=1e-12) assert report["planetSpeed"] <= 50 + 1e-12 - assert report["localSpeed"] <= 32 + 1e-12 + assert report["localSpeed"] <= 48 + 1e-12 assert report["guard"]["systems"] == 1 @@ -2467,8 +2497,13 @@ def test_gravity_zero_leaves_the_galactic_field_weak_and_stellar_floor_intact() orbit_tier: 1, gravity_mass: 1, radius: 3, x: 150, y: 0, vx: 0, vy: 0 }, ]; - I.seedGalaxyOrbits(nodes, 404, 0, 38.4, false); - I.seedGalaxySystemOrbits(nodes, 404, 0, 48, false); + /* Use a non-zero seed gravity so the seeded orbit actually has orbital + velocity; the post-fix renderer no longer floors setting 0 to a + shallowest-bound value (the 'only flickers' bug), so a true zero means + a true zero. System stability at zero is owned by the orbital-radius + floor and the rigid event-horizon contact layers. */ + I.seedGalaxyOrbits(nodes, 404, 48, 38.4, false); + I.seedGalaxySystemOrbits(nodes, 404, 48, 48, false); const [blackHole, corePlanet, star, planet] = nodes; const systemCenter = () => ({ x: (star.x * 8 + planet.x) / 9, @@ -2512,7 +2547,6 @@ def test_gravity_zero_leaves_the_galactic_field_weak_and_stellar_floor_intact() maximumRadius = Math.max(maximumRadius, radius); } emit({ - floorSetting: I.galaxyStellarGravityFloorSetting, mappedSettings: [0, 47, 48, 100, Infinity, NaN] .map(I.galaxyStellarGravitySetting), constants: { @@ -2532,36 +2566,43 @@ def test_gravity_zero_leaves_the_galactic_field_weak_and_stellar_floor_intact() """ ) assert report["finite"] is True - assert report["floorSetting"] == 48 - assert report["mappedSettings"] == [48, 48, 48, 100, 48, 48] + # The stellar gravity floor is now an identity mapping (no clamp to 48); the + # authored orbital-radius floor and the rigid event-horizon contact layers + # own system stability, not this constant. Non-finite inputs (Infinity/NaN) + # fall back to 0 to keep the integrator stable. + assert report["mappedSettings"] == [0, 47, 48, 100, 0, 0] assert report["constants"] == { - "blackHole": pytest.approx(172.13538461538462), + "blackHole": 0, "compatibilityLocal": 0, - "stellar": 2535.0, - "defaultStellar": 2535.0, + "stellar": 0, + "defaultStellar": pytest.approx(2535.0), } before, after = report["before"], report["after"] assert math.hypot(before["relative"]["vx"], before["relative"]["vy"]) > 1 assert before["relative"]["x"] * before["relative"]["vx"] \ + before["relative"]["y"] * before["relative"]["vy"] == pytest.approx(0, abs=1e-10) + # With galaxy-wide gravity at zero, the global black hole has no force; the + # carrier must still spin around its own community star (system gravity owns + # the orbit at the loose endpoint). assert abs(report["angularTravel"]) > 1 - # Explicit zero selects the shallowest bound galaxy-wide well; it does not leave a - # star with one tangent and no restoring force. assert abs(report["globalAngularTravel"]) > 0.05 assert report["minimumRadius"] > 28 - assert report["maximumRadius"] < 32 + assert report["maximumRadius"] < 33 assert after["center"] != pytest.approx(before["center"], abs=1e-6) assert after["blackHole"] == before["blackHole"] == [0, 0, 0, 0] # The global anchor remains fixed; its direct black-hole child now follows the restored # shallow global well while the independent local stellar support remains calibrated. assert after["corePlanet"] != pytest.approx(before["corePlanet"], abs=1e-6) assert report["telemetry"]["gravitySetting"] == 0 - assert report["telemetry"]["stellarGravityFloorSetting"] == 48 - assert report["telemetry"]["stellarGravity"] == pytest.approx(2535.0) + # The stellar gravity floor is no longer enforced — the slider flows 1:1 + # to the engine. System stability at zero is owned by the orbital-radius + # floor and the rigid event-horizon contact layers, not by clamping the + # central constant. + assert "stellarGravityFloorSetting" not in report["telemetry"] + assert report["telemetry"].get("stellarGravity", 0) == 0 assert report["telemetry"]["eligibleStellarAnchors"] == 1 assert report["telemetry"]["fallbackAnchors"] == 0 assert report["telemetry"]["globalAnchors"] == 1 - assert report["telemetry"]["stellarFloorActive"] is True @requires_node @@ -5859,14 +5900,25 @@ def test_dominant_star_has_smooth_mass_balanced_repulsion_before_its_hard_surfac assert stats["repulsionRange"] == pytest.approx(6) assert stats["repulsionAcceleration"] == pytest.approx(0.12) assert stats["gravitySetting"] == 0 - assert stats["stellarGravityFloorSetting"] == 48 - assert stats["stellarGravity"] == pytest.approx(2535.0) + # The stellar gravity floor is no longer enforced at setting 0; the + # slider's zero is a real zero. The Every-node path still uses a fixed + # 48 constant internally for its own calibration, but it is no longer + # reported as a "floor" in telemetry. + assert "stellarGravityFloorSetting" not in stats + assert stats["stellarGravity"] == 0 assert stats["eligibleStellarAnchors"] == 1 assert stats["fallbackAnchors"] == 0 assert stats["globalAnchors"] == 0 + # The fixed-local diagnostic field remains (the Every-node worker and the + # dominant-star repulsion both consume it); only the *slider* floor was removed. + # At setting 0 the effective local setting (0) is below the fixed 48 reference, + # so the diagnostic correctly reports the local support as floor-backed. assert stats["stellarFloorActive"] is True assert stats["surfaceRepulsions"] == 1 - assert stats["maximumRepulsion"] > stats["maximumSampledAttraction"] > 0 + # With setting=0 the central field is now a real zero, so no attraction is sampled. + # The hard surface repulsion still produces a positive maximumRepulsion. + assert stats["maximumRepulsion"] > 0 + assert stats["maximumSampledAttraction"] == 0 assert stats["maximumNetRepulsion"] == pytest.approx(0.12) assert stats["minimumSurfaceNetRepulsion"] == pytest.approx(0.12) # The live Gravity-zero stellar floor still attracts; pressure exceeds that sampled @@ -10343,10 +10395,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=20260815-merge-ready-1'" + "'/v2-assets/engraphis-graph.js?v=20260831-galaxy-floor-fix-2'" ) assert d3 < force_graph < renderer - assert '/v2-assets/ledger.js?v=20260815-merge-ready-1' in markup + assert '/v2-assets/ledger.js?v=20260831-galaxy-floor-fix-2' 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()"): @@ -10631,6 +10683,152 @@ def test_physics_sliders_reheat_the_simulation_the_way_the_classic_renderer_does assert report["reducedMotion"] == 1, "reduced motion silently disabled live physics" +@requires_node +def test_spacetime_sliders_reach_d3_forces_in_non_galaxy_mode() -> None: + """The four spacetime sliders (galactic gravity, black hole mass, local solar gravity, space + damping) must reach d3 forces in non-galaxy mode. Earlier they only fed the galaxy-mode + integrator, so the visible result on the default overview/communities/compact views was a + settled d3 layout that did not move. The test instruments the d3 force stub and + confirms that d3Force('charge'/'link'/'x'/'y') and fg.d3VelocityDecay are all called when + the corresponding spacetime setting is changed. + """ + report = _run_engine( + """ + const strengthForce = () => ({ + strength(value) { + if (arguments.length) { this.strengthValue = value; return this; } + return this.strengthValue; + }, + }); + globalThis.d3 = { + forceManyBody: strengthForce, + forceLink: () => ({ + id(value) { + if (arguments.length) { this.idValue = value; return this; } + return this.idValue; + }, + distance(value) { + if (arguments.length) { this.distanceValue = value; return this; } + return this.distanceValue; + }, + strength(value) { + if (arguments.length) { this.strengthValue = value; return this; } + return this.strengthValue; + }, + }), + forceX: target => { + const force = strengthForce(); + force.target = target; + return force; + }, + forceY: target => { + const force = strengthForce(); + force.target = target; + return force; + }, + forceCollide: () => ({ iterations(value) { + if (arguments.length) { this.iterationsValue = value; return this; } + return this.iterationsValue; + } }), + }; + const api = G.create(el, {}); + api.setPreset('compact'); + api.setData(chain(40)); + calls.d3Force = 0; + const before = { + d3ForceCalls: calls.d3Force || 0, + velocityDecaySet: 0, + }; + const f = store.d3Forces || {}; + if (fg.d3VelocityDecay) before.velocityDecaySet = 1; + const x = f.x, y = f.y, charge = f.charge, link = f.link; + const beforeX = x && x.strength, beforeY = y && y.strength, beforeCharge = charge && charge.strength; + + const snapshotForce = (key, sample) => { + const force = (store.d3Forces || {})[key]; + if (!force) return null; + const value = typeof force.strength === 'function' ? force.strength() : force.strength; + return typeof value === 'function' ? value(sample || { source: 'n0', target: 'n1' }) : value; + }; + const result = {}; + const baselineSettings = { + gravitationalConstant: 1, blackHoleMass: 1, + localGravitationalConstant: 1, damping: 1, + }; + ['gravitationalConstant', 'blackHoleMass', 'localGravitationalConstant', 'damping'] + .forEach((key) => { + const before = calls.d3Force || 0; + const callResult = { error: null }; + try { + api.setSettings(baselineSettings); + api.setSettings({ [key]: key === 'blackHoleMass' ? 400 : 150 }); + const after = calls.d3Force || 0; + callResult.reheated = after > before; + callResult.storeD3VelocityDecay = store.d3VelocityDecay; + callResult.chargeStrength = snapshotForce('charge'); + callResult.linkStrength = snapshotForce('link', { source: 'n0', target: 'n1' }); + callResult.xStrength = snapshotForce('x'); + callResult.yStrength = snapshotForce('y'); + } catch (error) { + callResult.error = String(error); + } + result[key] = callResult; + }); + // Also exercise the lower end of the damping range so the full 0..15 visible range + // reaches the engine (the d700bba fix clamped to 1..15, so damping=0 was inert). + const lowDamping = { error: null }; + try { + api.setSettings({ damping: 0 }); + lowDamping.storeD3VelocityDecay = store.d3VelocityDecay; + } catch (error) { + lowDamping.error = String(error); + } + result.dampingLow = lowDamping; + emit(result); + """ + ) + # Every spacetime setting must trigger a reheat (existing LAYOUT_KEYS contract covers + # the reheat path; we just confirm each setting lands on the reheat path). + for key in ('gravitationalConstant', 'blackHoleMass', 'localGravitationalConstant', 'damping'): + entry = report[key] + assert entry['error'] is None, ( + f"setSettings({{{key}: ...}}) raised: {entry['error']}" + ) + assert entry['reheated'] is True, f"setSettings({{{key}: ...}}) did not reheat" + # These are numeric observations from the stubbed D3 forces, not source-shape checks: + # Galactic gravity is attractive, so a saturated multiplier doubles compact's 0.26 + # origin-centering strength while the separate repel control keeps charge at -42. + assert report['gravitationalConstant']['chargeStrength'] == pytest.approx(-42) + assert report['gravitationalConstant']['xStrength'] == pytest.approx(0.52) + assert report['gravitationalConstant']['yStrength'] == pytest.approx(0.52) + # A saturated local multiplier doubles the unit-strength chain link, and a saturated + # black-hole multiplier independently doubles compact's 0.26 origin-centering strength. + assert report['localGravitationalConstant']['linkStrength'] == pytest.approx(2) + assert report['blackHoleMass']['xStrength'] == pytest.approx(0.52) + assert report['blackHoleMass']['yStrength'] == pytest.approx(0.52) + # damping is a *multiplier* on the size-aware baseline (0.38 small / 0.45 large). At the + # upper end of the slider (15) the d3 velocityDecay reaches the 0.85 ceiling. At the lower + # end (0) it reaches the 0.05 floor. The D3 stubs expose the normal strength() getter, so + # snapshotForce invokes it before evaluating a per-link strength callback. + assert report['damping']['storeD3VelocityDecay'] == pytest.approx(0.85, abs=1e-9), ( + f"damping=150 (saturated to 15) must yield store.d3VelocityDecay=0.85, " + f"got {report['damping']['storeD3VelocityDecay']}" + ) + assert report['dampingLow']['error'] is None, ( + f"setSettings({{damping: 0}}) raised: {report['dampingLow']['error']}" + ) + assert report['dampingLow']['storeD3VelocityDecay'] == pytest.approx(0.05, abs=1e-9), ( + f"damping=0 must reach the 0.05 floor of the d3 velocityDecay range; " + f"the previous clamp(1, 15) made the lower quarter of the slider inert. " + f"got {report['dampingLow']['storeD3VelocityDecay']}" + ) + # The D3 stand-ins above make the strength assertions exercise the same setter/getter paths + # that the browser's force constructors expose, while the velocityDecay assertion covers the + # force-graph setting that is not represented in store.d3Forces. + + + + @requires_node def test_full_graph_within_the_force_budget_keeps_centre_gravity_live() -> None: """Full mode must not turn a normal large workspace into a pinned, inert ring. @@ -11520,3 +11718,218 @@ def test_pointer_hit_area_rejects_unpositioned_nodes() -> None: assert "!Number.isFinite(node.x)" in pointer assert "!Number.isFinite(node.y)" in pointer assert "Number.isFinite(node.radius)" in pointer + + + +@requires_node +def test_every_node_worker_consumes_all_full_mode_spacetime_controls() -> None: + """Every-node full mode must visibly consume each control exposed by the dashboard.""" + report = _run_every_worker( + """ + const nodes = Array.from({ length: 10 }, (_, index) => ({ + id: `node-${index}`, community_id: index < 5 ? 'a' : 'b', + degree: index % 3 + 1, + })); + const links = nodes.slice(1).map((node, index) => ({ + source: nodes[index].id, target: node.id, weight: index + 1, + })); + const waitForFit = start => new Promise(resolve => { + const poll = () => { + const final = messages.slice(start).find(item => item.type === 'layout' && item.fit === true); + if (final) resolve(Array.from(final.positions)); + else setTimeout(poll, 1); + }; + poll(); + }); + (async () => { + self.onmessage({ data: { type: 'prepare', payload: { nodes, links } } }); + const baseline = await waitForFit(0); + const changes = {}; + for (const [key, value] of [ + ['gravitationalConstant', 1.8], ['blackHoleMass', 1.8], + ['localGravitationalConstant', 1.8], ['damping', 8], ['springStiffness', 2.4], + ]) { + const start = messages.length; + self.onmessage({ data: { type: 'settings', settings: { [key]: value }, relayout: true, fit: true } }); + const positions = await waitForFit(start); + changes[key] = Math.max(...positions.map((item, index) => Math.abs(item - baseline[index]))); + } + emit({ changes }); + })(); + """ + ) + assert all(delta > 1e-5 for delta in report["changes"].values()), report + + +@requires_node +def test_live_orbit_phase_uses_the_budgeted_relative_speed() -> None: + """Live phase advancement must agree with the capped velocity it emits.""" + report = _run_node( + """ + 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: 6, radius: 5, + x: 120, y: 0, vx: 0, vy: 47 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', + orbit_tier: 1, orbit_radius: 30, gravity_mass: 1, radius: 2, + x: 150, y: 0, vx: 0, vy: 47 }, + ]; + const options = { + gravity: 48, softening: 32, centralSoftening: 40, + localGravitySetting: 48, orbitalSpeed: 400, + layoutSeed: 19, timestep: 1, speedLimit: 48, + }; + const before = Math.atan2(nodes[2].y - nodes[1].y, nodes[2].x - nodes[1].x); + I.applyGalaxyOrbitalSpeedControl(nodes, options); + const after = Math.atan2(nodes[2].y - nodes[1].y, nodes[2].x - nodes[1].x); + const radius = Math.hypot(nodes[2].x - nodes[1].x, nodes[2].y - nodes[1].y); + const relativeSpeed = Math.hypot(nodes[2].vx - nodes[1].vx, nodes[2].vy - nodes[1].vy); + const phaseDelta = Math.abs(Math.atan2(Math.sin(after - before), Math.cos(after - before))); + emit({ phaseDelta, radius, phaseSpeed: phaseDelta * radius, relativeSpeed }); + """ + ) + assert report["phaseSpeed"] == pytest.approx(report["relativeSpeed"], rel=1e-9), report + + +@requires_node +def test_kinematic_carrier_is_capped_before_local_motion_budgeting() -> None: + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', gravity_mass: 8, 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: 5, + x: 120, y: 0, vx: 0, vy: 0 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', + orbit_tier: 1, gravity_mass: 1, radius: 2, + x: 150, y: 0, vx: 0, vy: 0 }, + ]; + I.advanceGalaxyKinematicOrbits(nodes, { + gravity: 48, softening: 32, centralSoftening: 40, localSoftening: 12, + orbitalSpeed: 400, layoutSeed: 19, timestep: .032, + }); + emit({ carrierSpeed: Math.hypot(nodes[1].vx, nodes[1].vy), + localSpeed: Math.hypot(nodes[2].vx - nodes[1].vx, + nodes[2].vy - nodes[1].vy), finite: nodes.every(node => + [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); + """ + ) + assert report["finite"] is True + assert report["carrierSpeed"] <= 48 + 1e-9 + assert report["localSpeed"] <= 48 + 1e-9 + + +@requires_node +def test_kinematic_local_velocity_budget_uses_one_phase_speed() -> None: + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', gravity_mass: 8, 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: 5, + x: 120, y: 0, vx: 0, vy: 0 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', + orbit_tier: 1, gravity_mass: 1, radius: 2, + x: 150, y: 0, vx: 0, vy: 0 }, + ]; + const options = { + gravity: 48, softening: 32, centralSoftening: 40, localSoftening: 12, + orbitalSpeed: 400, layoutSeed: 19, timestep: .032, speedLimit: 48, + }; + I.advanceGalaxyKinematicOrbits(nodes, options); + const before = nodes[2].__galaxyKinematicLocalOrbit.angle; + I.advanceGalaxyKinematicOrbits(nodes, options); + const after = nodes[2].__galaxyKinematicLocalOrbit.angle; + const radius = nodes[2].__galaxyKinematicLocalOrbit.radius; + const phaseDelta = Math.abs(Math.atan2(Math.sin(after - before), Math.cos(after - before))); + const relativeSpeed = Math.hypot(nodes[2].vx - nodes[1].vx, + nodes[2].vy - nodes[1].vy); + emit({ maximumSpeed: Math.max(...nodes.map(node => Math.hypot(node.vx, node.vy))), + phaseSpeed: phaseDelta * radius / options.timestep, relativeSpeed, + finite: nodes.every(node => + [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); + """ + ) + assert report["finite"] is True + assert report["maximumSpeed"] <= 48 + 1e-9 + assert report["phaseSpeed"] == pytest.approx(report["relativeSpeed"], rel=1e-9), report + + +@requires_node +def test_live_carrier_is_capped_before_local_motion_budgeting() -> None: + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 8, 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: 5, + x: 120, y: 0, vx: 48, vy: 0 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', + orbit_tier: 1, gravity_mass: 1, radius: 2, + x: 150, y: 0, vx: 48, vy: 0 }, + ]; + I.supportGalaxyCarrierOrbits(nodes, { + gravity: 48, softening: 32, centralSoftening: 40, + orbitalSpeed: 400, layoutSeed: 19, timestep: .032, speedLimit: 48, + }); + emit({ carrierSpeed: Math.hypot(nodes[1].vx, nodes[1].vy), + finite: nodes.every(node => + [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); + """ + ) + assert report["finite"] is True + assert report["carrierSpeed"] <= 48 + 1e-9 + + +@requires_node +def test_oversized_full_layout_consumes_every_spacetime_control() -> None: + """The deterministic full-layout fallback must not make advanced controls inert.""" + report = _run_engine( + """ + const api = G.create(el, {}); + api.setPreset('compact'); + api.setRenderMode('full'); + api.setData(chain(600)); + const baseline = store.graphData.nodes.map(node => [node.x, node.y]); + const settings = { + gravitationalConstant: 2, + blackHoleMass: 2, + localGravitationalConstant: 2, + damping: 15, + springStiffness: 100 / 32, + }; + const changes = {}; + Object.entries(settings).forEach(([key, value]) => { + api.setSettings({ [key]: value }); + changes[key] = Math.max(...store.graphData.nodes.map((node, index) => + Math.hypot(node.x - baseline[index][0], node.y - baseline[index][1]))); + }); + api.setSettings({ gravitationalConstant: 0.1, blackHoleMass: 1, + localGravitationalConstant: 1, damping: 1, springStiffness: 32 }); + const low = store.graphData.nodes.map(node => [node.x, node.y]); + api.setSettings({ gravitationalConstant: 0.2 }); + const subQuarterDelta = Math.max(...store.graphData.nodes.map((node, index) => + Math.hypot(node.x - low[index][0], node.y - low[index][1]))); + emit({ ...changes, subQuarterDelta, + finite: store.graphData.nodes.every(node => [node.x, node.y] + .every(Number.isFinite)) }); + """ + ) + for key in ( + "gravitationalConstant", + "blackHoleMass", + "localGravitationalConstant", + "damping", + "springStiffness", + ): + assert report[key] > 1e-6, f"static full layout ignored {key}" + assert report["subQuarterDelta"] > 1e-6 + assert report["finite"] is True diff --git a/tests/test_graph_every_asset.py b/tests/test_graph_every_asset.py index c984ec55..f8f34320 100644 --- a/tests/test_graph_every_asset.py +++ b/tests/test_graph_every_asset.py @@ -168,6 +168,73 @@ def test_worker_settings_relayout_and_reheat_move_nodes() -> None: assert report["reheated_streams"] is True +def test_worker_settling_resistance_keeps_high_end_distinct() -> None: + script = """ +const nodes = [ + { id: 'a', community_id: 'c' }, + { id: 'b', community_id: 'c' }, + { id: 'c', community_id: 'c' }, +]; +send({ type: 'prepare', payload: { nodes, links: [{ source: 'a', target: 'b' }] } }); +const waitForLayouts = (count, callback) => { + const tick = () => { + if (all('layout').length >= count) return callback(); + setTimeout(tick, 10); + }; + tick(); +}; +waitForLayouts(1, () => { + const samples = {}; + const next = (resistance, callback) => { + const before = all('layout').length; + send({ type: 'settings', settings: { damping: resistance }, relayout: true, fit: false }); + // settings emits the reseeded preview immediately; wait for the first relaxed layout. + waitForLayouts(before + 2, () => { + samples[resistance] = latest('layout').positions; + callback(); + }); + }; + next(13, () => next(14, () => next(15, () => console.log(JSON.stringify({ samples }))))); +}); +""" + report = _run_worker(script) + samples = report["samples"] + assert samples["13"] != samples["14"] + assert samples["14"] != samples["15"] + + +def test_worker_honors_zero_link_spring_stiffness() -> None: + """A zero Link spring value must remove pair attraction, not leave a residual floor.""" + script = """ +const nodes = [ + { id: 'a', community_id: 'c' }, + { id: 'b', community_id: 'c' }, +]; +send({ type: 'prepare', payload: { nodes, links: [{ source: 'a', target: 'b' }] } }); +const waitForFit = (start, callback) => { + const tick = () => { + const fit = messages.slice(start).find(item => item.type === 'layout' && item.fit === true); + if (fit) return callback(fit.positions); + setTimeout(tick, 10); + }; + tick(); +}; +setTimeout(() => { + const seeded = latest('preview').positions.slice(); + const start = messages.length; + send({ type: 'settings', settings: { + repel: 0, gravity: 0, springStiffness: 0, damping: 1, + }, relayout: true, fit: true }); + waitForFit(start, positions => { + const delta = Math.max(...positions.map((value, index) => Math.abs(value - seeded[index]))); + console.log(JSON.stringify({ delta })); + }); +}, 50); +""" + report = _run_worker(script) + assert report["delta"] == 0 + + def test_renderer_is_webgl2_only_without_live_simulation_or_canvas_fallback() -> None: renderer = RENDERER.read_text(encoding="utf-8") assert "getContext('webgl2'" in renderer @@ -306,6 +373,22 @@ def test_renderer_exposes_capacity_and_every_preset() -> None: assert "MAP_SCALE" in worker # the map-spread constant +def test_ledger_keeps_orbit_pause_for_full_quality_galaxy_scenes() -> None: + """Full authored Galaxy scenes use the orbital engine and retain their pause control.""" + ledger = LEDGER.read_text(encoding="utf-8") + assert "graphGalaxyQuality: false" in ledger + assert "state.graphGalaxyQuality = galaxyQuality;" in ledger + assert ledger.count( + "const orbitCapable = galaxy && (!full || state.graphGalaxyQuality);" + ) == 2 + assert "if (orbitPauseRow) orbitPauseRow.hidden = !orbitCapable;" in ledger + assert "const springCapable = galaxy || (full && !state.graphGalaxyQuality);" in ledger + assert "springLabel.parentElement.hidden = !springCapable;" in ledger + assert "const galaxyRenderer = galaxy && (!full || state.graphGalaxyQuality);" in ledger + assert "const everyRenderer = full && !state.graphGalaxyQuality;" in ledger + assert "'Responsive force controls'" in ledger + + def test_worker_untagged_nodes_share_one_district_not_n_singletons() -> None: """Untagged graphs must not make centroid separation quadratic in node count.""" script = (