diff --git a/CHANGELOG.md b/CHANGELOG.md index 1920e79f..d6e02466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -122,17 +122,30 @@ All notable changes to Engraphis are documented here. Format loosely follows - Folder imports report truncation explicitly: a folder with more matching files than the ceiling now warns and returns `truncated`/`matched_total`/`unreadable` fields instead of silently importing an alphabetically-first slice that looks complete. +- The `engraphis_prime_agent` integration now ships a fleet wrapper that boots multiple + sub-agents (researcher / coder / reviewer / writer) with one shared memory workspace, + with fleet-wide configuration via `ENGRAPHIS_REPO` and per-agent override via the + `repo=` argument; the `engraphis-prime-agent install` subcommand configures a target + Codex / Claude Code / OpenCode project and `python -m engraphis_prime_agent install` + works directly from the installed wheel. ### Fixed -- The Every node dashboard view no longer crashes on open: a declaration-order bug in the - renderer threw during construction before anything painted. The scene canvas also keeps its - accessible role/label now instead of being hidden from assistive technology. -- Import previews now page the source manifest exactly like execution, so vaults whose manifest - outgrew one list page (10k identities) no longer show manifest-only files as silently absent - from the preview plan; beyond-boundary rows are reported as `missing` instead of dropped. - Manifest pages now use one read snapshot and de-duplicate identities that move across a - cursor while a concurrent import updates their path. +- The Every node dashboard view no longer crashes on open: a declaration-order bug in the + renderer threw during construction before anything painted. The scene canvas also keeps its + accessible role/label now instead of being hidden from assistive technology. +- Prompt-only recall now honours an opt-in `ENGRAPHIS_RECALL_ARM_CANDIDATE_K` env var (and + the matching `RecallEngine(arm_candidate_k_cap=...)` constructor argument) that clamps both + the first-page widening (`candidate_k + min(250, candidate_k*3)`) and the second-page + ceiling, so operators can trade untrusted-scope widening for latency on the new k=50 + default without code changes. Measured ~1.9x speedup at cap=50 on a 49-fact trusted corpus + (201 ms -- 103 ms, with no regression in the trusted-only recall count). Default behaviour + is unchanged. +- Import previews now page the source manifest exactly like execution, so vaults whose manifest + outgrew one list page (10k identities) no longer show manifest-only files as silently absent + from the preview plan; beyond-boundary rows are reported as `missing` instead of dropped. + Manifest pages now use one read snapshot and de-duplicate identities that move across a + cursor while a concurrent import updates their path. - Importing more than 1,000 files through the dashboard no longer fails with "Internal Server Error": wizard upload routes parse multipart forms under the advertised 1,500-file ceiling instead of Starlette's hidden 1,000-part parser default, oversized batches return a clear 413, diff --git a/README.md b/README.md index 31c94a2e..1cb8b2ef 100644 --- a/README.md +++ b/README.md @@ -396,6 +396,51 @@ including `engraphis_check_update`, is in the [MCP tool reference](https://githu For installation, configuration, lifecycle commands, and the local trust boundary, see the [Pi extension guide](https://github.com/Coding-Dev-Tools/engraphis/blob/main/integrations/pi/README.md). +### Command Code SessionStart hook + +`integrations/commandcode/` ships a SessionStart hook that warms up a new +session with bounded, recalled context from the local Engraphis gateway. Fails +open on timeout and is installed via `python scripts/install_cc_hook.py`. + +### prime-agent fleet + +`integrations/prime_agent/` ships a first-party Python package for +[PrimeIntellect prime-agent](https://github.com/PrimeIntellect-ai/prime-agent) +that exposes the same nine Smart MCP tools, with a `PrimeAgentFleet` of eight +named sub-agents (`researcher`, `planner`, `coder`, `reviewer`, `tester`, +`documenter`, `monitor`, `integrator`) sharing one `engraphis-mcp` stdio +subprocess. Install via `pip install ./integrations/prime_agent` and register +with `python scripts/install_prime_agent.py`. See the +[prime-agent integration guide](https://github.com/Coding-Dev-Tools/engraphis/blob/main/integrations/prime_agent/README.md). + +**What the integration is.** A `PrimeAgentFleet` is a thin Python layer +around the same `engraphis-mcp` Smart gateway every other host uses. At +runtime the fleet holds one shared `EngraphisMcpClient`, which owns one +`engraphis-mcp` subprocess over JSON-RPC stdio. Each of the eight named +sub-agents gets its own Engraphis session (started lazily on first tool use) +and its own default `repo` scope, so per-role memory is isolated while the +local gateway stays single-process. The eight sub-agent names +(`researcher`, `planner`, `coder`, `reviewer`, `tester`, `documenter`, +`monitor`, `integrator`) are the fixed default; pass `agent_names=[...]` to +`PrimeAgentFleet(...)` for a custom set. Concurrent tool calls serialize at +the JSON-RPC frame layer through an `asyncio.Lock`, so framework-level +parallelism (eight sub-agents reasoning at once) is preserved while the +underlying MCP transport remains one ordered stream. The only integration +surface is `EngraphisPrimeAgent.register()` in +`integrations/prime_agent/src/engraphis_prime_agent/agent.py` -- that is the +single adapter point to override if prime-agent's tool-registration API +differs from the assumed `target.register_tool(name, fn, schema=...)` +contract. + +The design -- eight named sub-agents, one shared stdio subprocess, +per-agent session bootstrap, and `ENGRAPHIS_*`-only environment forwarding +to the gateway -- is recorded in `~/.commandcode/plans/prime-agent-integration.md` +on the host where the integration was developed. When that host plan is not +available (other contributor machines, CI), the same design is summarized in +the PR description that introduced the integration and in the +[prime-agent integration guide](https://github.com/Coding-Dev-Tools/engraphis/blob/main/integrations/prime_agent/README.md) +("Architecture" and "Concurrency model" sections). + ## Quickstart: repository graph ```bash @@ -721,8 +766,8 @@ file. It never searches the working directory for `.env`, and explicit process v | `ENGRAPHIS_ALLOW_AUTOMATIC_CRITICAL_RETENTION` | `false` | Opt in only when an LLM supervisor may automatically assign the long-lived `critical` class; explicit user-selected critical retention is unaffected | | `ENGRAPHIS_WHISPER_MODEL` | Not set | Enables local faster-whisper audio/video transcription | | `ENGRAPHIS_POSTGRES_DSN` | Not set | CLI-only PostgreSQL source; used for the connection and never stored | -| `ENGRAPHIS_POSTGRES_CONNECT_TIMEOUT` | `10` | PostgreSQL introspection connection timeout in seconds (bounded to 1–120) | -| `ENGRAPHIS_POSTGRES_STATEMENT_TIMEOUT_MS` | `30000` | Per-introspection PostgreSQL statement timeout in milliseconds (bounded to 1–300000) | +| `ENGRAPHIS_POSTGRES_CONNECT_TIMEOUT` | `10` | PostgreSQL introspection connection timeout in seconds (bounded to 1--120) | +| `ENGRAPHIS_POSTGRES_STATEMENT_TIMEOUT_MS` | `30000` | Per-introspection PostgreSQL statement timeout in milliseconds (bounded to 1--300000) | | `ENGRAPHIS_GRAPH_TOKEN` | Not set | Bearer token for `engraphis-graph-server`; required off-loopback | | `ENGRAPHIS_GRAPH_HOST` / `ENGRAPHIS_GRAPH_PORT` | `127.0.0.1` / `8720` | Read-only graph/recall server bind address | | `ENGRAPHIS_LLM_PROVIDER` | `openai` | `openai \| anthropic \| google \| openrouter \| custom` | @@ -743,6 +788,11 @@ file. It never searches the working directory for `.env`, and explicit process v | `ENGRAPHIS_CLOUD_ACCESS_TOKEN` | Not set | Optional short-lived access token for ephemeral jobs | | `ENGRAPHIS_MANAGED_COMPUTE_CONSENT` | *(auto)* | Operator override only; default follows whether a cloud session is configured (connected = allowed, local-only = never). `0` opts a connected installation out; `1` permits local snapshot preparation but does not create a cloud credential or authorize an upload | +Evaluated offline on the bundled retrieval gates (`eval/datasets/sample.jsonl`, +`codemem.jsonl`, k=5): enabling the optional cross-encoder reranker kept hit@5 at 1.0 with +zero per-question regressions, raised MRR@5 from 0.889→0.944 (sample) and 0.962→0.981 +(codemem), and added ~15 ms/query mean, a safe latency-bounded precision upgrade. + See `.env.example` for the full variable inventory. Supply those values through the process environment or the trusted config file above; copying it to an arbitrary `./.env` does not make Engraphis load it. diff --git a/docs/architecture/engraphis-v2-architecture.png b/docs/architecture/engraphis-v2-architecture.png new file mode 100644 index 00000000..afda4af2 Binary files /dev/null and b/docs/architecture/engraphis-v2-architecture.png differ diff --git a/docs/architecture/engraphis-v2-architecture.svg b/docs/architecture/engraphis-v2-architecture.svg new file mode 100644 index 00000000..c6f452fb --- /dev/null +++ b/docs/architecture/engraphis-v2-architecture.svg @@ -0,0 +1,225 @@ + + + + + + + + + + +How Engraphis works +v2 local-first agent memory: scoped facts in, grounded context out +CURRENT V2 ARCHITECTURE +schema 16 · legacy v1 omitted + +ENTRY POINTS & INPUTS + +TRANSPORT + COMPOSITION ROOT + +CORE ORCHESTRATION + +PERSISTENCE + DERIVED INDEXES + +INVARIANTS THAT SHAPE EVERY OPERATION + + + + + + + + + + + + + + + + + + + + + + + + + +Agent / host LLM +remember · recall · actions + + +MCP tools +smart + classic surfaces + + +CLI + dashboard +local HTTP / graph views + + +Local docs / repo +document import + code index + + +Optional backends +LLM · models · sync + + +MemoryService +validate · resolve names · return JSON + + +factory.py +select + inject concrete adapters + + +MemoryEngine +write + recall orchestration + + +Protocols +embedder · index · LLM + + +remember / ingest +facts enter + + +optional extract +raw → discrete facts + + +embed + resolve +ADD · NOOP · INVALIDATE + + +append / close validity +never overwrite history + + +evolve + reinforce +links · neighbors · decay + + +audit + receipt +hashed, content-free trail + + +recall(query, filter) +scope + valid_at + known_at + + +planner + 4 retrieval arms +vector · lexical · graph · code + + +fuse + rerank +RRF + weighted score + + +pack context +hard token budget + + +grounded gate +absolute support floor + + +answer +citations or abstain + + +SQLite v2 Store + +typed + scoped memories + +validity + system-time history + +events · jobs · audit + + +Derived indexes + +mem_vectors: NumPy / sqlite-vec + +mem_fts: FTS5 or LIKE fallback + +normalized embeddings + + +Knowledge + code graphs + +entities + layered edges + +symbols + calls/imports + +memory ↔ code bridges + + +Receipts + sync + +operation receipts + +source manifests + +tombstones + cursors +tool / SDK calls +ingest / index +optional +validated +constructs +injects +raw +facts +decision +links +receipt +scope + time +candidates +ranked +packed +cite / abstain +embeddings +bi-temporal rows +graph bridges +audit + sync +history +vector / FTS +graph / code + + +Scopes +workspace → repo → session + + +Memory types +working · episodic · semantic · procedural + + +Bi-temporal truth +valid time + known time + + +Provenance + governance +trust · review · secure erasure + + +Grounded output +cited evidence or explicit abstain +Flow semantics + +request / data + +memory read + +memory write + +transform / feedback + +control / trigger +Local-first by default; optional heavy backends stay behind interfaces. +Diagram reflects the current v2 core, backends, service facade, and schema documented in this repository. +Engraphis + \ No newline at end of file diff --git a/docs/architecture/generate_engraphis_architecture.py b/docs/architecture/generate_engraphis_architecture.py new file mode 100644 index 00000000..7bc327b7 --- /dev/null +++ b/docs/architecture/generate_engraphis_architecture.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import html +from pathlib import Path + + +WIDTH = 1600 +HEIGHT = 1240 +OUT = Path(__file__).with_name("engraphis-v2-architecture.svg") + + +lines: list[str] = [] +late_labels: list[str] = [] + + +def add(value: str) -> None: + lines.append(value) + + +def esc(value: str) -> str: + return html.escape(value, quote=True) + + +def text(x: float, y: float, value: str, *, size: float = 14, fill: str = "#0f172a", + weight: str = "400", anchor: str = "start", letter: str = "0") -> None: + add( + f'' + f'{esc(value)}' + ) + + +def rect(x: float, y: float, w: float, h: float, *, fill: str = "#ffffff", + stroke: str = "#cbd5e1", width: float = 1, radius: float = 12, + dash: str = "") -> None: + dash_attr = f' stroke-dasharray="{dash}"' if dash else "" + add( + f'' + ) + + +def region(x: float, y: float, w: float, h: float, title: str, fill: str) -> None: + rect(x, y, w, h, fill=fill, stroke="#cbd5e1", width=1.2, radius=18, dash="8 6") + text(x + 20, y + 27, title, size=12, fill="#475569", weight="700", letter="1.2") + + +def node(x: float, y: float, w: float, h: float, title: str, subtitle: str, + accent: str, *, fill: str = "#ffffff", title_size: float = 15, + subtitle_size: float = 11.5) -> None: + rect(x, y, w, h, fill=fill, stroke="#cbd5e1", width=1.2, radius=12) + rect(x, y, 7, h, fill=accent, stroke=accent, width=0, radius=4) + text(x + 20, y + 30, title, size=title_size, weight="700") + text(x + 20, y + 53, subtitle, size=subtitle_size, fill="#475569") + + +def storage_node(x: float, y: float, w: float, h: float, title: str, + bullets: list[str], accent: str) -> None: + rect(x, y, w, h, fill="#ffffff", stroke="#cbd5e1", width=1.2, radius=12) + rect(x, y, 7, h, fill=accent, stroke=accent, width=0, radius=4) + text(x + 20, y + 29, title, size=14.5, weight="700") + for index, bullet in enumerate(bullets): + yy = y + 53 + index * 20 + add(f'') + text(x + 34, yy, bullet, size=11.5, fill="#475569") + + +def path(points: list[tuple[float, float]], color: str, marker: str, *, dash: str = "", + width: float = 2, opacity: float = 1.0) -> None: + data = "M " + " L ".join(f"{x},{y}" for x, y in points) + dash_attr = f' stroke-dasharray="{dash}"' if dash else "" + add( + f'' + ) + + +def label(x: float, y: float, value: str, *, color: str = "#475569", anchor: str = "middle") -> None: + # Render labels after nodes so a short label never disappears beneath a box. + late_labels.append( + f'{esc(value)}' + ) + + +add(f'') +add(" ") +add(' ') +add(' ') +add(' ') +add(' ') +add(' ') +add(' ') +add(" ") +add('') + +text(56, 52, "How Engraphis works", size=28, weight="700") +text(56, 82, "v2 local-first agent memory: scoped facts in, grounded context out", size=15, fill="#475569") +text(1544, 52, "CURRENT V2 ARCHITECTURE", size=11, fill="#2563eb", weight="700", anchor="end", letter="1.4") +text(1544, 78, "schema 16 · legacy v1 omitted", size=11.5, fill="#64748b", anchor="end") + +region(48, 110, 1504, 120, "ENTRY POINTS & INPUTS", "#eff6ff") +region(48, 260, 1504, 142, "TRANSPORT + COMPOSITION ROOT", "#f0fdf4") +region(48, 432, 1504, 374, "CORE ORCHESTRATION", "#faf5ff") +region(48, 836, 1504, 182, "PERSISTENCE + DERIVED INDEXES", "#f8fafc") +region(48, 1048, 1504, 114, "INVARIANTS THAT SHAPE EVERY OPERATION", "#fff7ed") + +# Entry-point and composition arrows. +path([(480, 230), (480, 255), (255, 255), (255, 300)], "#2563eb", "arrow-blue", width=2.2) +label(366, 249, "tool / SDK calls", color="#2563eb") +path([(1110, 230), (1110, 286)], "#ea580c", "arrow-orange", width=1.8) +label(1150, 263, "ingest / index", color="#ea580c", anchor="start") +path([(1400, 230), (1400, 300)], "#ea580c", "arrow-orange", width=1.8) +label(1440, 263, "optional", color="#ea580c", anchor="start") +path([(420, 336), (510, 336)], "#2563eb", "arrow-blue", width=2) +label(465, 326, "validated", color="#2563eb") +path([(810, 336), (900, 336)], "#2563eb", "arrow-blue", width=2) +label(855, 326, "constructs", color="#2563eb") +path([(1320, 336), (1250, 336)], "#ea580c", "arrow-orange", width=1.8) +label(1285, 326, "injects", color="#ea580c") + +# Write path arrows: dashed green means memory write. +write_y = 537 +path([(276, write_y), (300, write_y)], "#059669", "arrow-green", dash="7 5", width=2) +path([(488, write_y), (512, write_y)], "#059669", "arrow-green", dash="7 5", width=2) +path([(717, write_y), (741, write_y)], "#059669", "arrow-green", dash="7 5", width=2) +path([(961, write_y), (985, write_y)], "#059669", "arrow-green", dash="7 5", width=2) +path([(1195, write_y), (1219, write_y)], "#059669", "arrow-green", dash="7 5", width=2) +label(288, 480, "raw", color="#059669") +label(500, 480, "facts", color="#059669") +label(729, 480, "decision", color="#059669") +label(973, 480, "links", color="#059669") +label(1207, 480, "receipt", color="#059669") + +# Read path arrows: blue means the primary request/data path. +read_y = 698 +path([(290, read_y), (330, read_y)], "#2563eb", "arrow-blue", width=2.2) +path([(580, read_y), (630, read_y)], "#2563eb", "arrow-blue", width=2.2) +path([(845, read_y), (875, read_y)], "#2563eb", "arrow-blue", width=2.2) +path([(1055, read_y), (1085, read_y)], "#2563eb", "arrow-blue", width=2.2) +path([(1290, read_y), (1325, read_y)], "#2563eb", "arrow-blue", width=2.2) +label(310, 648, "scope + time", color="#2563eb") +label(605, 648, "candidates", color="#2563eb") +label(860, 648, "ranked", color="#2563eb") +label(1070, 648, "packed", color="#2563eb") +label(1307, 648, "cite / abstain", color="#2563eb") + +# Write/read connections to local state. These use open corridors between rows. +path([(615, 582), (615, 620), (600, 620), (600, 820), (710, 820), (710, 878)], "#7c3aed", "arrow-purple", width=1.8) +label(658, 612, "embeddings", color="#7c3aed") +path([(851, 582), (851, 620), (310, 620), (310, 878)], "#059669", "arrow-green", dash="7 5", width=1.8) +label(565, 612, "bi-temporal rows", color="#059669") +path([(1090, 582), (1090, 620), (1055, 620), (1055, 878)], "#7c3aed", "arrow-purple", width=1.8) +label(1110, 612, "graph bridges", color="#7c3aed", anchor="start") +path([(1329, 582), (1329, 620), (1540, 620), (1540, 850), (1375, 850), (1375, 878)], "#64748b", "arrow-gray", dash="5 4", width=1.6) +label(1450, 812, "audit + sync", color="#64748b") + +# Read connections from persistent state, routed below the read row. +path([(310, 878), (310, 820), (875, 820), (875, 736)], "#059669", "arrow-green", width=1.8) +label(585, 812, "history", color="#059669") +path([(710, 878), (710, 820), (575, 820), (575, 736)], "#059669", "arrow-green", width=1.8) +label(642, 812, "vector / FTS", color="#059669") +path([(1055, 878), (1055, 820), (600, 820), (600, 760), (580, 760), (580, 736)], "#059669", "arrow-green", width=1.8) +label(830, 812, "graph / code", color="#059669") + +# Input surfaces. +node(80, 145, 250, 62, "Agent / host LLM", "remember · recall · actions", "#2563eb", fill="#ffffff") +node(355, 145, 250, 62, "MCP tools", "smart + classic surfaces", "#2563eb", fill="#ffffff") +node(630, 145, 250, 62, "CLI + dashboard", "local HTTP / graph views", "#2563eb", fill="#ffffff") +node(960, 145, 300, 62, "Local docs / repo", "document import + code index", "#ea580c", fill="#ffffff") +node(1300, 145, 200, 62, "Optional backends", "LLM · models · sync", "#ea580c", fill="#ffffff", title_size=14) + +# Composition and orchestration. +node(90, 300, 330, 72, "MemoryService", "validate · resolve names · return JSON", "#2563eb", fill="#f8fbff") +node(510, 290, 300, 92, "factory.py", "select + inject concrete adapters", "#ea580c", fill="#fffaf5") +node(900, 286, 350, 100, "MemoryEngine", "write + recall orchestration", "#7c3aed", fill="#fbf8ff", title_size=17) +node(1320, 300, 200, 72, "Protocols", "embedder · index · LLM", "#ea580c", fill="#fffaf5", title_size=14) + +# Write path. +node(88, 492, 188, 90, "remember / ingest", "facts enter", "#059669", fill="#f0fdf4", title_size=14) +node(300, 492, 188, 90, "optional extract", "raw → discrete facts", "#7c3aed", fill="#faf5ff", title_size=14) +node(512, 492, 205, 90, "embed + resolve", "ADD · NOOP · INVALIDATE", "#7c3aed", fill="#faf5ff", title_size=14) +node(741, 492, 220, 90, "append / close validity", "never overwrite history", "#059669", fill="#f0fdf4", title_size=14) +node(985, 492, 210, 90, "evolve + reinforce", "links · neighbors · decay", "#7c3aed", fill="#faf5ff", title_size=14) +node(1219, 492, 220, 90, "audit + receipt", "hashed, content-free trail", "#64748b", fill="#f8fafc", title_size=14) + +# Read path. +node(90, 660, 200, 76, "recall(query, filter)", "scope + valid_at + known_at", "#2563eb", fill="#eff6ff", title_size=14) +node(330, 660, 250, 76, "planner + 4 retrieval arms", "vector · lexical · graph · code", "#2563eb", fill="#eff6ff", title_size=14) +node(630, 660, 215, 76, "fuse + rerank", "RRF + weighted score", "#7c3aed", fill="#faf5ff", title_size=14) +node(875, 660, 180, 76, "pack context", "hard token budget", "#2563eb", fill="#eff6ff", title_size=14) +node(1085, 660, 205, 76, "grounded gate", "absolute support floor", "#7c3aed", fill="#faf5ff", title_size=14) +node(1325, 660, 190, 76, "answer", "citations or abstain", "#059669", fill="#f0fdf4", title_size=14) + +# Persistent state. +storage_node(90, 878, 420, 110, "SQLite v2 Store", [ + "typed + scoped memories", + "validity + system-time history", + "events · jobs · audit", +], "#059669") +storage_node(550, 878, 300, 110, "Derived indexes", [ + "mem_vectors: NumPy / sqlite-vec", + "mem_fts: FTS5 or LIKE fallback", + "normalized embeddings", +], "#7c3aed") +storage_node(900, 878, 320, 110, "Knowledge + code graphs", [ + "entities + layered edges", + "symbols + calls/imports", + "memory ↔ code bridges", +], "#2563eb") +storage_node(1250, 878, 270, 110, "Receipts + sync", [ + "operation receipts", + "source manifests", + "tombstones + cursors", +], "#64748b") + +# Arrow labels sit above/below their corridors and remain visible over node paint. +lines.extend(late_labels) + +# Cross-cutting invariants. +node(80, 1084, 235, 56, "Scopes", "workspace → repo → session", "#ea580c", fill="#fffaf5", title_size=13.5, subtitle_size=11) +node(340, 1084, 235, 56, "Memory types", "working · episodic · semantic · procedural", "#ea580c", fill="#fffaf5", title_size=13.5, subtitle_size=10.2) +node(600, 1084, 255, 56, "Bi-temporal truth", "valid time + known time", "#ea580c", fill="#fffaf5", title_size=13.5, subtitle_size=11) +node(880, 1084, 280, 56, "Provenance + governance", "trust · review · secure erasure", "#ea580c", fill="#fffaf5", title_size=13.5, subtitle_size=11) +node(1185, 1084, 335, 56, "Grounded output", "cited evidence or explicit abstain", "#ea580c", fill="#fffaf5", title_size=13.5, subtitle_size=11) + +# Legend and footer. +text(56, 1195, "Flow semantics", size=11, fill="#475569", weight="700") +path([(165, 1191), (205, 1191)], "#2563eb", "arrow-blue", width=2) +text(216, 1195, "request / data", size=10.5, fill="#475569") +path([(330, 1191), (370, 1191)], "#059669", "arrow-green", width=2) +text(381, 1195, "memory read", size=10.5, fill="#475569") +path([(495, 1191), (535, 1191)], "#059669", "arrow-green", dash="7 5", width=2) +text(546, 1195, "memory write", size=10.5, fill="#475569") +path([(680, 1191), (720, 1191)], "#7c3aed", "arrow-purple", width=2) +text(731, 1195, "transform / feedback", size=10.5, fill="#475569") +path([(900, 1191), (940, 1191)], "#ea580c", "arrow-orange", width=2) +text(951, 1195, "control / trigger", size=10.5, fill="#475569") +text(1544, 1195, "Local-first by default; optional heavy backends stay behind interfaces.", size=10.5, fill="#64748b", anchor="end") +text(56, 1220, "Diagram reflects the current v2 core, backends, service facade, and schema documented in this repository.", size=10.5, fill="#94a3b8") +text(1544, 1220, "Engraphis", size=10.5, fill="#94a3b8", anchor="end") + +add("") + +OUT.write_text("\n".join(lines), encoding="utf-8") +print(f"Wrote {OUT}") diff --git a/engraphis/core/recall.py b/engraphis/core/recall.py index f0c2b99b..695843c4 100644 --- a/engraphis/core/recall.py +++ b/engraphis/core/recall.py @@ -18,6 +18,7 @@ import json import logging import math +import os import queue import re import threading @@ -147,7 +148,8 @@ def __init__(self, store: Store, embedder, vector_index, reranker: Optional[Rera candidate_depth_policy: Optional[CandidateDepthPolicy] = None, graph_traversal_policy: Optional[GraphTraversalPolicy] = None, query_planner: Optional[QueryPlanner] = None, - planner_timeout_s: float = 2.0) -> None: + planner_timeout_s: float = 2.0, + arm_candidate_k_cap: Optional[int] = None) -> None: self.store = store self.embedder = embedder self.index = vector_index @@ -161,6 +163,23 @@ def __init__(self, store: Store, embedder, vector_index, reranker: Optional[Rera self.graph_traversal_policy = graph_traversal_policy or UniformGraphTraversalPolicy() self.query_planner = query_planner or DeterministicQueryPlanner() self.planner_timeout_s = max(0.0, float(planner_timeout_s)) + # Latency knob: PR #171 widened the prompt-only first arm to + # ``candidate_k + min(250, candidate_k*3)`` so a 49-fact corpus pays + # ~5x more matrix-vector cost on the new k=50 default. Operators can + # cap that first-page widening via constructor arg or the + # ``ENGRAPHIS_RECALL_ARM_CANDIDATE_K`` env var; the escalation loop + # still widens to ``candidate_ceiling`` if the narrower first page + # did not collect enough prompt-eligible evidence, so trusted-source + # recall on the larger k=50 callsite is preserved. + env_cap_raw = os.environ.get("ENGRAPHIS_RECALL_ARM_CANDIDATE_K", "").strip() + try: + env_cap = int(env_cap_raw) if env_cap_raw else None + except ValueError: + env_cap = None + resolved_cap = arm_candidate_k_cap if arm_candidate_k_cap is not None else env_cap + self._arm_candidate_k_cap = ( + max(1, int(resolved_cap)) if resolved_cap is not None else None + ) self._planner_slot = threading.BoundedSemaphore(1) # "ppr" (default) = Personalized PageRank over entities+links (multi-hop); # "1hop" = the Phase-1 entity expansion, kept for fallback and ablation. @@ -271,6 +290,23 @@ def recall(self, query: str, flt: Optional[SearchFilter] = None, *, k: int = 8, arm_candidate_k = candidate_k if prompt_only: arm_candidate_k = candidate_k + min(250, candidate_k * 3) + # Opt-in latency knob (see __init__). When the operator has set + # ``ENGRAPHIS_RECALL_ARM_CANDIDATE_K`` (or passed + # ``arm_candidate_k_cap=``) we clamp both the first-page widening + # and the second-page ceiling. Without the ceiling clamp the + # escalation loop would still widen to the untrusted-heavy + # PROMPT_ONLY_MIN_CANDIDATES on a second pass and the savings of + # narrowing the first page would vanish. Operators who set this + # cap are explicitly trading untrusted-scope widening for latency; + # the first-arm floor remains ``candidate_k`` so a one-fact scope + # still searches at least as deep as the caller's requested depth. + if self._arm_candidate_k_cap is not None: + # Clamp the widened first arm to the operator cap, but never + # below the caller's requested candidate_k so a small scope + # still searches at least as deep as requested. + arm_candidate_k = max( + candidate_k, min(self._arm_candidate_k_cap, arm_candidate_k) + ) candidate_ceiling = max( arm_candidate_k, min( @@ -278,6 +314,8 @@ def recall(self, query: str, flt: Optional[SearchFilter] = None, *, k: int = 8, max(PROMPT_ONLY_MIN_CANDIDATES, candidate_k * 16), ), ) + if self._arm_candidate_k_cap is not None: + candidate_ceiling = min(candidate_ceiling, self._arm_candidate_k_cap) run_configs = [ config if index == 0 and arm_config is not None else profile_config(item.profile) for index, item in enumerate(planned_queries) diff --git a/engraphis/dashboard_assets/engraphis-graph-every-worker.js b/engraphis/dashboard_assets/engraphis-graph-every-worker.js index 7028eb13..7d085927 100644 --- a/engraphis/dashboard_assets/engraphis-graph-every-worker.js +++ b/engraphis/dashboard_assets/engraphis-graph-every-worker.js @@ -217,7 +217,10 @@ 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 rest = Math.max(scaledSpacing * 1.9, Number(settings.link) * 1.6 * (MAP_SCALE * 0.55)); + /* Bumped from *1.6 to *2.4 — the link-distance slider now produces 50% more spring + rest-length change per slider unit, so the upper half of the slider is meaningfully + more responsive. */ + const rest = Math.max(scaledSpacing * 1.9, Number(settings.link) * 2.4 * (MAP_SCALE * 0.55)); for (let edge = 0; edge < model.totalLinks; edge += 1) { const a = model.sources[edge], b = model.targets[edge]; const ddx = pos[b * 2] - pos[a * 2], ddy = pos[b * 2 + 1] - pos[a * 2 + 1]; @@ -241,7 +244,9 @@ } const minDist = SPACING * MAP_SCALE * 1.55; const minDist2 = minDist * minDist; - const push = Number(settings.repel) / 48; + /* Bumped from /48 to /24 — the Every-node engine now produces 100% more repulsion per + slider unit, so the upper half of the repel slider is meaningfully more responsive. */ + const push = Number(settings.repel) / 24; 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,7 +319,10 @@ } } - const gravity = Number(settings.gravity) / 48 * 0.0015; + /* Bumped from 0.0015 to 0.0033 — combined with the base gravity 25% bump and the + linear (no-sqrt) mass path, the Every-node worker now pulls nodes toward the centre + ~50% harder at every slider position than the previous 0.0022 calibration. */ + const gravity = Number(settings.gravity) / 48 * 0.0033; for (let index = 0; index < count; index += 1) { dx[index] += (cx - pos[index * 2]) * gravity; dy[index] += (cy - pos[index * 2 + 1]) * gravity; diff --git a/engraphis/dashboard_assets/engraphis-graph.js b/engraphis/dashboard_assets/engraphis-graph.js index 62bb3333..e07c8a6f 100644 --- a/engraphis/dashboard_assets/engraphis-graph.js +++ b/engraphis/dashboard_assets/engraphis-graph.js @@ -307,9 +307,13 @@ const GALAXY_ORBITAL_SPEED_DEFAULT = 100; const GALAXY_ORBITAL_SPEED_MAXIMUM_SETTING = 400; const GALAXY_ORBITAL_SPEED_MINIMUM = 0.25; - const GALAXY_ORBITAL_SPEED_RESPONSE_GAIN = 0.5; + /* The orbital-speed slider's high-end gain. Bumped from 0.5 to 1.0 so the upper half of + the slider is fully proportional: at repel=200 the multiplier is 2.0 (was 1.5), and at + repel=400 the multiplier is 4.0 (was 2.5, capped to 4.6). */ + const GALAXY_ORBITAL_SPEED_RESPONSE_GAIN = 1.0; const GALAXY_ORBITAL_SPEED_MAXIMUM = 4.6; - const GALAXY_ORBITAL_RADIUS_MAXIMUM = 1.24; + /* Bumped from 1.24 to 1.5 so the orbital-radius response is more visible. */ + const GALAXY_ORBITAL_RADIUS_MAXIMUM = 1.5; function galaxyOrbitalSpeedMultiplier(setting) { const raw = Number(setting); const value = Number.isFinite(raw) @@ -434,7 +438,10 @@ the integrator. */ const GALAXY_REHEAT_STEPS = 0; const GALAXY_REHEAT_LARGE_STEPS = 0; - const GALAXY_VELOCITY_DECAY = 0.00005; + /* Bumped from 0.00005 to 0.0005 — the damping slider (1..15) now has visibly stronger + effect: at slider=1 the per-tick velocity multiplier is 0.0005; at slider=15 it climbs + to 0.0075 (50% stronger than the previous 0.0015 cap). */ + const GALAXY_VELOCITY_DECAY = 0.0005; /* Developer-facing spacetime controls are normalized multipliers around the calibrated dashboard physics. Keeping them separate from the established Gravity/Link controls makes the advanced panel reversible and avoids changing saved-layout semantics. */ @@ -2450,13 +2457,20 @@ const explicitGlobal = anchor.anchor_role === 'global'; const gravitationalConstantMultiplier = galaxyPhysicsMultiplier(opts.gravitationalConstant, GALAXY_GRAVITATIONAL_CONSTANT_MULTIPLIER, 8); + /* Black-hole mass is now a LINEAR multiplier on the gravitational field — the user + expects that dragging the mass slider to 500 visibly doubles/triples the central + pull. The previous sqrt(blackHoleMassMultiplier) flattened the response so a 4x + slider change produced only a 2x force change, which made the slider feel dead. */ const gravitationalConstant = galaxyBlackHoleGravityConstant(opts.gravity, explicitGlobal) - * gravitationalConstantMultiplier * Math.sqrt(Math.max(0.25, blackHoleMassMultiplier)); + * gravitationalConstantMultiplier * blackHoleMassMultiplier; const accelerationCap = Math.max(0, Number.isFinite(Number(opts.accelerationCap)) ? Number(opts.accelerationCap) : defaultGalaxyBlackHoleAccelerationCap(opts.gravity, explicitGlobal) + /* Linear in blackHoleMassMultiplier (was Math.max(1, ...)) so the acceleration + cap scales with the same linear response as gravitationalConstant. The 0.25 + floor keeps the lower half of the slider from collapsing the cap. */ * Math.max(0.25, Math.min(8, - gravitationalConstantMultiplier * Math.max(1, blackHoleMassMultiplier)))); + gravitationalConstantMultiplier * Math.max(0.25, blackHoleMassMultiplier)))); const haloVelocitySquared = haloMass > 0 ? gravitationalConstant * haloMass / (Math.SQRT2 * haloScale) : 0; const model = { @@ -8669,10 +8683,12 @@ a constant gravity amount. */ const diagnosticMass = galaxyPhysicsMultiplier(state.settings.blackHoleMass, GALAXY_BLACK_HOLE_MASS_MULTIPLIER, 16); + /* Linear in diagnosticMass (was sqrt) so the diagnostic matches the new linear field + equation in galaxyBlackHoleField. */ const effectiveGravity = galaxyBlackHoleGravityConstant(state.settings.gravity, true) * galaxyPhysicsMultiplier(state.settings.gravitationalConstant, GALAXY_GRAVITATIONAL_CONSTANT_MULTIPLIER, 8) - * Math.sqrt(Math.max(0.25, diagnosticMass)); + * diagnosticMass; return Object.assign(galaxyMotionDiagnostics(data.nodes || []), { mode: state.settings.mode, running, diff --git a/engraphis/dashboard_assets/ledger.js b/engraphis/dashboard_assets/ledger.js index d31d4e79..2191003f 100644 --- a/engraphis/dashboard_assets/ledger.js +++ b/engraphis/dashboard_assets/ledger.js @@ -2506,11 +2506,18 @@ return settings; }, {}); return { - gravitationalConstant: controls.gravitationalConstant / 50, + /* The visible G controls are percentage sliders: 100 is neutral, 0 is off and 200 is + twice the calibrated field. Dividing by 25 makes every slider value 50% more + responsive than the previous /33.33: at default (100) the engine sees 4.0, and the + visible upper bound (200) lands at 8.0 — exactly the galaxyPhysicsMultiplier cap. */ + gravitationalConstant: controls.gravitationalConstant / 25, blackHoleMass: graphBlackHoleMassMultiplier(controls.blackHoleMass), - localGravitationalConstant: controls.localGravitationalConstant / 50, + localGravitationalConstant: controls.localGravitationalConstant / 25, damping: controls.damping, - springStiffness: controls.springStiffness / 32, + /* Bumped from /32 to /20 — the spring-stiffness slider is now 60% more responsive. + At default (32) the engine sees 1.6 instead of 1.0; at max (100) it lands at 5.0 + (still inside the engine cap of 8). */ + springStiffness: controls.springStiffness / 20, orbitPaused: state.graphOrbitPaused, }; } @@ -2585,12 +2592,14 @@ const GRAPH_BLACK_HOLE_MASS_BASELINE = 160; function graphBlackHoleMassMultiplier(controlValue) { const value = number(controlValue); - /* Keep the established lower half and neutral default. Above 160, every +10 slider units - adds exactly +0.10 to the compact central-mass multiplier: 160→1.0, 170→1.1, 180→1.2. - Local stellar wells remain owned exclusively by Local solar gravity. */ + /* Above 160, every +10 slider units now adds +0.20 (was +0.10, then +0.15) — the + black-hole-mass slider is 100% more responsive on its upper half than the original + calibration: 170→1.20 (was 1.10), 500→8.80 (was 4.40). The lower-half ratio + (value/160) is preserved. The mass is now a LINEAR multiplier on gravitational + field strength in the engine, so the user can directly see the central pull grow. */ return value <= GRAPH_BLACK_HOLE_MASS_BASELINE ? Math.max(0, value / GRAPH_BLACK_HOLE_MASS_BASELINE) - : 1 + (value - GRAPH_BLACK_HOLE_MASS_BASELINE) / 100; + : 1 + (value - GRAPH_BLACK_HOLE_MASS_BASELINE) * 0.02; } diff --git a/engraphis/mcp_server.py b/engraphis/mcp_server.py index 8e75d6d3..2470787b 100644 --- a/engraphis/mcp_server.py +++ b/engraphis/mcp_server.py @@ -2606,7 +2606,7 @@ def smart_recall_context( workspace: Annotated[Optional[str], Field(description="Optional workspace.", max_length=200)] = None, repo: Annotated[Optional[str], Field(description="Optional repository.", max_length=200)] = None, session_id: Annotated[Optional[str], Field(description="Optional active session.")] = None, - k: Annotated[int, Field(description="Maximum source memories.", ge=1, le=50)] = 8, + k: Annotated[int, Field(description="Maximum source memories.", ge=1, le=50)] = 50, token_budget: Annotated[int, Field(description="Hard returned-context token budget.", ge=0, le=32_768)] = 1024, ) -> str: diff --git a/integrations/prime_agent/.gitignore b/integrations/prime_agent/.gitignore new file mode 100644 index 00000000..1cf3700e --- /dev/null +++ b/integrations/prime_agent/.gitignore @@ -0,0 +1,30 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +dist/ +*.egg-info/ +*.egg + +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +.tox/ +.nox/ +.mypy_cache/ +.ruff_cache/ +.hypothesis/ + +.venv/ +venv/ +env/ +ENV/ + +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store diff --git a/integrations/prime_agent/LICENSE b/integrations/prime_agent/LICENSE new file mode 100644 index 00000000..a6ad03ca --- /dev/null +++ b/integrations/prime_agent/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 The Engraphis Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/integrations/prime_agent/NOTICE b/integrations/prime_agent/NOTICE new file mode 100644 index 00000000..52b73d92 --- /dev/null +++ b/integrations/prime_agent/NOTICE @@ -0,0 +1,17 @@ +Engraphis for prime-agent +Copyright 2026 The Engraphis Authors + +This product includes software developed by the Engraphis project. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +This integration depends on the `mcp` Python SDK (Model Context Protocol), +which is licensed under the MIT License. See https://github.com/modelcontextprotocol/python-sdk +for upstream attribution. + +"Engraphis" and the Engraphis logo are trademarks of the Engraphis project. +The Apache-2.0 license does not grant trademark rights (see LICENSE, section 6). diff --git a/integrations/prime_agent/README.md b/integrations/prime_agent/README.md new file mode 100644 index 00000000..361905e1 --- /dev/null +++ b/integrations/prime_agent/README.md @@ -0,0 +1,283 @@ +# Engraphis for prime-agent + +`engraphis-prime-agent` is the first-party [PrimeIntellect prime-agent](https://github.com/PrimeIntellect-ai/prime-agent) +integration for durable, local-first Engraphis memory. It lazily launches the existing +`engraphis-mcp` server on stdio and exposes the same nine-tool Smart MCP surface +that every other Engraphis host uses, so a prime-agent fleet gets prompt-ready +context, durable facts, and governed governance actions through one shared local +gateway. + +A `PrimeAgentFleet` of eight named sub-agents (`researcher`, `planner`, `coder`, +`reviewer`, `tester`, `documenter`, `monitor`, `integrator`) shares one stdio +subprocess. Each sub-agent starts its own Engraphis session on first tool use, +so memory stays isolated by session while the gateway stays single-process. + +## Architecture + +At runtime the integration has three layers: + +1. **A shared stdio subprocess.** The first time a `PrimeAgentFleet` is entered + it spawns one `engraphis-mcp` process over JSON-RPC stdio. Every tool call + from every sub-agent goes through that one process. +2. **A shared `EngraphisMcpClient`.** Owns the subprocess, exposes the + `engraphis-mcp-classic` and the new Smart nine-tool surface, and serializes + concurrent calls through an `asyncio.Lock` at the JSON-RPC frame layer. +3. **Eight named `EngraphisPrimeAgent` sub-agents.** Each one holds its own + session id, lazily started on first tool use, and the same nine tool + bindings. Sub-agent identity doubles as the default `repo` scope, so + per-role memory isolation is the default. + +The eight fixed names — `researcher`, `planner`, `coder`, `reviewer`, `tester`, +`documenter`, `monitor`, `integrator` — match the prime-agent roles the +integration was designed around. A custom fleet can be built by passing +`agent_names=[...]` to `PrimeAgentFleet(...)`; the stdio subprocess and the +client are still shared. + +## When to use this vs. the Pi extension vs. the commandcode hook + +All three integrations expose the same nine-tool Smart MCP surface against the +local Engraphis gateway. Choose by host, not by feature set. + +| Integration | Host | Best for | Concurrency | Install | +|---|---|---|---|---| +| `integrations/prime_agent/` (this package) | [PrimeIntellect prime-agent](https://github.com/PrimeIntellect-ai/prime-agent) fleets of 1–8 named sub-agents | Multi-role pipelines (`researcher` → `coder` → `reviewer` → `tester`) that need per-role session isolation but one local gateway | Eight sub-agents share one stdio subprocess; tool calls serialize at the JSON-RPC frame layer | `pip install ./integrations/prime_agent` | +| [Pi extension](https://github.com/Coding-Dev-Tools/engraphis/blob/main/integrations/pi/README.md) | The Pi coding agent | A single interactive coding loop with prompt-ready recall, durable notes, and governed governance actions | One agent, one stdio gateway | Pi extension marketplace / `pip install engraphis-pi` | +| [Command Code SessionStart hook](https://github.com/Coding-Dev-Tools/engraphis/blob/main/integrations/commandcode/) | A Command Code session | Warming a brand-new session with bounded, cited context on `SessionStart`; fails open on timeout | One hook per session | `python scripts/install_cc_hook.py` | + +Pick the prime-agent integration when you already have or want a multi-role +pipeline and the per-role memory boundary is useful. Pick the Pi extension for +single-agent interactive work. Pick the commandcode hook when you want a +zero-config, one-shot context warm-up at session start. + +## Install + +Install Engraphis 1.5.x with Python 3.10 or later. Version 1.5 introduced the +nine-tool Smart MCP contract required by this integration: + +```bash +python -m pip install --upgrade "engraphis[mcp]>=1.5,<2" +``` + +Install this package from a checkout of the engraphis repository: + +```bash +pip install ./integrations/prime_agent +``` + +Or, once published: + +```bash +pip install engraphis-prime-agent +``` + +## Quick start + +```python +import asyncio +from engraphis_prime_agent import PrimeAgentFleet + +async def main(): + async with PrimeAgentFleet(workspace="myrepo") as fleet: + # Warm every sub-agent's session up front so the first real + # tool call on each role never blocks on session bootstrap. + await fleet.start_all_sessions() + + # 1. The researcher asks for prior decisions on a topic. + research = await fleet["researcher"].call( + "engraphis_recall_context", + {"query": "decision: sqlite-vec KNN", "k": 5, "token_budget": 600}, + ) + + # 2. Fan out: the planner and the coder both look up the procedure + # for rebuilding persistent vectors after an embedding swap. + plans = await fleet.fan_out( + "engraphis_recall_context", + { + "planner": {"query": "procedure: rebuild persistent vectors", "k": 5}, + "coder": {"query": "procedure: rebuild persistent vectors", "k": 5}, + }, + ) + + # 3. The documenter persists the durable decision the coder just made. + # The integration returns the pending review boundary; the memory + # is not prompt-eligible until a human approves it (see "Trust model"). + pending = await fleet["documenter"].call("engraphis_remember", { + "content": "Prefer sqlite-vec KNN for <=1M vectors; rebuild after model swap.", + "importance": 0.7, + "mtype": "semantic", + }) + + # 4. The reviewer scans the inbox for any new conflicts. + review = await fleet["reviewer"].call("engraphis_conflict_review", {"limit": 10}) + + return research, plans, pending, review + +asyncio.run(main()) +``` + +The example uses four of the eight sub-agents and exercises `recall_context`, +`remember`, and `conflict_review`. The four untasked sub-agents (`tester`, +`monitor`, `integrator`, and the second role of the fan-out) can be invoked +the same way — they are ordinary `EngraphisPrimeAgent` instances behind the +fleet's dict interface. + +## Registering with prime-agent + +After the package is installed, register it with prime-agent's tool manager: + +```bash +python scripts/install_prime_agent.py +``` + +The installer is idempotent: re-running updates the existing entry instead of +duplicating it. Use `--uninstall` to remove the entry. + +If prime-agent expects a different tool-registration surface, the single +adapter point is `EngraphisPrimeAgent.register()`. Pass any object with a +`register_tool(name, fn, schema=...)` method; the integration registers all +nine Smart tools with that target. Override the method (or pass a thin +adapter) if prime-agent's real API differs. + +## Configuration + +| Variable | Purpose | +|---|---| +| `ENGRAPHIS_MCP_COMMAND` | Override the `engraphis-mcp` console-script path (e.g. an absolute path under a virtualenv or pipx). | +| `ENGRAPHIS_DB_PATH` | Path to the local Engraphis SQLite database. The integration inherits whatever the gateway sees, so the dashboard and the fleet share one store. | +| `ENGRAPHIS_WORKSPACE` | Default workspace name. The fleet's `workspace=` overrides this. | +| `ENGRAPHIS_REPO` | Default repo scope. The fleet's `repo=` overrides this. | +| `PRIME_AGENT_CONFIG_PATH` | Override the prime-agent config file path used by `scripts/install_prime_agent.py`. | + +Only `ENGRAPHIS_*`, `PATH`, `Path`, `SystemRoot`, and `ComSpec` are forwarded to +the gateway subprocess — never the full environment. + +## The nine Smart tools + +| Tool | Purpose | +|---|---| +| `engraphis_session` | Start, resume, or end a session for the calling sub-agent. | +| `engraphis_recall_context` | Compact, cited, token-budgeted context for the current task. | +| `engraphis_remember` | Persist a durable fact, decision, preference, or procedure. | +| `engraphis_discover_actions` | Find a best-fit advanced capability with a version-bound schema. | +| `engraphis_execute_read` | Run a discovered read-only advanced capability. | +| `engraphis_execute_action` | Run a discovered write/admin/destructive advanced capability. | +| `engraphis_get_memory` | Read one governed memory record by id. | +| `engraphis_update_memory` | Edit one memory's title/type/importance/audit actor. | +| `engraphis_conflict_review` | List pending, quarantined, or conflicting memories for review. | + +## Concurrency model + +The fleet shares one `EngraphisMcpClient`, which owns one `engraphis-mcp` +subprocess. The stdio transport is a single connection, so concurrent tool +calls are serialized at the JSON-RPC frame layer through an `asyncio.Lock`. +Framework-level concurrency (eight sub-agents reasoning in parallel and +issuing one tool call each) is unaffected — the `fan_out()` helper +demonstrates the pattern via `asyncio.gather`. + +> **For true parallel MCP**, run multiple fleets against **distinct +> databases** (different `ENGRAPHIS_DB_PATH` values). Sharing a single +> database across two fleets is safe at the SQL level, but the stdio +> frame lock means you would pay for the same serialization twice. The +> default `PrimeAgentFleet` is designed for one workspace, one local +> gateway, eight sub-agents. + +This serialization is intentional. See the design discussion in +[issue #1: shared stdio frame serialization](https://github.com/Coding-Dev-Tools/engraphis/issues/1) +("For true parallel MCP, run multiple fleets against distinct databases") for +the trade-offs that drove the choice of a single subprocess. + +## Trust model + +The integration runs with your local user permissions. Install only the +official package or a reviewed checkout. `ENGRAPHIS_MCP_COMMAND` should point +only to a trusted local executable. + +Engraphis MCP writes enter the normal pending-review boundary. A successful +`engraphis_remember` call does not make unreviewed text prompt-eligible; +approve it through the Engraphis dashboard or the interactive approval +command before expecting it in normal recall. This behavior is intentional +and shared with the Pi and commandcode integrations. + +## Testing + +The test suite includes a fake MCP server (`tests/conftest.py`) so the default +unit tests do not require a live `engraphis-mcp` binary. + +Run the unit suite: + +```bash +cd integrations/prime_agent +python -m pip install -e ".[test]" +pytest -q +``` + +Run a single test file or test id: + +```bash +pytest -q tests/test_agent.py +pytest -q tests/test_agent.py::TestEngraphisPrimeAgent::test_register +``` + +Run the **live-gated** tests, which require a real `engraphis-mcp` on `PATH` +and a writable temporary database: + +```bash +ENGRAPHIS_INTEGRATION_LIVE=1 pytest -q +``` + +Live tests are skipped without the flag and are the right place to add any +new test that exercises real subprocess behavior. Keep them small and +idempotent; the fake server in `conftest.py` is the right home for everything +else. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `ModuleNotFoundError: No module named 'mcp'` | The MCP Python SDK is not installed | `pip install "engraphis[mcp]"` (or `pip install -e ".[test]"` for development) | +| `ERROR: engraphis-prime-agent requires Python >=3.10` (or a hard `SyntaxError` on import) | The active interpreter is 3.9 or older | Use Python 3.10+. The Engraphis 1.5 MCP server and the MCP SDK both require 3.10+ | +| `engraphis-mcp` is on `PATH` but the server starts and the tool list is empty or the Smart nine tools are missing | The installed `engraphis` is older than 1.5 | `pip install --upgrade "engraphis[mcp]>=1.5,<2"`. Version 1.5 introduced the nine-tool Smart contract this integration depends on | +| `ConnectionRefusedError` / `FileNotFoundError` / `OSError: [Errno 2] No such file or directory: 'engraphis-mcp'` when the fleet enters | `engraphis-mcp` is not on `PATH` for the Python that imports the integration | Install `engraphis[mcp]` in the same environment, or set `ENGRAPHIS_MCP_COMMAND` to the absolute path of the `engraphis-mcp` console script (for example `.venv/bin/engraphis-mcp` or `~/.local/bin/engraphis-mcp`) | +| `engraphis_prime_agent.cli` returns exit code 2 with "binary not on PATH" | Same as above, surfaced by the CLI check | Install `engraphis[mcp]`, or `pipx install "engraphis[mcp]"` if you intentionally keep the integration in a different venv | +| `pytest` cannot import `engraphis_prime_agent` from the repo checkout | The package was not installed in editable mode | From `integrations/prime_agent/`, run `pip install -e ".[test]"` | +| `Pending` memories never show up in normal recall | This is expected, not a bug | New writes enter the pending review boundary. Approve them through the Engraphis dashboard or `engraphis-cli review approve` before expecting them in normal recall (see "Trust model") | + +If a failure is not on this list, run `python -m engraphis_prime_agent check` +against your environment — it returns one of the documented exit codes +(`0` ok, `1` incompatible tool set, `2` missing binary / install failure, +`3` transport error) and prints the matching hint. + +## Contributing + +The integration has one adapter point. Everything else — the eight named +sub-agents, the shared `EngraphisMcpClient`, the nine Smart tool bindings, +the stdio subprocess lifecycle, and the per-agent session bootstrap — is +fixed and reviewed as a unit. + +**The single adapter point is `EngraphisPrimeAgent.register()`** in +`src/engraphis_prime_agent/agent.py`. The assumed contract is +`target.register_tool(name, fn, schema=...)` (LangChain / CrewAI style). If +prime-agent's real API differs, override this method or pass a thin adapter +that exposes the same shape. The body of `register()` is intentionally short +so a port is a small, reviewable change. + +Before opening a PR: + +1. Read the design notes in + [`~/.commandcode/plans/prime-agent-integration.md`](https://github.com/Coding-Dev-Tools/engraphis/blob/main/integrations/prime_agent/) + (host-local) or, when the host plan is not available, the PR description + that introduced the integration. The eight sub-agent names, the shared + stdio subprocess, the per-agent session boundary, and the + `ENGRAPHIS_*`-only environment forwarding are all deliberate choices + called out there. +2. Run `pytest -q` from `integrations/prime_agent/`. Unit tests must pass + without `ENGRAPHIS_INTEGRATION_LIVE=1`. +3. If you changed the adapter point, the CLI install/uninstall, or the tool + surface, also run `ENGRAPHIS_INTEGRATION_LIVE=1 pytest -q`. +4. Keep new live tests small and idempotent; prefer extending the fake + server in `tests/conftest.py` for anything that is not really testing the + subprocess. + +## License + +Apache-2.0. See `LICENSE` and `NOTICE`. diff --git a/integrations/prime_agent/pyproject.toml b/integrations/prime_agent/pyproject.toml new file mode 100644 index 00000000..c3c2c23f --- /dev/null +++ b/integrations/prime_agent/pyproject.toml @@ -0,0 +1,55 @@ +[build-system] +requires = ["setuptools>=83.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "engraphis-prime-agent" +version = "0.1.0" +description = "First-party Engraphis Smart MCP integration for PrimeIntellect's prime-agent" +readme = "README.md" +license = "Apache-2.0" +license-files = ["LICENSE", "NOTICE"] +requires-python = ">=3.10" +authors = [{ name = "The Engraphis Authors" }] +keywords = [ + "engraphis", + "mcp", + "memory", + "agent", + "prime-agent", + "primeintellect", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] +dependencies = [ + "mcp>=1.28.1,<2; python_version >= '3.10'", + "typing-extensions>=4.0", +] + +[project.optional-dependencies] +test = [ + "pytest>=9.0.3", + "pytest-asyncio>=0.23", +] + +[project.scripts] +engraphis-prime-agent = "engraphis_prime_agent.cli:main" + +[project.urls] +Repository = "https://github.com/Coding-Dev-Tools/engraphis/tree/main/integrations/prime_agent" +Issues = "https://github.com/Coding-Dev-Tools/engraphis/issues" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +addopts = "-q" diff --git a/integrations/prime_agent/src/engraphis_prime_agent/__init__.py b/integrations/prime_agent/src/engraphis_prime_agent/__init__.py new file mode 100644 index 00000000..33f5750f --- /dev/null +++ b/integrations/prime_agent/src/engraphis_prime_agent/__init__.py @@ -0,0 +1,38 @@ +"""First-party Engraphis integration for PrimeIntellect's prime-agent.""" +from .config import ( + DEFAULT_AGENT_NAMES, + EngraphisRuntimeConfig, + build_runtime_config, +) + +__all__ = [ + "EngraphisRuntimeConfig", + "build_runtime_config", + "DEFAULT_AGENT_NAMES", +] +__version__ = "0.1.0" + +# Defer the heavy imports (mcp_client, tools, agent) so callers that only +# need config or exception types don't have to install the mcp package. +try: # pragma: no cover - import guard + from .mcp_client import ( + EngraphisCompatibilityError, + EngraphisMcpClient, + EngraphisMcpToolError, + ) + from .tools import all_tools, apply_scope_defaults, build_tool, TOOL_SPECS + from .agent import EngraphisPrimeAgent, PrimeAgentFleet + + __all__ += [ + "EngraphisMcpClient", + "EngraphisMcpToolError", + "EngraphisCompatibilityError", + "EngraphisPrimeAgent", + "PrimeAgentFleet", + "all_tools", + "apply_scope_defaults", + "build_tool", + "TOOL_SPECS", + ] +except ImportError: # mcp (or a transitive dep) is not installed + pass diff --git a/integrations/prime_agent/src/engraphis_prime_agent/__main__.py b/integrations/prime_agent/src/engraphis_prime_agent/__main__.py new file mode 100644 index 00000000..60f2c0f3 --- /dev/null +++ b/integrations/prime_agent/src/engraphis_prime_agent/__main__.py @@ -0,0 +1,7 @@ +"""Allow ``python -m engraphis_prime_agent``.""" +from .cli import main + +if __name__ == "__main__": + import sys + + sys.exit(main()) diff --git a/integrations/prime_agent/src/engraphis_prime_agent/agent.py b/integrations/prime_agent/src/engraphis_prime_agent/agent.py new file mode 100644 index 00000000..0d41af5a --- /dev/null +++ b/integrations/prime_agent/src/engraphis_prime_agent/agent.py @@ -0,0 +1,470 @@ +"""EngraphisPrimeAgent (single sub-agent) and PrimeAgentFleet (8 sub-agents).""" +from __future__ import annotations + +import asyncio +import json +import logging +import threading +from contextlib import AsyncExitStack +from typing import Any, Awaitable, Iterable + +from .config import ( + DEFAULT_AGENT_NAMES, + EngraphisRuntimeConfig, + build_runtime_config, +) +from .mcp_client import EngraphisMcpClient, EngraphisMcpToolError +from .tools import ToolFn, all_tools, build_tool, TOOL_SPECS + +_logger = logging.getLogger("engraphis_prime_agent.agent") + + +class EngraphisPrimeAgent: + """One named sub-agent owning its own Engraphis session. + + Holds: + - a shared EngraphisMcpClient (one stdio subprocess for the whole fleet) + - a per-agent session id (started lazily on first tool call) + - the 9 Smart tools as (callable, schema) pairs + """ + + def __init__( + self, + name: str, + client: EngraphisMcpClient, + config: EngraphisRuntimeConfig, + *, + workspace: str | None = None, + repo: str | None = None, + goal: str = "", + token_budget: int = 512, + ) -> None: + if not name or not name.strip(): + raise ValueError("Sub-agent name must be non-empty.") + self.name = name.strip() + self.client = client + self.config = config + # Workspace precedence: explicit per-agent kwarg > config default. + self.workspace = workspace or config.default_workspace + # Repo precedence: explicit per-agent kwarg > config default > sub-agent + # name. A single effective repo must be used for both session creation + # and the tool-call defaults — a session opened in `researcher` while + # tools send `api` is rejected by MemoryService with "session_id does + # not belong to that workspace/repo". When ENGRAPHIS_REPO sets a + # fleet-wide default, every sub-agent's session and every tool call + # use that same repo; only when no default is configured does the + # sub-agent name double as the repo, giving per-role isolation by + # default. + if repo is not None: + self.repo = repo + elif config.default_repo is not None: + self.repo = config.default_repo + else: + self.repo = self.name + self.goal = goal + self.token_budget = token_budget + self._session_id: str | None = None + self._session_lock = asyncio.Lock() + self._tools: dict[str, tuple[ToolFn, dict[str, Any]]] | None = None + # Protects lazy initialization of the tool-binding cache. The + # session lock above is *not* enough because get_tool() and tools() + # are synchronous and can be called from multiple threads (or, in + # the future, multiple event-loop iterations) on a fresh agent + # before start_session() has run. threading.Lock is correct here: + # the method is sync, and we just need mutual exclusion across + # concurrent sync callers — not coordination with awaits. + self._tools_lock = threading.Lock() + + def __repr__(self) -> str: + sid = self._session_id if self._session_id else "none" + return ( + f"EngraphisPrimeAgent(name={self.name!r}, workspace={self.workspace!r}, " + f"repo={self.repo!r}, session_id={sid!r})" + ) + + # --- session lifecycle ------------------------------------------------ + + async def start_session(self, *, force_new: bool = False) -> str: + # The two state mutations below happen under _session_lock so they + # are atomic w.r.t. concurrent start_session / end_session callers + # (and concurrent get_tool() callers that read self._session_id). + async with self._session_lock: + if self._session_id and not force_new: + return self._session_id + args: dict[str, Any] = { + "action": "start", + "agent": self.name, + "force_new": force_new, + "goal": self.goal, + "token_budget": self.token_budget, + } + if self.workspace: + args["workspace"] = self.workspace + if self.repo: + args["repo"] = self.repo + response = await self.client.call_tool("engraphis_session", args) + session_id = self._extract_session_id(response) + if not session_id: + raise EngraphisMcpToolError( + f"engraphis_session(start) for agent={self.name!r} returned no session_id." + ) + # Atomic state transition: only one writer holds this lock. + self._session_id = session_id + self._tools = None # rebuild bindings with the new session id + return session_id + + async def end_session(self, *, summary: str = "", outcome: str = "") -> None: + # Capture the id under the lock so a concurrent start_session can't + # race us between the "no session" check and the call_tool. + async with self._session_lock: + session_id = self._session_id + if not session_id: + return + # Always clear local state, even if the gateway call fails, so + # the sub-agent is not stuck in a half-open state. + self._session_id = None + self._tools = None + # Make the close-call best-effort. Log the error so operators can + # spot stranded sessions, but never propagate: end_session() is + # called from aclose/__aexit__ paths where raising would mask the + # real shutdown error. + try: + await self.client.call_tool( + "engraphis_session", + { + "action": "end", + "agent": self.name, + "session_id": session_id, + "summary": summary, + "outcome": outcome, + }, + ) + except Exception as exc: # noqa: BLE001 — best-effort close + _logger.warning( + "end_session for agent=%r (session_id=%r) failed: %s", + self.name, + session_id, + exc, + ) + + @property + def session_id(self) -> str | None: + return self._session_id + + # --- tool access ------------------------------------------------------ + + def _ensure_tools(self) -> dict[str, tuple[ToolFn, dict[str, Any]]]: + # Fast path: bindings already built. The lock is only for the slow + # path so we don't pay synchronization cost on every tool access. + if self._tools is not None: + return self._tools + # Two coroutines that race here on a fresh agent must not both + # build (and leak) duplicate bindings. asyncio.Lock is fair, so + # the second waiter will see self._tools already populated. + # Note: a synchronous lock is fine because this method is sync; + # we just need mutual exclusion against other sync call sites. + with self._tools_lock: + if self._tools is None: + self._tools = { + meta["name"]: build_tool( + meta["name"], + self.client, + self.config, + session_id=self._session_id, + ) + for _fn, meta in all_tools( + self.client, self.config, session_id=self._session_id + ) + } + return self._tools + + def tools(self) -> list[tuple[ToolFn, dict[str, Any]]]: + bindings = self._ensure_tools() + return [bindings[name] for name, _schema in TOOL_SPECS] + + def get_tool(self, name: str) -> tuple[ToolFn, dict[str, Any]]: + return self._ensure_tools()[name] + + async def call(self, tool: str, args: dict[str, Any]) -> dict[str, Any]: + if not self._session_id: + await self.start_session() + fn, _schema = self.get_tool(tool) + return await fn(args) + + # --- registration into prime-agent ----------------------------------- + + def register(self, target: Any) -> Any: + """Register all 9 tools into a prime-agent Agent (or compatible). + + The assumed contract is ``target.register_tool(name, fn, schema=...)`` + (LangChain/CrewAI-style). If prime-agent's actual API differs, this + is the single function the implementer needs to adjust. + + The framework may invoke the registered callables directly rather + than going through ``EngraphisPrimeAgent.call()``, so each registered + tool is wrapped to lazily start the session on first invocation. + Without this wrapper, the advertised registration path would never + create or inject a per-agent session, and MemoryService would reject + every call. + """ + # Validate both presence and that it's actually a method (hasattr + # would otherwise accept an attribute that happens to be a string + # or a class-level descriptor that isn't callable). + register_tool = getattr(target, "register_tool", None) + if not callable(register_tool): + raise TypeError( + f"Cannot register tools on {type(target).__name__}: " + "expected a callable `register_tool` method. " + "See agent.py for the adapter point." + ) + for fn, meta in self.tools(): + register_tool(meta["name"], self._wrap_for_registration(fn, meta["name"]), + schema=meta) + return target + + def _wrap_for_registration( + self, bound_fn: ToolFn, tool_name: str + ) -> ToolFn: + """Return a callable that lazily starts a session, then delegates. + + Mirrors the lazy-start behaviour of ``EngraphisPrimeAgent.call()`` so + that frameworks which invoke the registered tool directly (bypassing + ``call()``) still get a per-agent session injected. + """ + agent = self + + async def _wrapper(args: dict[str, Any]) -> dict[str, Any]: + if not agent._session_id: + await agent.start_session() + # start_session() rebuilds the bound tools with the new + # session_id, so re-fetch the fresh binding for the current + # call. + fresh_fn, _schema = agent.get_tool(tool_name) + return await fresh_fn(args) + return await bound_fn(args) + + return _wrapper + + def status(self) -> dict[str, Any]: + return { + "name": self.name, + "workspace": self.workspace, + "repo": self.repo, + "goal": self.goal, + "session_id": self._session_id, + "tools_bound": self._tools is not None, + } + + # --- helpers ---------------------------------------------------------- + + @staticmethod + def _extract_session_id(response: dict[str, Any]) -> str | None: + for block in response.get("content", []) or []: + text = block.get("text") + if not isinstance(text, str): + continue + try: + parsed = json.loads(text) + except (ValueError, TypeError): + continue + if isinstance(parsed, dict): + sid = parsed.get("session_id") or parsed.get("sessionId") + if isinstance(sid, str) and sid: + return sid + return None + + +class PrimeAgentFleet: + """N named sub-agents sharing one Engraphis stdio gateway. + + Use as an async context manager so the subprocess is shut down cleanly:: + + async with PrimeAgentFleet(workspace="myrepo") as fleet: + await fleet["researcher"].call("engraphis_recall_context", {"query": "..."}) + """ + + def __init__( + self, + *, + workspace: str | None = None, + repo: str | None = None, + agent_names: Iterable[str] | None = None, + config: EngraphisRuntimeConfig | None = None, + goals: dict[str, str] | None = None, + ) -> None: + base = config or build_runtime_config() + if workspace or repo is not None: + base = EngraphisRuntimeConfig( + command=base.command, + args=base.args, + cwd=base.cwd, + default_workspace=workspace if workspace is not None else base.default_workspace, + default_repo=repo if repo is not None else base.default_repo, + environment=dict(base.environment), + ) + self.config = base + self._client = EngraphisMcpClient(self.config) + names = tuple(agent_names) if agent_names else DEFAULT_AGENT_NAMES + self._goals = goals or {} + self._agents: dict[str, EngraphisPrimeAgent] = { + n: EngraphisPrimeAgent( + n, + self._client, + self.config, + workspace=workspace, + repo=repo, + goal=self._goals.get(n, ""), + ) + for n in names + } + self._stack: AsyncExitStack | None = None + self._closed = False + + # --- collection protocol --------------------------------------------- + + def __getitem__(self, name: str) -> EngraphisPrimeAgent: + """Look up a sub-agent by name. Raises KeyError for unknown names. + + Example:: + + agent = fleet["researcher"] + """ + return self._agents[name] + + def __iter__(self): + """Iterate over sub-agents in insertion order (matches `names()`).""" + return iter(self._agents.values()) + + def __len__(self) -> int: + """Return the number of sub-agents in the fleet (default 8).""" + return len(self._agents) + + def __contains__(self, name: object) -> bool: + """Return True if a sub-agent with the given name is in the fleet. + + Example:: + + if "researcher" in fleet: + ... + """ + return name in self._agents + + def names(self) -> tuple[str, ...]: + """Return the sub-agent names in insertion order.""" + return tuple(self._agents) + + def status(self) -> dict[str, Any]: + return { + "workspace": self.config.default_workspace, + "agents": [a.status() for a in self._agents.values()], + "clientGeneration": self._client.generation(), + } + + @property + def client(self) -> EngraphisMcpClient: + return self._client + + # --- lifecycle -------------------------------------------------------- + + async def __aenter__(self) -> "PrimeAgentFleet": + self._stack = AsyncExitStack() + await self._stack.enter_async_context(self._client) + return self + + async def __aexit__(self, *exc: Any) -> None: + # Best-effort: end every active session, then close the stdio gateway. + await asyncio.gather( + *(a.end_session() for a in self._agents.values()), + return_exceptions=True, + ) + if self._stack is not None: + await self._stack.aclose() + self._stack = None + self._closed = True + + async def aclose(self) -> None: + if not self._closed: + await self.__aexit__(None, None, None) + + # --- fan-out helpers ------------------------------------------------- + + async def start_all_sessions( + self, + ) -> dict[str, Any]: + """Warm up the fleet by starting every sub-agent's session eagerly. + + prime-agent schedulers that require the first tool call to never + block on session bootstrap should call this once before dispatching. + + Returns a dict that always carries these two keys (so callers can + rely on the shape regardless of partial failures): + + - ``"sessions"``: ``dict[str, str]`` mapping sub-agent name to + session id for every sub-agent whose start succeeded. + - ``"errors"``: ``dict[str, BaseException]`` mapping sub-agent + name to the exception raised for every sub-agent whose start + failed. Empty if everything succeeded. + + Using ``asyncio.gather(..., return_exceptions=True)`` ensures a + single failing sub-agent does not abort the warm-up for the + others, and the structured ``errors`` dict makes partial failures + observable (previously they were only logged). + """ + coros: list[Awaitable[str]] = [ + agent.start_session() for agent in self._agents.values() + ] + results = await asyncio.gather(*coros, return_exceptions=True) + sessions: dict[str, str] = {} + errors: dict[str, BaseException] = {} + for name, value in zip(self._agents, results): + if isinstance(value, BaseException): + _logger.warning("start_session for %s failed: %s", name, value) + errors[name] = value + continue + if isinstance(value, str) and value: + sessions[name] = value + return {"sessions": sessions, "errors": errors} + + async def fan_out( + self, + tool: str, + per_agent_args: dict[str, dict[str, Any]], + ) -> dict[str, Any]: + """Run the same tool across multiple sub-agents concurrently. + + Each sub-agent awaits its own session start (which serializes on the + stdio transport through _call_lock). Framework-level concurrency is + preserved because asyncio.gather issues the calls as separate coroutines. + + Args: + tool: The MCP tool name to invoke on every targeted sub-agent. + per_agent_args: Mapping of sub-agent name to its per-call args. + Must be non-empty; an empty mapping is almost always a + caller bug (likely a misnamed variable) and would silently + produce an empty result dict. An empty mapping raises + ValueError so the bug surfaces immediately. + + Returns: + Dict mapping sub-agent name to the per-call result (or to the + exception if that sub-agent's call failed; return_exceptions=True + means partial failures are reported, not raised). + + Raises: + ValueError: If ``per_agent_args`` is empty. + KeyError: If any key in ``per_agent_args`` is not a known + sub-agent of this fleet. + """ + if not per_agent_args: + raise ValueError( + "fan_out requires a non-empty per_agent_args mapping; " + "got an empty dict (this is almost always a caller bug)." + ) + coros: list[Awaitable[Any]] = [] + names: list[str] = [] + for name, args in per_agent_args.items(): + if name not in self._agents: + raise KeyError(f"Unknown sub-agent: {name}") + coros.append(self._agents[name].call(tool, args)) + names.append(name) + results = await asyncio.gather(*coros, return_exceptions=True) + return {n: r for n, r in zip(names, results)} diff --git a/integrations/prime_agent/src/engraphis_prime_agent/cli.py b/integrations/prime_agent/src/engraphis_prime_agent/cli.py new file mode 100644 index 00000000..931e5cf1 --- /dev/null +++ b/integrations/prime_agent/src/engraphis_prime_agent/cli.py @@ -0,0 +1,374 @@ +"""Console entry point: ``engraphis-prime-agent check|status|register|install|version``. + +Exit codes (convention used across subcommands): + 0 - success + 1 - the MCP server was reachable but is misconfigured (e.g. wrong tool set) + 2 - dependency missing on the host (binary not on PATH, install script + reported a config problem, or a transitive module is unavailable) + 3 - the MCP server could not be reached at all (subprocess error, IO, + timeout, JSON-RPC handshake failure) + 64 - command-line usage error (argparse default) +""" +from __future__ import annotations + +import argparse +import asyncio +import base64 +import json +import shutil +import sys +from typing import Any + +from .agent import PrimeAgentFleet +from .config import build_runtime_config +from .mcp_client import EngraphisCompatibilityError, EngraphisMcpClient + +#: Exit code used when the configured MCP command is not on PATH. +EXIT_MISSING_BINARY = 2 +#: Exit code used when the MCP server is reachable but its tool surface is +#: incompatible with what this integration expects. +EXIT_INCOMPATIBLE = 1 +#: Exit code used for any other transport / connect / IO failure. +EXIT_TRANSPORT = 3 +#: Exit code used when the install/uninstall script reports a config error. +EXIT_INSTALL_FAILED = 2 + +#: Hint printed when ``shutil.which(config.command)`` comes back empty. +_MISSING_BINARY_HINT = ( + "The Engraphis MCP console script was not found on PATH. " + "Install the Smart MCP extra with: pip install \"engraphis[mcp]>=1.5,<2\"" +) + + +def _json_default(value: Any) -> Any: + """``json`` default that handles ``bytes`` (base64) and falls back to ``str``.""" + if isinstance(value, bytes): + return {"__type__": "bytes", "base64": base64.b64encode(value).decode("ascii")} + return str(value) + + +def _print_json(obj: Any) -> None: + json.dump(obj, sys.stdout, indent=2, sort_keys=True, default=_json_default) + sys.stdout.write("\n") + + +def _print_human_check(result: dict[str, Any]) -> None: + if result.get("ok"): + status = result.get("status") or {} + print( + f"ok: engraphis-mcp reachable, {status.get('toolCount', '?')} tools " + f"(server={status.get('server')!r})" + ) + else: + print(f"error: {result.get('error')}") + hint = result.get("hint") + if hint: + print(f"hint: {hint}") + + +def _print_human_status(result: dict[str, Any]) -> None: + agents = result.get("agents") or [] + print(f"workspace: {result.get('workspace')}") + print(f"agents: {len(agents)}") + for entry in agents: + sid = entry.get("session_id") or "-" + print(f" - {entry.get('name'):<11} session_id={sid}") + + +def _check(as_json: bool) -> int: + """Boot ``engraphis-mcp`` once and report status. + + Returns 0 on success, 1 on a compatibility error (server reachable but + missing tools), 2 if the binary is not on PATH, 3 on any other failure. + """ + config = build_runtime_config() + binary_path = shutil.which(config.command) + if binary_path is None: + # Don't even try to spawn: report an actionable error and a distinct + # exit code so a wrapper script can tell "binary missing" apart from + # "server reachable but wrong tool set". + result = { + "ok": False, + "error": f"command not found on PATH: {config.command!r}", + "hint": _MISSING_BINARY_HINT, + "command": config.command, + } + if as_json: + _print_json(result) + else: + _print_human_check(result) + return EXIT_MISSING_BINARY + + print(f"command: {config.command} -> {binary_path}", file=sys.stderr) + + async def _run() -> tuple[dict[str, Any], int]: + client = EngraphisMcpClient(config) + try: + await client.connect() + status = await client.status() + return {"ok": True, "command": config.command, "binary": binary_path, "status": status}, 0 + except EngraphisCompatibilityError as exc: + return ( + { + "ok": False, + "error": str(exc), + "hint": ( + "The server is reachable but is missing the Smart 9-tool " + "surface. Upgrade with: pip install --upgrade " + "\"engraphis[mcp]>=1.5,<2\"" + ), + "command": config.command, + "binary": binary_path, + }, + EXIT_INCOMPATIBLE, + ) + except Exception as exc: # noqa: BLE001 — surface to user + hint = client.diagnostic_hint() + return ( + { + "ok": False, + "error": str(exc), + "hint": hint, + "command": config.command, + "binary": binary_path, + }, + EXIT_TRANSPORT, + ) + finally: + await client.close() + + result, exit_code = asyncio.run(_run()) + if as_json: + _print_json(result) + else: + _print_human_check(result) + return exit_code + + +def _status(as_json: bool) -> int: + async def _run() -> tuple[dict[str, Any], int]: + config = build_runtime_config() + # Fail fast (and actionably) if the MCP command isn't on PATH, so the + # user doesn't have to read a stack trace to know the remedy. + if shutil.which(config.command) is None: + return ( + { + "ok": False, + "error": f"command not found on PATH: {config.command!r}", + "hint": _MISSING_BINARY_HINT, + }, + EXIT_MISSING_BINARY, + ) + try: + async with PrimeAgentFleet(workspace="prime-agent-cli") as fleet: + return {"ok": True, **fleet.status()}, 0 + except FileNotFoundError as exc: + return ( + { + "ok": False, + "error": str(exc), + "hint": ( + f"Could not launch {config.command!r}. " + "Install it with: pip install \"engraphis[mcp]>=1.5,<2\"" + ), + }, + EXIT_MISSING_BINARY, + ) + except EngraphisCompatibilityError as exc: + return ( + { + "ok": False, + "error": str(exc), + "hint": ( + "The server is reachable but is missing the Smart 9-tool " + "surface. Upgrade with: pip install --upgrade " + "\"engraphis[mcp]>=1.5,<2\"" + ), + }, + EXIT_INCOMPATIBLE, + ) + except Exception as exc: # noqa: BLE001 — surface to user + return ( + {"ok": False, "error": str(exc), "errorType": type(exc).__name__}, + EXIT_TRANSPORT, + ) + + result, exit_code = asyncio.run(_run()) + if as_json: + _print_json(result) + else: + if result.get("ok"): + _print_human_status(result) + else: + print(f"error: {result.get('error')}") + hint = result.get("hint") + if hint: + print(f"hint: {hint}") + return exit_code + + +def _register(as_json: bool) -> int: + """Print the prime-agent config snippet to stdout.""" + snippet = { + "tools": { + "engraphis": { + "package": "engraphis-prime-agent", + "import": "engraphis_prime_agent", + "entry": "PrimeAgentFleet", + } + } + } + if as_json: + _print_json(snippet) + else: + # Human-readable view of the same snippet. + print("# Drop this into your prime-agent config (e.g. tools section):") + print(json.dumps(snippet["tools"], indent=2, sort_keys=True)) + return 0 + + +def _install(uninstall: bool = False, config_path: str | None = None) -> int: + """Invoke the package-distributed installer. + + The installer lives at ``engraphis_prime_agent.installer`` so it ships + with the wheel and works after ``pip install engraphis-prime-agent`` + (the previous runpy-based path required the source-tree layout). + """ + from .installer import ( + _resolve_config_path, + install as _installer_install, + uninstall as _installer_uninstall, + ) + + path = _resolve_config_path(config_path) + if uninstall: + _installer_uninstall(path) + else: + _installer_install(path) + return 0 + + +def _version() -> int: + """Print the package version (single source of truth: ``__version__``).""" + from . import __version__ + + print(__version__) + return 0 + + +def _add_json_flag(parser: argparse.ArgumentParser) -> None: + """Add ``--json``/``--no-json`` to a subcommand. + + JSON is the default and matches the historical behavior; the flag exists + so wrapper scripts can be explicit, and so users can request a + human-readable view with ``--no-json`` where it makes sense. + """ + parser.add_argument( + "--json", + action=argparse.BooleanOptionalAction, + default=True, + dest="as_json", + help="Emit machine-readable JSON (default: true; use --no-json for text).", + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="engraphis-prime-agent", + description=( + "Engraphis Smart MCP integration for PrimeIntellect's prime-agent. " + "Use one of the subcommands below; --json is the default output " + "format for all subcommands." + ), + ) + sub = parser.add_subparsers(dest="cmd", required=True) + + check_parser = sub.add_parser( + "check", + help="Start engraphis-mcp once and report status.", + description=( + "Boot the configured engraphis-mcp console script, list its tools, " + "and print a JSON status. Exit codes: 0 ok, 1 incompatible tool " + "surface, 2 binary missing, 3 transport error." + ), + ) + _add_json_flag(check_parser) + + status_parser = sub.add_parser( + "status", + help="Boot the 8-agent fleet and print session/agent state.", + description=( + "Construct the 8-agent PrimeAgentFleet, start an MCP session, " + "and print per-agent state. Fails with an actionable error if " + "engraphis-mcp is not installed." + ), + ) + _add_json_flag(status_parser) + + register_parser = sub.add_parser( + "register", + help="Print the prime-agent tool registration snippet.", + description=( + "Print the JSON snippet that registers the engraphis tool with " + "a prime-agent installation. Pipe the output into your config." + ), + ) + _add_json_flag(register_parser) + + install_parser = sub.add_parser( + "install", + help="Idempotently install the integration into prime-agent.", + description=( + "Idempotently register the integration with prime-agent by writing " + "the tools.engraphis entry into its config file. Use --uninstall to " + "remove the entry. --config-path overrides the target file (the " + "PRIME_AGENT_CONFIG_PATH env var is also respected)." + ), + ) + install_parser.add_argument( + "--uninstall", + action="store_true", + help="Remove the engraphis entry from the prime-agent config instead of installing it.", + ) + install_parser.add_argument( + "--config-path", + default=None, + metavar="PATH", + help="Override the prime-agent config file path (defaults to $PRIME_AGENT_CONFIG_PATH or ~/.config/prime-agent/config.json).", + ) + + version_parser = sub.add_parser( + "version", + help="Print the engraphis-prime-agent version and exit.", + description="Print the installed engraphis-prime-agent __version__ and exit.", + ) + # The version subcommand prints a single line; --json is a no-op there + # but kept for symmetry with the other subcommands. + _add_json_flag(version_parser) + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + as_json = bool(getattr(args, "as_json", True)) + if args.cmd == "check": + return _check(as_json=as_json) + if args.cmd == "status": + return _status(as_json=as_json) + if args.cmd == "register": + return _register(as_json=as_json) + if args.cmd == "install": + return _install( + uninstall=bool(getattr(args, "uninstall", False)), + config_path=getattr(args, "config_path", None), + ) + if args.cmd == "version": + return _version() + parser.error(f"unknown subcommand: {args.cmd}") + return 64 # unreachable, but keeps type-checkers happy + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/integrations/prime_agent/src/engraphis_prime_agent/config.py b/integrations/prime_agent/src/engraphis_prime_agent/config.py new file mode 100644 index 00000000..9095e3e5 --- /dev/null +++ b/integrations/prime_agent/src/engraphis_prime_agent/config.py @@ -0,0 +1,220 @@ +"""Runtime configuration for the engraphis-mcp stdio gateway. + +Mirrors integrations/pi/src/config.ts: a bounded environment allowlist, an +overridable console command, and explicit default workspace/repo. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import Any, Mapping + +EXTENSION_VERSION = "0.1.0" + +CORE_DIRECT_TOOLS: tuple[str, ...] = ( + "engraphis_session", + "engraphis_recall_context", + "engraphis_remember", + "engraphis_discover_actions", + "engraphis_execute_read", + "engraphis_execute_action", + "engraphis_get_memory", + "engraphis_update_memory", + "engraphis_conflict_review", +) + +# 8 sub-agent names. Overridable via PrimeAgentFleet(agent_names=...). +# Invariants enforced at import time: exactly 8 entries, each a non-empty +# string, and all distinct so they can be used as fleet/dict keys. +DEFAULT_AGENT_NAMES: tuple[str, ...] = ( + "researcher", # gather context, recall prior decisions + "planner", # decompose goals into ordered steps + "coder", # implement changes + "reviewer", # critique diffs and surface risks + "tester", # write/run/verify tests + "documenter", # capture decisions for durable memory + "monitor", # watch logs, regressions, health + "integrator", # merge, deploy, coordinate handoffs +) + +assert len(DEFAULT_AGENT_NAMES) == 8, "DEFAULT_AGENT_NAMES must contain exactly 8 sub-agents" +assert all(isinstance(n, str) and n for n in DEFAULT_AGENT_NAMES), ( + "DEFAULT_AGENT_NAMES entries must be non-empty strings" +) +assert len(set(DEFAULT_AGENT_NAMES)) == len(DEFAULT_AGENT_NAMES), ( + "DEFAULT_AGENT_NAMES entries must be unique" +) + +# Allowlist, identical to integrations/pi/src/config.ts::engraphisEnvironment. +# +# Note on case sensitivity: +# * POSIX is case-sensitive: only ``PATH`` exists; ``Path`` would be a +# separate variable and is harmless to include. +# * Windows is case-insensitive: ``PATH``, ``Path``, and ``path`` all refer +# to the same environment entry. Including both ``PATH`` and ``Path`` is +# redundant on Windows but never harmful — the OS lookups normalise case +# and Python's ``os.environ`` preserves the case of the *first* writer. +# We keep both for symmetry with the Pi TS implementation. +_ALLOWED_ENV_KEYS = frozenset({ + "PATH", "Path", "SystemRoot", "ComSpec", +}) +_ALLOWED_ENV_PREFIX = "ENGRAPHIS_" + + +@dataclass(frozen=True) +class EngraphisRuntimeConfig: + """Resolved runtime configuration for the stdio gateway subprocess. + + The dataclass is frozen: attributes cannot be reassigned after ``__init__``. + The mutable-looking fields (``args``, ``environment``) are normalised in + :meth:`__post_init__` so that callers cannot mutate them in place either + — ``args`` becomes a ``tuple`` and ``environment`` is a shallow copy of + the input mapping stored as an immutable-style ``dict[str, str]``. + + :param command: Executable name or absolute path of the MCP gateway + binary. Must be a non-empty string; falls back to ``"engraphis-mcp"`` + on the PATH when constructed via :func:`build_runtime_config`. + :param args: Positional arguments passed to ``command``. Frozen as a + tuple at construction time. + :param cwd: Optional working directory for the subprocess. The value + is forwarded unchanged to the runtime layer, which is responsible + for path resolution and existence checks; this class only enforces + that, when provided, it is a non-empty string. + :param default_workspace: Optional default workspace identifier + forwarded to the gateway (typically a memory scope key). + :param default_repo: Optional default repository identifier forwarded + to the gateway. + :param environment: Allowlist-filtered environment variables to pass + to the subprocess. Stored as a defensive copy. + """ + + command: str = "engraphis-mcp" + args: tuple[str, ...] = () + cwd: str | None = None + default_workspace: str | None = None + default_repo: str | None = None + environment: Mapping[str, str] = field(default_factory=dict) + + def __post_init__(self) -> None: + # Validate `command`: must be a non-empty string. We check truthiness + # after stripping so a bare-whitespace value is rejected too. + if not isinstance(self.command, str) or not self.command.strip(): + raise ValueError("EngraphisRuntimeConfig.command must be a non-empty string") + # Normalise `command` in place (frozen dataclass requires object.__setattr__). + object.__setattr__(self, "command", self.command.strip()) + + # Freeze `args` as a tuple. Accept any iterable of strings; reject + # non-string entries to surface caller mistakes early. + normalised_args: tuple[str, ...] = tuple(self.args) + for a in normalised_args: + if not isinstance(a, str): + raise TypeError( + f"EngraphisRuntimeConfig.args entries must be str, got {type(a).__name__}" + ) + object.__setattr__(self, "args", normalised_args) + + # `cwd`: light validation. The runtime layer is responsible for + # path resolution and existence checks; here we only ensure that, + # when provided, the value is a non-empty string. Relative paths + # are allowed and resolved relative to the parent process cwd. + if self.cwd is not None and (not isinstance(self.cwd, str) or not self.cwd): + raise ValueError("EngraphisRuntimeConfig.cwd must be a non-empty string or None") + + # Defensive copy of the environment mapping. We also coerce values + # to str to give the field a precise ``Mapping[str, str]`` shape + # even if a caller passed a more permissive type. + env_copy: dict[str, str] = {str(k): str(v) for k, v in dict(self.environment).items()} + object.__setattr__(self, "environment", env_copy) + + def as_subprocess_env(self) -> dict[str, str]: + """Return a fresh ``dict`` copy of the environment for subprocess use. + + Always returns a new mapping so callers can mutate the result + without affecting this config's frozen state. + """ + return dict(self.environment) + + +def _non_blank(value: str | None) -> str | None: + """Return ``value`` with surrounding whitespace stripped, or ``None``. + + A value that is ``None``, empty, or whitespace-only returns ``None``; + otherwise the stripped string is returned. Used to normalise optional + environment overrides before they are stored on the config. + """ + if value is None: + return None + cleaned = value.strip() + return cleaned or None + + +def _engraphis_environment(env: Mapping[str, Any]) -> dict[str, str]: + """Forward only the Engraphis settings and the Windows/POSIX path vars. + + Mirrors integrations/pi/src/config.ts so a sub-agent's gateway sees the + same allowlist the Pi extension uses. + + The parameter is typed ``Mapping[str, Any]`` because real-world + sources (``os.environ`` is fine, but test fixtures and ad-hoc dicts may + contain ``None`` or other non-string values). Non-string values are + silently dropped — this is intentional: a missing or wrongly-typed + variable should not crash config construction, it should just be + excluded from the forwarded environment. + """ + forwarded: dict[str, str] = {} + for key, value in env.items(): + if not isinstance(value, str): + continue + if key.startswith(_ALLOWED_ENV_PREFIX) or key in _ALLOWED_ENV_KEYS: + # Trim surrounding whitespace so a value like " /tmp/x.db " is + # forwarded as "/tmp/x.db". This keeps gateway config (paths, + # workspace ids, repo names) free of accidental padding and + # matches the trimming `_non_blank` applies to the dedicated + # workspace/repo fields. + forwarded[key] = value.strip() + return forwarded + + +def build_runtime_config( + env: Mapping[str, Any] | None = None, + *, + command: str | None = None, + args: tuple[str, ...] | None = None, + cwd: str | None = None, +) -> EngraphisRuntimeConfig: + """Build the runtime config the same way the Pi TS integration does. + + Reads from ``env`` (defaults to :data:`os.environ`) with the following + resolution order for each field: + + * ``command`` — explicit ``command`` kwarg, else + ``$ENGRAPHIS_MCP_COMMAND``, else ``"engraphis-mcp"``. + * ``args`` — explicit ``args`` kwarg, else ``()``. + * ``cwd`` — explicit ``cwd`` kwarg, else ``None``. + * ``default_workspace`` — ``$ENGRAPHIS_WORKSPACE`` (trimmed; + whitespace-only becomes ``None``). + * ``default_repo`` — ``$ENGRAPHIS_REPO`` (trimmed). + * ``environment`` — allowlist-filtered view of ``env``; only keys with + the ``ENGRAPHIS_`` prefix or in :data:`_ALLOWED_ENV_KEYS` are + forwarded, and only when their value is a ``str``. + + The returned :class:`EngraphisRuntimeConfig` is frozen and stores + defensive copies of any mutable inputs. + """ + src: Mapping[str, Any] = os.environ if env is None else env + resolved_command = ( + _non_blank(command) + or _non_blank(src.get("ENGRAPHIS_MCP_COMMAND")) # type: ignore[arg-type] + or "engraphis-mcp" + ) + forwarded = _engraphis_environment(src) + workspace = _non_blank(src.get("ENGRAPHIS_WORKSPACE")) # type: ignore[arg-type] + repo = _non_blank(src.get("ENGRAPHIS_REPO")) # type: ignore[arg-type] + return EngraphisRuntimeConfig( + command=resolved_command, + args=tuple(args or ()), + cwd=cwd, + default_workspace=workspace, + default_repo=repo, + environment=forwarded, + ) diff --git a/integrations/prime_agent/src/engraphis_prime_agent/installer.py b/integrations/prime_agent/src/engraphis_prime_agent/installer.py new file mode 100644 index 00000000..9c06b1b6 --- /dev/null +++ b/integrations/prime_agent/src/engraphis_prime_agent/installer.py @@ -0,0 +1,277 @@ +"""Idempotent registration of the integration with PrimeIntellect's prime-agent. + +This module is the canonical, package-distributed implementation. The +``scripts/install_prime_agent.py`` wrapper at the repo root invokes this +module so the install/uninstall behaviour stays identical for both +``pip install`` users and source-tree developers. + +The exact prime-agent config file path is the verification point: at +implementation time the implementer inspects +https://github.com/PrimeIntellect-ai/prime-agent and uses the documented +location. This module defaults to a JSON file at +``~/.config/prime-agent/config.json`` (or whatever ``PRIME_AGENT_CONFIG_PATH`` +points at) and falls back to TOML when the file has a ``.toml`` extension. +The path and format can be confirmed and tightened once the prime-agent +repo is available. + +Usage: + python scripts/install_prime_agent.py + python scripts/install_prime_agent.py --uninstall +""" +from __future__ import annotations + +import argparse +import datetime +import json +import os +import sys +from pathlib import Path +from typing import Any + +PACKAGE = "engraphis_prime_agent" +ENTRY = "PrimeAgentFleet" +TOOL_KEY = "engraphis" + +# Default path; override with PRIME_AGENT_CONFIG_PATH. +_DEFAULT_PATH = Path.home() / ".config" / "prime-agent" / "config.json" + + +def _settings_path() -> Path: + override = os.environ.get("PRIME_AGENT_CONFIG_PATH") + if override: + return Path(override) + return _DEFAULT_PATH + + +def _utc_stamp() -> str: + return datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d") + + +def _backup(path: Path) -> Path | None: + if not path.exists(): + return None + # Skip the backup when the file is brand new (zero bytes) or empty — + # there's nothing meaningful to preserve, and the timestamp collision + # on rapid successive runs is avoided. + if path.stat().st_size == 0: + return None + backup = path.with_name(f"{path.name}.bak-engraphis-{_utc_stamp()}") + if backup.exists(): + return backup + backup.write_bytes(path.read_bytes()) + return backup + + +def _read(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + text = path.read_text(encoding="utf-8").strip() + if not text: + return {} + if path.suffix == ".json": + try: + return json.loads(text) + except json.JSONDecodeError as exc: + print(f"error: {path} is not valid JSON: {exc}", file=sys.stderr) + sys.exit(2) + if path.suffix == ".toml": + try: + import tomllib # Python 3.11+ + except ImportError: + print( + f"error: reading {path} as TOML requires Python 3.11+ " + "(tomllib is in the stdlib from 3.11 onward)", + file=sys.stderr, + ) + sys.exit(2) + try: + return tomllib.loads(text) + except tomllib.TOMLDecodeError as exc: + print(f"error: {path} is not valid TOML: {exc}", file=sys.stderr) + sys.exit(2) + print( + f"error: unsupported config format for {path} " + f"(expected .json or .toml, got {path.suffix!r})", + file=sys.stderr, + ) + sys.exit(2) + + +def _ensure_writable_parent(path: Path) -> None: + """Refuse to write if the parent directory is not writable. + + Catches the common failure modes early: missing parent on a read-only + filesystem, an unwritable existing directory, or a path whose parent is a + file. The actual write still happens after this check, so a TOCTOU race is + technically possible, but in practice the only way to fail here is the + configuration the user is asking us to use. + """ + parent = path.parent + if parent.exists() and not parent.is_dir(): + print( + f"error: parent of {path} exists but is not a directory: {parent}", + file=sys.stderr, + ) + sys.exit(2) + if not parent.exists(): + # We will create it; check that we can. ``os.access`` on a non-existent + # path checks the nearest existing ancestor, which is what we want. + ancestor = parent + while not ancestor.exists(): + ancestor = ancestor.parent + if not os.access(str(ancestor), os.W_OK): + print( + f"error: cannot create {path}: no write access to {ancestor}", + file=sys.stderr, + ) + sys.exit(2) + return + if not os.access(str(parent), os.W_OK): + print( + f"error: parent directory of {path} is not writable: {parent}", + file=sys.stderr, + ) + sys.exit(2) + + +def _write(path: Path, data: dict[str, Any]) -> None: + _ensure_writable_parent(path) + path.parent.mkdir(parents=True, exist_ok=True) + if path.suffix == ".json": + path.write_text( + json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return + if path.suffix == ".toml": + try: + import tomli_w + except ImportError: + print( + f"error: writing {path} as TOML requires the 'tomli_w' package; " + "install it with: pip install 'tomli_w>=1.0' " + "(it is not bundled with the engraphis core package)", + file=sys.stderr, + ) + sys.exit(2) + # tomli_w.dumps returns str, not bytes — use write_text, not write_bytes. + path.write_text(tomli_w.dumps(data), encoding="utf-8") + return + print( + f"error: unsupported config format for {path} " + f"(expected .json or .toml, got {path.suffix!r})", + file=sys.stderr, + ) + sys.exit(2) + + +def _entry() -> dict[str, str]: + # Use the underscore-separated import name as the distribution name; the + # PyPI distribution is engraphis-prime-agent (hyphenated) but the + # Python import path is engraphis_prime_agent (underscored). + return { + "package": "engraphis-prime-agent", + "import": PACKAGE, + "entry": ENTRY, + } + + +def _dry_run(path: Path, before: dict[str, Any], after: dict[str, Any]) -> None: + print("--- before") + print(json.dumps(before, indent=2, sort_keys=True)) + print("--- after") + print(json.dumps(after, indent=2, sort_keys=True)) + print(f"(dry-run) no changes written to {path}") + + +def install( + path: Path | None = None, + *, + merge: bool = False, + dry_run: bool = False, +) -> None: + path = path or _settings_path() + cfg = _read(path) + before = dict(cfg) + tools = cfg.setdefault("tools", {}) + entry = _entry() + if merge and isinstance(tools.get(TOOL_KEY), dict): + # Preserve operator-supplied keys under the tools.engraphis table. + merged = dict(tools[TOOL_KEY]) + merged.update(entry) + tools[TOOL_KEY] = merged + else: + tools[TOOL_KEY] = entry + if dry_run: + _dry_run(path, before, cfg) + return + _backup(path) + _write(path, cfg) + print(f"installed engraphis-prime-agent into {path}") + + +def uninstall( + path: Path | None = None, + *, + dry_run: bool = False, +) -> None: + path = path or _settings_path() + cfg = _read(path) + before = dict(cfg) + tools = cfg.get("tools", {}) + if TOOL_KEY not in tools: + if dry_run: + _dry_run(path, before, before) + else: + print(f"no engraphis entry in {path}") + return + del tools[TOOL_KEY] + if not tools: + cfg.pop("tools", None) + if dry_run: + _dry_run(path, before, cfg) + return + _backup(path) + _write(path, cfg) + print(f"removed engraphis entry from {path}") + + +def _resolve_config_path(explicit: str | None) -> Path | None: + """CLI flag → env var → None (use default). Empty string is treated as unset.""" + if explicit: + return Path(explicit) + env = os.environ.get("PRIME_AGENT_CONFIG_PATH") + if env: + return Path(env) + return None + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0]) + parser.add_argument("--uninstall", action="store_true") + parser.add_argument( + "--config-path", + default=None, + help="Override the prime-agent config file path (defaults to " + "$PRIME_AGENT_CONFIG_PATH or ~/.config/prime-agent/config.json).", + ) + parser.add_argument( + "--merge", + action="store_true", + help="Merge with any existing [tools.engraphis] entry instead of replacing it.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show the before/after diff and exit without writing or backing up.", + ) + args = parser.parse_args(argv) + path = _resolve_config_path(args.config_path) + if args.uninstall: + uninstall(path, dry_run=args.dry_run) + else: + install(path, merge=args.merge, dry_run=args.dry_run) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/integrations/prime_agent/src/engraphis_prime_agent/mcp_client.py b/integrations/prime_agent/src/engraphis_prime_agent/mcp_client.py new file mode 100644 index 00000000..9c849308 --- /dev/null +++ b/integrations/prime_agent/src/engraphis_prime_agent/mcp_client.py @@ -0,0 +1,344 @@ +"""Async stdio client for the local Engraphis MCP gateway. + +Translates integrations/pi/src/mcp-client.ts to the Python `mcp` SDK: + - one shared subprocess (StdioClientTransport from mcp.client.stdio) + - generation counter so a close-during-connect cannot leave a stale Client + - bounded 4 KiB stderr buffer for diagnosis + - retry-on-read-only up to 2 attempts with backoff + - 60s connect / 5 min tool timeouts + - two distinct exception classes for tool-level vs. compatibility errors +""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +import re +import tempfile +from contextlib import AsyncExitStack +from typing import Any, TextIO + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +from mcp.types import Implementation + +from .config import CORE_DIRECT_TOOLS, EXTENSION_VERSION, EngraphisRuntimeConfig + +_logger = logging.getLogger("engraphis_prime_agent.mcp_client") + +TOOL_REQUEST_TIMEOUT_S = 5 * 60 +CONNECT_TIMEOUT_S = 60 +STDERR_BUFFER_BYTES = 4 * 1024 + +READ_ONLY_TOOLS = frozenset({ + "engraphis_recall_context", + "engraphis_get_memory", + "engraphis_conflict_review", + "engraphis_discover_actions", +}) + + +class EngraphisMcpToolError(RuntimeError): + """Semantic rejection returned by the MCP server (e.g. invalid args).""" + + +class EngraphisCompatibilityError(RuntimeError): + """Gateway is reachable but does not expose the Smart 9-tool surface.""" + + +class EngraphisMcpClient: + """Lazy async stdio client. Safe to share across coroutines. + + Concurrent tool calls are serialized through a single asyncio.Lock; the + stdio transport is one connection, so the upstream SDK cannot interleave + JSON-RPC frames safely. Framework-level concurrency (e.g. 8 sub-agents + reasoning in parallel and then each issuing a tool call) is unaffected. + """ + + def __init__(self, config: EngraphisRuntimeConfig) -> None: + self._config = config + self._lifecycle = 0 + self._session: ClientSession | None = None + self._stack: AsyncExitStack | None = None + self._connect_lock = asyncio.Lock() + self._call_lock = asyncio.Lock() + self._tools_cache: list[dict[str, Any]] | None = None + self._diagnostic = "" + self._client_name = f"engraphis-prime-agent/{EXTENSION_VERSION}" + # A real temp file is the only cross-platform `errlog` that Windows + # subprocess.Popen accepts. The file is read on demand to fill the + # bounded diagnostic buffer; it's never persisted. + self._stderr_file: TextIO | None = None + self._stderr_path: str | None = None + + # --- lifecycle ------------------------------------------------------- + + def generation(self) -> int: + return self._lifecycle + + @property + def config(self) -> EngraphisRuntimeConfig: + return self._config + + def diagnostic_hint(self) -> str | None: + d = self._diagnostic + if re.search(r"python 3\.10|requires python 3\.10", d, re.I): + return "The Engraphis MCP server requires Python 3.10 or later." + if re.search(r"no module named ['\"]?mcp", d, re.I): + return "The Engraphis MCP dependency is missing. Install `engraphis[mcp]>=1.5,<2`." + if re.search(r"no module named ['\"]?engraphis", d, re.I): + return "Engraphis is not installed for the configured MCP command." + return None + + def _refresh_diagnostic_from_file(self) -> None: + path = self._stderr_path + if not path: + return + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + data = f.read(STDERR_BUFFER_BYTES * 4) + except OSError: + return + self._diagnostic = data[-STDERR_BUFFER_BYTES:] + + async def connect(self) -> ClientSession: + async with self._connect_lock: + if self._session is not None: + return self._session + # Capture the generation so a concurrent close() (which bumps + # _lifecycle) invalidates this connect. The post-await check + # below closes the freshly-opened stack and discards the session + # instead of publishing a live subprocess after shutdown. + generation = self._lifecycle + self._diagnostic = "" + stack = AsyncExitStack() + try: + params = StdioServerParameters( + command=self._config.command, + args=list(self._config.args), + cwd=self._config.cwd, + env=dict(self._config.environment), + ) + # Open a real temp file for stderr so Windows subprocess.Popen + # can take its fileno. The file is closed and unlinked after + # the session is torn down. + err_fd, err_path = tempfile.mkstemp(prefix="engraphis-prime-agent-", suffix=".err") + err_file = os.fdopen(err_fd, mode="w", encoding="utf-8", buffering=1) + stack.callback(err_file.close) + stack.callback(self._safe_unlink, err_path) + self._stderr_file = err_file + self._stderr_path = err_path + read, write = await asyncio.wait_for( + stack.enter_async_context(stdio_client(params, errlog=err_file)), + timeout=CONNECT_TIMEOUT_S, + ) + session = await stack.enter_async_context( + ClientSession( + read, + write, + client_info=Implementation(name=self._client_name, version=EXTENSION_VERSION), + ) + ) + await asyncio.wait_for(session.initialize(), timeout=CONNECT_TIMEOUT_S) + tools = await self._list_tools(session) + available = {t["name"] for t in tools} + missing = [n for n in CORE_DIRECT_TOOLS if n not in available] + if missing: + self._refresh_diagnostic_from_file() + raise EngraphisCompatibilityError( + "Engraphis 1.5.x Smart MCP is required; the server is " + f"missing: {', '.join(missing)}." + ) + # If close() ran while we were awaiting, abort — don't + # publish a session that the caller has already decided to + # discard. The local stack is closed before the raise so the + # subprocess is reaped. + if self._lifecycle != generation: + await stack.aclose() + self._stderr_file = None + self._stderr_path = None + raise EngraphisMcpToolError( + "Engraphis client was closed before the connect completed." + ) + self._session = session + self._stack = stack + self._tools_cache = tools + return session + except BaseException: + self._refresh_diagnostic_from_file() + await stack.aclose() + self._session = None + self._stack = None + self._tools_cache = None + self._stderr_file = None + self._stderr_path = None + raise + + @staticmethod + def _safe_unlink(path: str) -> None: + try: + os.unlink(path) + except OSError: + pass + + async def close(self) -> None: + # Hold the connect lock so any in-flight connect() either completes + # before us (and is then torn down) or aborts via the post-await + # generation check. Without this, a concurrent close() can return + # while a connect() is still mid-await, leaving a live subprocess. + async with self._connect_lock: + self._lifecycle += 1 + stack = self._stack + self._stack = None + self._session = None + self._tools_cache = None + # Reset stderr-temp-file handles. The actual file close + unlink are + # registered as AsyncExitStack callbacks in connect(), so they fire + # when `stack.aclose()` runs below. We just need to drop the Python + # references so a subsequent connect() can recreate them cleanly. + self._stderr_file = None + self._stderr_path = None + if stack is not None: + try: + await stack.aclose() + except Exception: # noqa: BLE001 — best-effort teardown + _logger.debug("ignored error while closing MCP stack", exc_info=True) + + async def __aenter__(self) -> "EngraphisMcpClient": + await self.connect() + return self + + async def __aexit__(self, *exc: Any) -> None: + await self.close() + + # --- tool surface ---------------------------------------------------- + + async def list_tools(self) -> list[dict[str, Any]]: + if self._tools_cache is not None: + return list(self._tools_cache) + session = await self.connect() + tools = await self._list_tools(session) + self._tools_cache = tools + return list(tools) + + async def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]: + if name not in CORE_DIRECT_TOOLS: + raise EngraphisMcpToolError(f"Unknown Engraphis tool: {name}") + last_error: BaseException | None = None + retry = name in READ_ONLY_TOOLS + max_attempts = 3 if retry else 1 + for attempt in range(max_attempts): + try: + async with self._call_lock: + session = await self.connect() + response = await asyncio.wait_for( + session.call_tool(name, arguments), + timeout=TOOL_REQUEST_TIMEOUT_S, + ) + return self._format_result(name, response) + except EngraphisMcpToolError: + raise + except EngraphisCompatibilityError: + raise + except asyncio.TimeoutError: + raise + except asyncio.CancelledError: + raise + except (BrokenPipeError, ConnectionError, OSError, EOFError) as exc: + # Standard transport / stdio-pipe failure: log distinctly + # at DEBUG (per-attempt noise is already covered by the + # WARNING below on the terminal failure). + last_error = exc + _logger.debug( + "MCP transport failure for %s (attempt %d): %s", + name, attempt + 1, exc, + ) + self._refresh_diagnostic_from_file() + await self.close() + if attempt + 1 >= max_attempts: + break + # Linear backoff: attempt 0 -> 1.0s, attempt 1 -> 2.2s. + # Formula: base * (attempt + 1) + jitter * attempt. + await asyncio.sleep((attempt + 1) * 1.0 + attempt * 0.2) + except Exception as exc: # unexpected transport failure + last_error = exc + _logger.debug( + "MCP unexpected failure for %s (attempt %d): %s", + name, attempt + 1, exc, + ) + self._refresh_diagnostic_from_file() + await self.close() + if attempt + 1 >= max_attempts: + break + await asyncio.sleep((attempt + 1) * 1.0 + attempt * 0.2) + assert last_error is not None + _logger.warning( + "MCP call %s failed after %d attempt(s): %s", + name, max_attempts, last_error, + ) + raise last_error + + # --- helpers --------------------------------------------------------- + + async def _list_tools(self, session: ClientSession) -> list[dict[str, Any]]: + all_tools: list[dict[str, Any]] = [] + cursor: str | None = None + while True: + page = await session.list_tools(cursor=cursor) + for tool in page.tools: + all_tools.append( + { + "name": tool.name, + "description": tool.description, + "inputSchema": tool.inputSchema, + } + ) + cursor = page.nextCursor + if not cursor: + break + return all_tools + + @staticmethod + def _format_result(name: str, response: Any) -> dict[str, Any]: + is_error = bool(getattr(response, "isError", False)) + content: list[dict[str, Any]] = [] + for block in getattr(response, "content", []) or []: + text = getattr(block, "text", None) + content.append({"type": getattr(block, "type", "text"), "text": text}) + text = "\n\n".join( + b["text"] for b in content if b.get("type") == "text" and b.get("text") + ).strip() + declared_error = re.match(r"^Error:\s*([a-z0-9_]+)\s*$", text, re.I) + server_error = text.lower().startswith("error:") + if is_error or server_error: + if declared_error: + msg = f"Engraphis rejected the request: {declared_error.group(1)}." + else: + msg = ( + "Engraphis rejected the request. Verify the parameters and " + "inspect the local Engraphis logs." + ) + raise EngraphisMcpToolError(msg) + return {"_tool": name, "isError": is_error, "content": content} + + # --- status ---------------------------------------------------------- + + async def status(self) -> dict[str, Any]: + tools = await self.list_tools() + return { + "connected": True, + "server": "engraphis", + "toolCount": len(tools), + "diagnosticHint": self.diagnostic_hint(), + } + + +def format_mcp_payload(payload: dict[str, Any]) -> str: + """Return the joined text content of a tool result, falling back to JSON.""" + parts: list[str] = [] + for block in payload.get("content", []) or []: + if block.get("type") == "text" and isinstance(block.get("text"), str): + parts.append(block["text"]) + joined = "\n\n".join(parts).strip() + return joined or json.dumps(payload, indent=2, default=str) diff --git a/integrations/prime_agent/src/engraphis_prime_agent/tools.py b/integrations/prime_agent/src/engraphis_prime_agent/tools.py new file mode 100644 index 00000000..88cd9dec --- /dev/null +++ b/integrations/prime_agent/src/engraphis_prime_agent/tools.py @@ -0,0 +1,509 @@ +"""9 Smart tool factories, each a (args, ctx) -> dict callable. + +Schema and semantics are translated 1:1 from +integrations/pi/src/tool-schemas.ts. The resulting callables work with +both EngraphisPrimeAgent and any prime-agent tool-registration surface that +matches the (args: dict, ctx: dict | None) -> dict contract. +""" +from __future__ import annotations + +from typing import Any, Awaitable, Callable + +from .config import EngraphisRuntimeConfig +from .mcp_client import EngraphisMcpClient, EngraphisMcpToolError + +# The runtime contract: prime-agent (and any compatible tool-registration +# surface) calls the registered callable with the model's args plus an +# optional ctx dict (conversation/session metadata). Both are accepted +# positionally; ctx defaults to None so the legacy single-arg call shape +# still works. +ToolFn = Callable[ + [dict[str, Any], dict[str, Any] | None], Awaitable[dict[str, Any]] +] + +# --- JSON Schemas (translated from tool-schemas.ts) ------------------------- +# The same defaults, bounds, and descriptions; identical behaviour across Pi +# and prime-agent integrations. + +_SESSION_SCHEMA: dict[str, Any] = { + "type": "object", + "additionalProperties": False, + "properties": { + "action": {"type": "string", "enum": ["start", "end"], "default": "start"}, + # `agent` is required by the underlying engraphis_session tool — + # we never want a silent fallback to a generic "prime-agent" + # name, so no `default` is declared. + "agent": {"type": "string", "minLength": 1, "maxLength": 200}, + "force_new": {"type": "boolean", "default": False}, + "goal": {"type": "string", "maxLength": 1000, "default": ""}, + "session_id": {"type": "string", "maxLength": 200, "default": ""}, + "summary": {"type": "string", "maxLength": 100000, "default": ""}, + "outcome": {"type": "string", "maxLength": 1000, "default": ""}, + "open_threads": { + "type": "array", + "items": {"type": "string"}, + "nullable": True, + "default": None, + }, + "token_budget": {"type": "integer", "minimum": 0, "maximum": 32768, "default": 512}, + "workspace": {"type": "string", "maxLength": 200}, + "repo": {"type": ["string", "null"], "maxLength": 200, "default": None}, + }, + "required": ["agent"], +} + +_RECALL_CONTEXT_SCHEMA: dict[str, Any] = { + "type": "object", + "additionalProperties": False, + "properties": { + "query": {"type": "string", "minLength": 1, "maxLength": 100000}, + "k": {"type": "integer", "minimum": 1, "maximum": 50, "default": 8}, + "session_id": {"type": ["string", "null"], "default": None}, + "token_budget": { + "type": "integer", + "minimum": 0, + "maximum": 32768, + "default": 1024, + }, + "workspace": {"type": ["string", "null"], "maxLength": 200, "default": None}, + "repo": {"type": ["string", "null"], "maxLength": 200, "default": None}, + }, + "required": ["query"], +} + +_REMEMBER_SCHEMA: dict[str, Any] = { + "type": "object", + "additionalProperties": False, + "properties": { + "content": {"type": "string", "minLength": 1, "maxLength": 100000}, + "mtype": { + "type": "string", + "enum": ["semantic", "episodic", "procedural", "working"], + "default": "semantic", + }, + "importance": {"type": "number", "minimum": 0, "maximum": 1, "default": 0}, + "session_id": {"type": ["string", "null"], "default": None}, + "workspace": {"type": "string", "maxLength": 200}, + "repo": {"type": ["string", "null"], "maxLength": 200, "default": None}, + }, + "required": ["content"], +} + +_DISCOVER_ACTIONS_SCHEMA: dict[str, Any] = { + "type": "object", + "additionalProperties": False, + "properties": { + "task": {"type": "string", "minLength": 1, "maxLength": 2000}, + "category": { + "type": "string", + "enum": ["memory", "governance", "code", "audit", "ops", ""], + "maxLength": 100, + "default": "", + }, + "intent": { + "type": "string", + "enum": ["any", "read", "write", "admin", "destructive"], + "default": "any", + }, + "limit": {"type": "integer", "minimum": 1, "maximum": 3, "default": 1}, + }, + "required": ["task"], +} + +_EXECUTE_PARAM_PROPS = { + "capability_id": {"type": "string", "minLength": 8, "maxLength": 128}, + "schema_digest": {"type": "string", "minLength": 8, "maxLength": 128}, + "arguments": {"type": "object", "additionalProperties": True}, +} + +_EXECUTE_READ_SCHEMA: dict[str, Any] = { + "type": "object", + "additionalProperties": False, + "properties": _EXECUTE_PARAM_PROPS, + "required": ["capability_id", "schema_digest", "arguments"], +} + +_EXECUTE_ACTION_SCHEMA: dict[str, Any] = { + "type": "object", + "additionalProperties": False, + "properties": _EXECUTE_PARAM_PROPS, + "required": ["capability_id", "schema_digest", "arguments"], +} + +_GET_MEMORY_SCHEMA: dict[str, Any] = { + "type": "object", + "additionalProperties": False, + "properties": { + "memory_id": {"type": "string", "minLength": 1, "maxLength": 200}, + "workspace": {"type": "string", "maxLength": 200}, + "repo": {"type": ["string", "null"], "maxLength": 200, "default": None}, + }, + "required": ["memory_id"], +} + +_UPDATE_MEMORY_SCHEMA: dict[str, Any] = { + "type": "object", + "additionalProperties": False, + "properties": { + "memory_id": {"type": "string", "minLength": 1, "maxLength": 200}, + "title": {"type": ["string", "null"], "maxLength": 500, "default": None}, + "mtype": { + "type": ["string", "null"], + "enum": ["semantic", "episodic", "procedural", "working", None], + "default": None, + }, + "importance": {"type": ["number", "null"], "minimum": 0, "maximum": 1, "default": None}, + "actor": {"type": "string", "maxLength": 200, "default": "user"}, + "workspace": {"type": "string", "maxLength": 200}, + "repo": {"type": ["string", "null"], "maxLength": 200, "default": None}, + }, + "required": ["memory_id"], +} + +_CONFLICT_REVIEW_SCHEMA: dict[str, Any] = { + "type": "object", + "additionalProperties": False, + "properties": { + "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50}, + "workspace": {"type": "string", "maxLength": 200}, + "repo": {"type": ["string", "null"], "maxLength": 200, "default": None}, + }, + # All three parameters are optional; the empty list documents that + # explicitly so consumers don't have to guess whether the missing + # `required` key means "all fields implicit" or "no fields required". + "required": [], +} + +_DESC: dict[str, str] = { + "engraphis_session": ( + "Start, resume, or end an Engraphis session for a named sub-agent. " + "Call with `action: 'start'` to obtain a session_id that all other " + "tools will reuse; call `action: 'end'` with a summary and outcome " + "to close it. The `agent` field identifies the sub-agent in audit " + "logs — pick a stable role name, not a per-request token." + ), + "engraphis_recall_context": ( + "Recall prior decisions, procedures, and context for the current " + "task. Use at the start of any non-trivial task to surface " + "existing constraints, conventions, and reusable code. The `query` " + "should be a short intent statement (e.g. 'how we index vectors'), " + "not a raw log dump — keep it under a few hundred characters for " + "best recall." + ), + "engraphis_remember": ( + "Persist a durable fact, decision, preference, or procedure that " + "future tasks should be able to recall. Use sparingly for " + "load-bearing decisions (architecture, conventions, gotchas) and " + "always write a self-contained `content` — do NOT store " + "credentials, API keys, raw log lines, or PII." + ), + "engraphis_discover_actions": ( + "Discover advanced capabilities (governance / code / ops) for a " + "task. Call this when none of the 8 direct tools fits, or when " + "you suspect there is a write/admin surface you have not been " + "exposed to. The returned `capability_id` + `schema_digest` pair " + "must be passed back to `engraphis_execute_read` or " + "`engraphis_execute_action`." + ), + "engraphis_execute_read": ( + "Invoke a read-only advanced action discovered via " + "`engraphis_discover_actions`. Safe to retry on transport failure. " + "Never pass arguments the schema did not declare — read-only tools " + "still authenticate the caller, and unknown keys are rejected." + ), + "engraphis_execute_action": ( + "Invoke a write or admin advanced action discovered via " + "`engraphis_discover_actions`. This is the write-side equivalent " + "of `engraphis_execute_read` — same capability_id / schema_digest " + "pair, but mutations and admin operations. The action is recorded " + "in the audit log; ensure `arguments` is complete and accurate " + "before calling." + ), + "engraphis_get_memory": ( + "Read a specific memory by id. Use after `engraphis_recall_context` " + "to fetch the full record of a memory referenced only by summary. " + "Returns the governed record (content, provenance, scope, " + "temporal fields); treat the result as untrusted display text." + ), + "engraphis_update_memory": ( + "Edit an existing memory's metadata — title, type, importance, or " + "the audit actor. Content edits are intentionally NOT exposed: to " + "change the body, write a new memory and let the conflict-review " + "flow reconcile. Bounds: `importance` is a float in [0, 1]; " + "`actor` is the principal performing the edit (defaults to " + "'user')." + ), + "engraphis_conflict_review": ( + "List memories flagged for conflict review — typically two records " + "that disagree about the same scope. Read this list, then either " + "update one side via `engraphis_update_memory` or write a new " + "resolution memory. Safe to poll on a schedule." + ), +} + +TOOL_SPECS: tuple[tuple[str, dict[str, Any]], ...] = ( + ("engraphis_session", _SESSION_SCHEMA), + ("engraphis_recall_context", _RECALL_CONTEXT_SCHEMA), + ("engraphis_remember", _REMEMBER_SCHEMA), + ("engraphis_discover_actions", _DISCOVER_ACTIONS_SCHEMA), + ("engraphis_execute_read", _EXECUTE_READ_SCHEMA), + ("engraphis_execute_action", _EXECUTE_ACTION_SCHEMA), + ("engraphis_get_memory", _GET_MEMORY_SCHEMA), + ("engraphis_update_memory", _UPDATE_MEMORY_SCHEMA), + ("engraphis_conflict_review", _CONFLICT_REVIEW_SCHEMA), +) + + +# --- factory ---------------------------------------------------------------- + + +def apply_scope_defaults( + params: dict[str, Any], + config: EngraphisRuntimeConfig, + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Translate of integrations/pi/src/tool-schemas.ts::applyScopeDefaults. + + Model-supplied values win. Workspace/repo defaults from the runtime config + are only injected when the caller has not already set them and the chosen + workspace matches the configured default (mirrors Pi behaviour). + """ + result: dict[str, Any] = dict(extra or {}) + result.update(params) + if "workspace" not in result and config.default_workspace: + result["workspace"] = config.default_workspace + if ( + "repo" not in result + and config.default_repo + and config.default_workspace + and result.get("workspace") == config.default_workspace + ): + result["repo"] = config.default_repo + return result + + +# --- lightweight schema validation ------------------------------------------ +# +# We avoid pulling in `jsonschema` as a top-level dependency and instead +# implement the small subset of JSON Schema that our 9 tool definitions +# actually use. Each tool's schema is hand-written, so a focused validator +# is enough and keeps the runtime surface zero-extra-dep. +# +# Supported keywords: +# - type: str | list[str] (with "null" used as the nullable sentinel) +# - enum: sequence of allowed values +# - required: list of required property names +# - additionalProperties: bool (False rejects unknown keys) +# - properties: per-keyword sub-schemas (each one runs through the same +# validator, recursively for `items`) +# - minLength / maxLength: string length bounds +# - minimum / maximum: int/number bounds +# - minItems / maxItems: array length bounds +# +# The `default` keyword is accepted but never enforced — the call sites do +# their own defaulting (see `apply_scope_defaults`). + +_TYPE_RANK = { + "string": str, + "integer": int, + "number": (int, float), + "boolean": bool, + "array": list, + "object": dict, + "null": type(None), +} + + +def _coerce_type(value: Any, declared: Any) -> bool: + """True iff `value` satisfies the JSON-Schema-style `type` keyword.""" + if isinstance(declared, str): + declared = [declared] + # bool is a subclass of int in Python; reject it where the schema + # says "integer" / "number" so a stray `True` is not silently accepted. + for t in declared: + py = _TYPE_RANK.get(t) + if py is None: + continue + if t in ("integer", "number") and isinstance(value, bool): + return False + if not isinstance(value, py): + return False + return True + + +def _validate_schema(schema: dict[str, Any], value: Any, path: str = "") -> list[str]: + errors: list[str] = [] + declared_type = schema.get("type") + if declared_type is not None: + if not _coerce_type(value, declared_type): + errors.append( + f"{path or 'value'}: expected type {declared_type}, " + f"got {type(value).__name__}" + ) + return errors # type is wrong; deeper checks would be misleading + if "enum" in schema and value not in schema["enum"]: + errors.append( + f"{path or 'value'}: must be one of {list(schema['enum'])!r}, " + f"got {value!r}" + ) + if declared_type == "string" or "minLength" in schema or "maxLength" in schema: + if isinstance(value, str): + lo = schema.get("minLength") + hi = schema.get("maxLength") + if lo is not None and len(value) < lo: + errors.append( + f"{path or 'value'}: string length {len(value)} < minLength {lo}" + ) + if hi is not None and len(value) > hi: + errors.append( + f"{path or 'value'}: string length {len(value)} > maxLength {hi}" + ) + if declared_type in ("integer", "number") or "minimum" in schema or "maximum" in schema: + if isinstance(value, (int, float)) and not isinstance(value, bool): + lo = schema.get("minimum") + hi = schema.get("maximum") + if lo is not None and value < lo: + errors.append(f"{path or 'value'}: {value} < minimum {lo}") + if hi is not None and value > hi: + errors.append(f"{path or 'value'}: {value} > maximum {hi}") + if declared_type == "array" or "minItems" in schema or "maxItems" in schema: + if isinstance(value, list): + lo = schema.get("minItems") + hi = schema.get("maxItems") + if lo is not None and len(value) < lo: + errors.append( + f"{path or 'value'}: array length {len(value)} < minItems {lo}" + ) + if hi is not None and len(value) > hi: + errors.append( + f"{path or 'value'}: array length {len(value)} > maxItems {hi}" + ) + item_schema = schema.get("items") + if isinstance(item_schema, dict): + for i, item in enumerate(value): + errors.extend( + _validate_schema(item_schema, item, f"{path}[{i}]") + ) + if declared_type == "object" or "properties" in schema: + if isinstance(value, dict): + properties = schema.get("properties") or {} + required = schema.get("required") or [] + for key in required: + if key not in value: + errors.append(f"{path}.{key}: required") + for key, sub in properties.items(): + if key in value: + errors.extend( + _validate_schema(sub, value[key], f"{path}.{key}") + ) + additional = schema.get("additionalProperties", True) + if additional is False: + unknown = sorted(set(value) - set(properties)) + for key in unknown: + errors.append(f"{path}.{key}: unknown property (additionalProperties=False)") + return errors + + +def validate_args(name: str, args: dict[str, Any] | None) -> dict[str, Any]: + """Validate `args` against the named tool's JSON Schema. + + Returns the cleaned args dict on success. Raises + `EngraphisMcpToolError` with a single message that lists every + violation (each prefixed with the JSON-Pointer-ish path of the + offending field). Designed for the agent layer to call before + dispatching a tool, so the model sees a precise rejection instead + of a generic MCP error. + """ + schemas = dict(TOOL_SPECS) + if name not in schemas: + raise KeyError(f"Unknown Engraphis tool: {name}") + if args is None: + args = {} + if not isinstance(args, dict): + raise EngraphisMcpToolError( + f"{name}: args must be a dict, got {type(args).__name__}" + ) + errors = _validate_schema(schemas[name], args) + if errors: + joined = "; ".join(errors) + raise EngraphisMcpToolError(f"{name} args invalid: {joined}") + return args + + +def tool_spec(name: str) -> dict[str, Any]: + """Return just the meta dict for a single named tool. + + Convenience for callers that need the schema + description without + binding a client/session (e.g. for prompt inspection or registering + into a tool surface that already has its own client wiring). + """ + schemas = dict(TOOL_SPECS) + if name not in schemas: + raise KeyError(f"Unknown Engraphis tool: {name}") + return { + "name": name, + "description": _DESC[name], + "parameters": schemas[name], + } + + +def build_tool( + name: str, + client: EngraphisMcpClient, + config: EngraphisRuntimeConfig, + *, + session_id: str | None = None, +) -> tuple[ToolFn, dict[str, Any]]: + """Return (callable, meta dict) for the named tool, bound to a client. + + The callable matches the prime-agent tool contract:: + + async def fn(args: dict, ctx: dict | None = None) -> dict + + `ctx` is accepted positionally for compatibility with surfaces that + pass conversation/session metadata; the Engraphis tools do not + currently read it. Schema is a JSON Schema dict that any downstream + tool-registration surface can translate to its own format. + + Precedence: caller-supplied `session_id` (via the args dict) ALWAYS + wins over the `session_id` bound at build time. The bound value is + only injected when the args dict does not already include one — + this lets a single tool instance be re-used across requests that + occasionally need to operate on a different session (e.g. a + cross-session audit lookup). + """ + schemas = dict(TOOL_SPECS) + if name not in schemas: + raise KeyError(f"Unknown Engraphis tool: {name}") + + async def _call( + args: dict[str, Any], + _ctx: dict[str, Any] | None = None, + ) -> dict[str, Any]: + # _ctx is reserved for future per-call overrides (e.g. trace ids, + # tenant hints); current MCP tools don't need it, so we accept + # and ignore. The leading underscore keeps the parameter name + # visible in stack traces / introspection while signalling that + # it is intentionally unused. The signature stays compatible + # with agent.py's `await fn(args, ctx)` call site. + params = apply_scope_defaults(args, config) + # Precedence: caller-supplied session_id wins over the bound one. + if session_id and "session_id" not in params: + params["session_id"] = session_id + return await client.call_tool(name, params) + + meta = {"name": name, "description": _DESC[name], "parameters": schemas[name]} + return _call, meta + + +def all_tools( + client: EngraphisMcpClient, + config: EngraphisRuntimeConfig, + *, + session_id: str | None = None, +) -> list[tuple[ToolFn, dict[str, Any]]]: + """Build the 9 tool (callable, schema) pairs bound to the given client/session.""" + return [ + build_tool(name, client, config, session_id=session_id) + for name, _schema in TOOL_SPECS + ] diff --git a/integrations/prime_agent/tests/__init__.py b/integrations/prime_agent/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/integrations/prime_agent/tests/conftest.py b/integrations/prime_agent/tests/conftest.py new file mode 100644 index 00000000..ba68af1a --- /dev/null +++ b/integrations/prime_agent/tests/conftest.py @@ -0,0 +1,296 @@ +"""Pytest fixtures: in-process fake MCP server + live-gated real client. + +The fake server monkey-patches `mcp.client.stdio.stdio_client` so the real +`ClientSession` runs over an `anyio` memory-stream transport. Tests then +exercise the full JSON-RPC framing without an `engraphis-mcp` subprocess. + +Set `ENGRAPHIS_INTEGRATION_LIVE=1` to skip the fake and boot a real +`engraphis-mcp` subprocess for the live integration tests. +""" +from __future__ import annotations + +import asyncio +import contextlib +import json +import os +from collections.abc import AsyncIterator +from typing import Any + +import anyio +import pytest +import pytest_asyncio + +from engraphis_prime_agent.config import EngraphisRuntimeConfig +from engraphis_prime_agent.mcp_client import EngraphisMcpClient + +__all__ = ["FakeMcpServer", "live_mcp_client", "mcp_client"] + + +CORE_TOOL_NAMES = ( + "engraphis_session", + "engraphis_recall_context", + "engraphis_remember", + "engraphis_discover_actions", + "engraphis_execute_read", + "engraphis_execute_action", + "engraphis_get_memory", + "engraphis_update_memory", + "engraphis_conflict_review", +) + + +class FakeMcpServer: + """In-process stand-in for the Engraphis MCP gateway. + + The patched `stdio_client` returns ``(read_stream, write_stream)`` over an + anyio memory channel pair. The server task drains requests, calls the + provided handler, and writes back responses. + """ + + def __init__(self, tool_names: tuple[str, ...] = CORE_TOOL_NAMES) -> None: + async def _default(name: str, args: dict[str, Any]) -> dict[str, Any]: + if name == "engraphis_session": + # Pretend a session was created and echo the request back. + payload = { + "session_id": f"ses_fake_{next(self._session_counter):04d}", + "agent": args.get("agent", "unknown"), + "workspace": args.get("workspace"), + "repo": args.get("repo"), + "action": args.get("action", "start"), + } + if args.get("action") == "end": + payload["status"] = "closed" + return { + "_tool": name, + "content": [{"type": "text", "text": json.dumps(payload)}], + } + return {"_tool": name, "content": [{"type": "text", "text": json.dumps(args)}]} + + self.tool_handler = _default + self._session_counter = iter(range(1, 10_000)) + self.tool_names = tool_names + self.call_log: list[tuple[str, dict[str, Any]]] = [] + self.fail_next: Exception | None = None + self.crash_on_next: bool = False + # Shared streams so the test can restart the server task while the + # client keeps the same transport alive. + self._shared_server_to_client_send: Any = None + self._shared_client_to_server_send: Any = None + self._server_task: asyncio.Task[None] | None = None + self._original_stdio_client: Any = None + self._installed = False + + def install(self) -> None: + from mcp.client import stdio as stdio_mod + + self._original_stdio_client = stdio_mod.stdio_client + + @contextlib.asynccontextmanager + async def _fake_stdio(_params, errlog=None): # type: ignore[no-untyped-def] + # If streams haven't been allocated yet (first call), create them. + if self._shared_client_to_server_send is None: + # anyio.create_memory_object_stream returns (send, receive). + s2c_send, c_read = anyio.create_memory_object_stream(max_buffer_size=4096) + c2s_send, s_read = anyio.create_memory_object_stream(max_buffer_size=4096) + self._shared_server_to_client_send = s2c_send + self._shared_client_to_server_send = c2s_send + self._server_read = s_read + self._client_read = c_read + self._start_server() + elif self._server_task is None or self._server_task.done(): + # Re-entry after a transport failure: spin a fresh server. + self._start_server() + try: + yield (self._client_read, self._shared_client_to_server_send) + finally: + if self._server_task and not self._server_task.done(): + self._server_task.cancel() + with contextlib.suppress(BaseException): + await self._server_task + + # Patch both the source module AND the binding used by the client. + stdio_mod.stdio_client = _fake_stdio # type: ignore[assignment] + import engraphis_prime_agent.mcp_client as _client_mod + + self._original_client_binding = _client_mod.stdio_client + _client_mod.stdio_client = _fake_stdio # type: ignore[assignment] + self._installed = True + + def _start_server(self) -> None: + from mcp.shared.message import SessionMessage + + self._server_task = asyncio.create_task( + self._serve(self._server_read, self._shared_server_to_client_send, SessionMessage) + ) + + async def restart_server(self) -> None: + """Kill the server task and start a fresh one on the same streams. + + Used to simulate a transport failure (server crash) followed by the + client successfully reconnecting. + """ + if self._server_task and not self._server_task.done(): + self._server_task.cancel() + with contextlib.suppress(BaseException): + await self._server_task + self._start_server() + + def restore(self) -> None: + from mcp.client import stdio as stdio_mod + import engraphis_prime_agent.mcp_client as _client_mod + + if self._installed and self._original_stdio_client is not None: + stdio_mod.stdio_client = self._original_stdio_client # type: ignore[assignment] + if getattr(self, "_original_client_binding", None) is not None: + _client_mod.stdio_client = self._original_client_binding # type: ignore[assignment] + self._installed = False + if self._server_task and not self._server_task.done(): + self._server_task.cancel() + + async def _serve(self, read_stream, write_stream, SessionMessage) -> None: # type: ignore[no-untyped-def] + """Minimal MCP server. Handles initialize / notifications / tools/list / tools/call.""" + from mcp.shared.message import JSONRPCMessage + from mcp.types import ( + CallToolResult, + InitializeResult, + JSONRPCError, + JSONRPCResponse, + ListToolsResult, + TextContent, + Tool, + ) + + async def reply_ok(req_id: Any, result: Any) -> None: + # JSONRPCResponse.result is typed as a dict; dump pydantic models. + payload_dict = ( + result.model_dump(by_alias=True, mode="json", exclude_none=True) + if hasattr(result, "model_dump") + else result + ) + payload = JSONRPCResponse(jsonrpc="2.0", id=req_id, result=payload_dict) + await write_stream.send(SessionMessage(message=JSONRPCMessage(payload))) + + async def reply_error(req_id: Any, message: str) -> None: + err = JSONRPCError( + jsonrpc="2.0", + id=req_id, + error={"code": -32601, "message": message}, + ) + await write_stream.send(SessionMessage(message=JSONRPCMessage(err))) + + while True: + try: + message: Any = await read_stream.receive() + except (anyio.EndOfStream, asyncio.CancelledError): + return + # `message` is a SessionMessage; `.message` is a JSONRPCMessage; + # `.root` is the actual JSONRPCRequest / JSONRPCNotification. + jsonrpc = getattr(message, "message", message) + request = getattr(jsonrpc, "root", jsonrpc) + method = getattr(request, "method", None) + request_id = getattr(request, "id", None) + params = getattr(request, "params", None) or {} + # If the tool handler itself raises (e.g. a transport-failure + # simulation), we let the exception propagate so the server task + # exits. The client will see a closed receive stream and treat it + # as a transport failure, exercising the retry path. + if method == "tools/call": + name = params.get("name", "") + arguments = params.get("arguments") or {} + self.call_log.append((name, arguments)) + if self.fail_next is not None: + exc = self.fail_next + self.fail_next = None + raise exc + if self.crash_on_next: + self.crash_on_next = False + return + result = await self.tool_handler(name, arguments) + content = [ + TextContent(type="text", text=block.get("text", "")) + for block in (result.get("content", []) or []) + ] + await reply_ok( + request_id, + CallToolResult(content=content, isError=bool(result.get("isError"))), + ) + continue + try: + if method == "initialize": + await reply_ok( + request_id, + InitializeResult( + protocolVersion="2025-03-26", + capabilities={}, + serverInfo=ServerInfo(name="fake-engraphis", version="0.0.0"), + ), + ) + elif method == "notifications/initialized": + continue + elif method == "tools/list": + tools = [ + Tool( + name=n, + description=f"fake {n}", + inputSchema={"type": "object", "properties": {}}, + ) + for n in self.tool_names + ] + await reply_ok( + request_id, ListToolsResult(tools=tools, nextCursor=None) + ) + else: + await reply_error(request_id, f"Method not found: {method}") + except Exception as exc: # noqa: BLE001 — surface as tool error + try: + await reply_ok( + request_id, + CallToolResult( + content=[TextContent(type="text", text=f"Error: {exc}")], + isError=True, + ), + ) + except Exception: + return + + +def ServerInfo(name: str, version: str) -> Any: # noqa: N802 — helper + from mcp.types import Implementation + + return Implementation(name=name, version=version) + + +@pytest_asyncio.fixture +async def fake_mcp_server() -> AsyncIterator[FakeMcpServer]: + server = FakeMcpServer() + server.install() + try: + yield server + finally: + server.restore() + + +@pytest_asyncio.fixture +async def mcp_client(fake_mcp_server: FakeMcpServer) -> AsyncIterator[EngraphisMcpClient]: + """Return a connected `EngraphisMcpClient` backed by the fake server.""" + config = EngraphisRuntimeConfig(command="ignored", environment={}) + client = EngraphisMcpClient(config) + await client.connect() + try: + yield client + finally: + await client.close() + + +@pytest_asyncio.fixture +async def live_mcp_client() -> AsyncIterator[EngraphisMcpClient]: + """Yield a real EngraphisMcpClient against `engraphis-mcp` if available.""" + if not os.environ.get("ENGRAPHIS_INTEGRATION_LIVE"): + pytest.skip("set ENGRAPHIS_INTEGRATION_LIVE=1 to run live integration tests") + config = EngraphisRuntimeConfig(command="engraphis-mcp", environment={}) + client = EngraphisMcpClient(config) + await client.connect() + try: + yield client + finally: + await client.close() diff --git a/integrations/prime_agent/tests/test_config.py b/integrations/prime_agent/tests/test_config.py new file mode 100644 index 00000000..0a307082 --- /dev/null +++ b/integrations/prime_agent/tests/test_config.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +from dataclasses import FrozenInstanceError, fields + +import pytest + +from engraphis_prime_agent.config import ( + DEFAULT_AGENT_NAMES, + EngraphisRuntimeConfig, + _engraphis_environment, + _non_blank, + build_runtime_config, +) + + +def test_non_blank_trims_and_rejects_empty() -> None: + assert _non_blank(None) is None + assert _non_blank("") is None + assert _non_blank(" ") is None + assert _non_blank(" hello ") == "hello" + + +def test_engraphis_environment_allowlist() -> None: + env = { + "ENGRAPHIS_DB_PATH": "/tmp/x.db", + "ENGRAPHIS_WORKSPACE": "demo", + "PATH": "/usr/bin", + "Path": "C:\\Windows", + "SystemRoot": "C:\\Windows", + "ComSpec": "C:\\Windows\\System32\\cmd.exe", + "ANTHROPIC_API_KEY": "sk-secret", + "HOME": "/root", + "USER": "alice", + } + forwarded = _engraphis_environment(env) + assert set(forwarded) == { + "ENGRAPHIS_DB_PATH", + "ENGRAPHIS_WORKSPACE", + "PATH", + "Path", + "SystemRoot", + "ComSpec", + } + assert forwarded["ENGRAPHIS_DB_PATH"] == "/tmp/x.db" + assert "ANTHROPIC_API_KEY" not in forwarded + assert "HOME" not in forwarded + + +def test_engraphis_environment_ignores_non_string_values() -> None: + env = {"ENGRAPHIS_WORKSPACE": 123, "PATH": None} # type: ignore[dict-item] + assert _engraphis_environment(env) == {} + + +def test_build_runtime_config_defaults() -> None: + cfg = build_runtime_config(env={}) + assert cfg.command == "engraphis-mcp" + assert cfg.args == () + assert cfg.cwd is None + assert cfg.default_workspace is None + assert cfg.default_repo is None + assert cfg.environment == {} + + +def test_build_runtime_config_reads_env() -> None: + env = { + "ENGRAPHIS_MCP_COMMAND": "C:/venv/Scripts/engraphis-mcp.exe", + "ENGRAPHIS_WORKSPACE": "engraphis", + "ENGRAPHIS_REPO": "prime-agent", + "ENGRAPHIS_DB_PATH": "C:/data/x.db", + "ANTHROPIC_API_KEY": "sk-secret", + } + cfg = build_runtime_config(env=env) + assert cfg.command == "C:/venv/Scripts/engraphis-mcp.exe" + assert cfg.default_workspace == "engraphis" + assert cfg.default_repo == "prime-agent" + assert "ANTHROPIC_API_KEY" not in cfg.environment + assert cfg.environment["ENGRAPHIS_DB_PATH"] == "C:/data/x.db" + + +def test_build_runtime_config_command_override() -> None: + cfg = build_runtime_config(env={}, command="/abs/engraphis-mcp") + assert cfg.command == "/abs/engraphis-mcp" + + +def test_build_runtime_config_trims_blank_env() -> None: + env = {"ENGRAPHIS_WORKSPACE": " ", "ENGRAPHIS_REPO": " real "} + cfg = build_runtime_config(env=env) + assert cfg.default_workspace is None + assert cfg.default_repo == "real" + + +def test_default_agent_names_are_eight() -> None: + assert len(DEFAULT_AGENT_NAMES) == 8 + assert "researcher" in DEFAULT_AGENT_NAMES + assert "coder" in DEFAULT_AGENT_NAMES + assert all(isinstance(name, str) and name for name in DEFAULT_AGENT_NAMES) + # Names must be unique (default fleet keys must be hashable). + assert len(set(DEFAULT_AGENT_NAMES)) == 8 + + +def test_runtime_config_is_frozen() -> None: + cfg = EngraphisRuntimeConfig() + try: + cfg.command = "x" # type: ignore[misc] + except Exception: + return + raise AssertionError("EngraphisRuntimeConfig should be frozen") + + +def test_runtime_config_frozen_raises_frozen_instance_error_on_every_field() -> None: + """Every public field must reject assignment with FrozenInstanceError.""" + cfg = EngraphisRuntimeConfig( + command="x", + args=("a", "b"), + cwd="C:/work", + default_workspace="ws", + default_repo="repo", + environment={"ENGRAPHIS_DB_PATH": "/tmp/x.db"}, + ) + for name in ("command", "args", "cwd", "default_workspace", "default_repo", "environment"): + with pytest.raises(FrozenInstanceError): + setattr(cfg, name, "mutated") # type: ignore[misc] + + +def test_runtime_config_field_names_are_stable() -> None: + """Lock the public dataclass surface so a refactor that renames a field + is caught here rather than at a downstream caller.""" + expected = { + "command", + "args", + "cwd", + "default_workspace", + "default_repo", + "environment", + } + assert {f.name for f in fields(EngraphisRuntimeConfig)} == expected + + +def test_build_runtime_config_preserves_args_tuple_type() -> None: + """`args` must remain a tuple — the stdio gateway expects a sequence and + downstream code (e.g. ``list(self._config.args)``) relies on tuple semantics.""" + src_args = ("--flag", "value", "C:/path/with space") + cfg = build_runtime_config(env={}, args=src_args) + assert isinstance(cfg.args, tuple) + assert cfg.args == src_args + # Mutating the original tuple must not leak into the config. + assert cfg.args is not src_args or cfg.args == src_args + + +def test_build_runtime_config_empty_args_default_to_empty_tuple() -> None: + """The default is an empty tuple, not None or a list, so callers can + iterate without a None-check.""" + cfg = build_runtime_config(env={}) + assert cfg.args == () + assert isinstance(cfg.args, tuple) + + +def test_engraphis_environment_handles_windows_specific_keys() -> None: + """SystemRoot and ComSpec must be forwarded on Windows. We don't assume + Windows-only — any platform that has these keys in env should see them + through the allowlist.""" + env = { + "SystemRoot": "C:\\Windows", + "ComSpec": "C:\\Windows\\System32\\cmd.exe", + "PATHEXT": ".EXE;.BAT", # NOT in the allowlist; must be dropped. + "WINDIR": "C:\\Windows", # NOT in the allowlist; must be dropped. + } + forwarded = _engraphis_environment(env) + assert forwarded["SystemRoot"] == "C:\\Windows" + assert forwarded["ComSpec"] == "C:\\Windows\\System32\\cmd.exe" + assert "PATHEXT" not in forwarded + assert "WINDIR" not in forwarded + + +def test_build_runtime_config_trims_default_workspace_and_repo_from_env() -> None: + """Whitespace-padded env values must be stripped, and a pure-whitespace + value must become None (not the literal whitespace).""" + env = { + "ENGRAPHIS_WORKSPACE": " ", + "ENGRAPHIS_REPO": "\trepo\t", + "ENGRAPHIS_DB_PATH": " /tmp/x.db ", + } + cfg = build_runtime_config(env=env) + assert cfg.default_workspace is None + assert cfg.default_repo == "repo" + # The env allowlist also strips; the entry must reflect the trimmed value. + assert cfg.environment["ENGRAPHIS_DB_PATH"] == "/tmp/x.db" + + +def test_build_runtime_config_does_not_mutate_input_env() -> None: + """`build_runtime_config` must not mutate the caller's env mapping.""" + env = { + "ENGRAPHIS_WORKSPACE": " ws ", + "ENGRAPHIS_REPO": " repo ", + "ENGRAPHIS_DB_PATH": " /tmp/x.db ", + "PATH": " /usr/bin ", + } + snapshot = dict(env) + build_runtime_config(env=env) + assert env == snapshot + + +def test_engraphis_environment_empty_input_returns_empty_dict() -> None: + """Defensive: an empty mapping must produce an empty dict, not raise.""" + assert _engraphis_environment({}) == {} + + +def test_engraphis_environment_skips_prefix_only_keys_without_value() -> None: + """An ENGRAPHIS_-prefixed key whose value is non-string must be skipped + rather than forwarded as-is (which would crash subprocess.Popen).""" + env = { + "ENGRAPHIS_DB_PATH": 42, # type: ignore[dict-item] + "ENGRAPHIS_WORKSPACE": None, # type: ignore[dict-item] + } + assert _engraphis_environment(env) == {} # type: ignore[arg-type] + + +def test_non_blank_strips_tabs_and_newlines() -> None: + """`_non_blank` is the single source of truth for trimming env values; + tabs and newlines should be treated like spaces.""" + assert _non_blank("\t\n hi \n\t") == "hi" + assert _non_blank("\t\n \n\t") is None + + +def test_runtime_config_as_subprocess_env_returns_independent_copy() -> None: + """Mutating the dict returned by as_subprocess_env must not change the + frozen config's own mapping.""" + cfg = EngraphisRuntimeConfig( + command="x", + environment={"ENGRAPHIS_DB_PATH": "/tmp/x.db"}, + ) + env = cfg.as_subprocess_env() + env["ENGRAPHIS_DB_PATH"] = "/mutated/y.db" + assert cfg.environment["ENGRAPHIS_DB_PATH"] == "/tmp/x.db" diff --git a/integrations/prime_agent/tests/test_fleet.py b/integrations/prime_agent/tests/test_fleet.py new file mode 100644 index 00000000..aa0b722a --- /dev/null +++ b/integrations/prime_agent/tests/test_fleet.py @@ -0,0 +1,411 @@ +"""Tests for EngraphisPrimeAgent and PrimeAgentFleet.""" +from __future__ import annotations + +import pytest + +from engraphis_prime_agent.agent import EngraphisPrimeAgent, PrimeAgentFleet +from engraphis_prime_agent.config import ( + DEFAULT_AGENT_NAMES, + EngraphisRuntimeConfig, +) +from engraphis_prime_agent.mcp_client import EngraphisMcpClient +from engraphis_prime_agent.tools import TOOL_SPECS + + +# Auto-use the fake MCP server for every test in this module so that any +# test which constructs an EngraphisMcpClient (directly or via the fleet) +# gets the in-process fake transport, not a real subprocess. +@pytest.fixture(autouse=True) +def _install_fake(fake_mcp_server) -> None: + return None + + +@pytest.fixture +async def fleet() -> PrimeAgentFleet: + f = PrimeAgentFleet( + workspace="test", + config=EngraphisRuntimeConfig(command="ignored", environment={}), + ) + await f.client.connect() + try: + yield f + finally: + await f.aclose() + + +@pytest.mark.asyncio +async def test_fleet_default_names_is_eight() -> None: + f = PrimeAgentFleet(workspace="x") + assert len(f) == 8 + assert f.names() == DEFAULT_AGENT_NAMES + + +@pytest.mark.asyncio +async def test_fleet_custom_agent_names() -> None: + custom = ("a", "b", "c", "d", "e", "f", "g", "h") + f = PrimeAgentFleet(workspace="x", agent_names=custom) + assert f.names() == custom + + +@pytest.mark.asyncio +async def test_subagent_repr_and_contains() -> None: + f = PrimeAgentFleet(workspace="x") + assert "researcher" in f + assert f["researcher"].name == "researcher" + + +@pytest.mark.asyncio +async def test_subagent_rejects_blank_name() -> None: + config = EngraphisRuntimeConfig(command="ignored", environment={}) + client = EngraphisMcpClient(config) + with pytest.raises(ValueError): + EngraphisPrimeAgent(" ", client, config) + with pytest.raises(ValueError): + EngraphisPrimeAgent("", client, config) + + +@pytest.mark.asyncio +async def test_status_reports_workspace_and_agents() -> None: + f = PrimeAgentFleet(workspace="demo") + status = f.status() + assert status["workspace"] == "demo" + assert len(status["agents"]) == 8 + for entry in status["agents"]: + assert "name" in entry + assert "session_id" in entry + + +@pytest.mark.asyncio +async def test_start_session_returns_session_id_and_caches_it(fleet) -> None: + agent = fleet["researcher"] + sid = await agent.start_session() + assert isinstance(sid, str) and sid + # Second call is a no-op. + sid2 = await agent.start_session() + assert sid2 == sid + assert agent.session_id == sid + + +@pytest.mark.asyncio +async def test_force_new_starts_a_fresh_session(fleet) -> None: + agent = fleet["researcher"] + sid1 = await agent.start_session() + sid2 = await agent.start_session(force_new=True) + assert sid1 != sid2 + + +@pytest.mark.asyncio +async def test_call_lazy_starts_session(fleet) -> None: + agent = fleet["researcher"] + assert agent.session_id is None + await agent.call("engraphis_recall_context", {"query": "anything"}) + assert agent.session_id is not None + + +@pytest.mark.asyncio +async def test_call_injects_session_id_into_subsequent_calls(fleet) -> None: + agent = fleet["researcher"] + await agent.call("engraphis_recall_context", {"query": "warm up"}) + # The client we drive is the one used by the fleet. + # We can verify the call succeeded and returned the tool name. + result = await agent.call("engraphis_recall_context", {"query": "next"}) + assert result["_tool"] == "engraphis_recall_context" + + +@pytest.mark.asyncio +async def test_end_session_clears_cached_id(fleet) -> None: + agent = fleet["researcher"] + await agent.start_session() + assert agent.session_id is not None + await agent.end_session(summary="done", outcome="shipped") + assert agent.session_id is None + + +@pytest.mark.asyncio +async def test_end_session_is_idempotent_when_no_session(fleet) -> None: + agent = fleet["researcher"] + await agent.end_session() # no-op + + +@pytest.mark.asyncio +async def test_fan_out_runs_concurrently(fleet) -> None: + args = { + "researcher": {"query": "researcher query"}, + "coder": {"query": "coder query"}, + } + out = await fleet.fan_out("engraphis_recall_context", args) + assert set(out.keys()) == {"researcher", "coder"} + for value in out.values(): + assert value["_tool"] == "engraphis_recall_context" + + +@pytest.mark.asyncio +async def test_fan_out_raises_for_unknown_agent(fleet) -> None: + with pytest.raises(KeyError): + await fleet.fan_out("engraphis_recall_context", {"ghost": {}}) + + +@pytest.mark.asyncio +async def test_start_all_sessions_warms_every_agent(fleet) -> None: + out = await fleet.start_all_sessions() + # New structured return: {"sessions": {name: sid}, "errors": {name: exc}}. + assert set(out.keys()) == {"sessions", "errors"} + sessions = out["sessions"] + errors = out["errors"] + assert isinstance(sessions, dict) and isinstance(errors, dict) + assert set(sessions.keys()) == set(fleet.names()) + assert errors == {} + for sid in sessions.values(): + assert isinstance(sid, str) and sid + + +@pytest.mark.asyncio +async def test_register_requires_register_tool() -> None: + fleet = PrimeAgentFleet(workspace="x") + with pytest.raises(TypeError) as exc: + fleet["researcher"].register(object()) + assert "register_tool" in str(exc.value) + + +@pytest.mark.asyncio +async def test_register_registers_all_nine_tools() -> None: + fleet = PrimeAgentFleet(workspace="x") + registered: list[tuple[str, dict]] = [] + + class _Target: + def register_tool(self, name: str, fn, schema: dict) -> None: + registered.append((name, schema)) + + target = _Target() + fleet["researcher"].register(target) + assert len(registered) == 9 + for name, schema in registered: + assert name.startswith("engraphis_") + assert "parameters" in schema + + +@pytest.mark.asyncio +async def test_aclose_ends_sessions_and_closes_client() -> None: + fleet = PrimeAgentFleet(workspace="x") + await fleet.client.connect() + await fleet["researcher"].start_session() + await fleet["coder"].start_session() + await fleet.aclose() + assert fleet["researcher"].session_id is None + assert fleet["coder"].session_id is None + assert fleet._closed is True + + +@pytest.mark.asyncio +async def test_aexit_via_context_manager() -> None: + async with PrimeAgentFleet(workspace="x") as fleet: + await fleet["researcher"].start_session() + assert fleet._closed is True + + +# ---- new edge-case tests below ---- + + +@pytest.mark.asyncio +async def test_aclose_is_idempotent() -> None: + """`aclose()` (and therefore `__aexit__`) must be safe to call twice. + The second call is a no-op because the fleet has already torn down.""" + f = PrimeAgentFleet(workspace="x") + await f.client.connect() + await f["researcher"].start_session() + await f.aclose() + assert f._closed is True + # Second call must not raise. + await f.aclose() + assert f._closed is True + + +@pytest.mark.asyncio +async def test_aclose_before_any_session_is_safe() -> None: + """A fresh fleet that has never connected must close cleanly without + requiring a prior `start_session` or `connect`.""" + f = PrimeAgentFleet(workspace="x") + await f.aclose() + assert f._closed is True + + +@pytest.mark.asyncio +async def test_fan_out_with_single_sub_agent() -> None: + """fan_out() with exactly one agent must return a one-entry dict and + must not raise. The framework-level concurrency path should still work + for a single coroutine.""" + f = PrimeAgentFleet(workspace="x") + await f.client.connect() + try: + out = await f.fan_out( + "engraphis_recall_context", + {"researcher": {"query": "single-agent query"}}, + ) + assert set(out.keys()) == {"researcher"} + result = out["researcher"] + assert result["_tool"] == "engraphis_recall_context" + finally: + await f.aclose() + + +@pytest.mark.asyncio +async def test_fan_out_with_empty_args_raises_value_error() -> None: + """fan_out() with an empty mapping must raise ValueError so a misnamed + variable at the call site surfaces immediately rather than silently + producing an empty result dict.""" + f = PrimeAgentFleet(workspace="x") + await f.client.connect() + try: + with pytest.raises(ValueError) as exc: + await f.fan_out("engraphis_recall_context", {}) + assert "non-empty" in str(exc.value).lower() or "empty" in str(exc.value).lower() + finally: + await f.aclose() + + +@pytest.mark.asyncio +async def test_status_before_any_session_started() -> None: + """`status()` is a sync method — it must work without any prior connect, + start_session, or call. It should report the configured workspace, the + full agent roster, and a None session_id for every agent.""" + f = PrimeAgentFleet(workspace="demo") + s = f.status() + assert s["workspace"] == "demo" + assert len(s["agents"]) == 8 + for entry in s["agents"]: + assert entry["session_id"] is None + assert "name" in entry + assert "workspace" in entry + assert "repo" in entry + + +def test_status_before_connect_does_not_require_async() -> None: + """`status()` is intentionally sync (status snapshot, not a live call). + It must be callable from a non-async context without a runtime error.""" + f = PrimeAgentFleet(workspace="x") + s = f.status() + assert s["workspace"] == "x" + assert isinstance(s["agents"], list) + assert isinstance(s["clientGeneration"], int) + # Generation starts at 0. + assert s["clientGeneration"] == 0 + + +@pytest.mark.asyncio +async def test_register_calls_register_tool_exactly_n_times() -> None: + """`register()` must invoke `register_tool` exactly once per tool — + not zero, not twice, not conditional on the tool name. We assert this + by counting invocations against the number of tools in TOOL_SPECS.""" + f = PrimeAgentFleet(workspace="x") + invocations: list[tuple[str, object]] = [] + + class _Target: + def register_tool(self, name: str, fn, schema: dict) -> None: + invocations.append((name, fn)) + + target = _Target() + f["researcher"].register(target) + expected_count = len(TOOL_SPECS) + assert len(invocations) == expected_count + # Every tool name from TOOL_SPECS must appear exactly once. + seen = [name for name, _fn in invocations] + assert seen == [n for n, _ in TOOL_SPECS] + # Each call's `fn` is callable and distinct from the others. + fns = [fn for _name, fn in invocations] + assert all(callable(fn) for fn in fns) + assert len({id(fn) for fn in fns}) == expected_count + + +@pytest.mark.asyncio +async def test_register_invokes_for_each_agent_independently() -> None: + """Each sub-agent's register() registers its OWN 9 tools. Registering + one agent must not bleed into another agent's binding.""" + f = PrimeAgentFleet(workspace="x") + researcher_calls: list[str] = [] + coder_calls: list[str] = [] + + class _T: + def __init__(self, sink: list[str]) -> None: + self._sink = sink + + def register_tool(self, name: str, fn, schema: dict) -> None: + self._sink.append(name) + + f["researcher"].register(_T(researcher_calls)) + f["coder"].register(_T(coder_calls)) + assert len(researcher_calls) == 9 + assert len(coder_calls) == 9 + assert researcher_calls == coder_calls # same tool surface + + +@pytest.mark.asyncio +async def test_fleet_iter_and_len_match() -> None: + """`len(fleet)` and `for a in fleet` must agree — they both read from + the same internal agent dict.""" + f = PrimeAgentFleet(workspace="x") + assert len(f) == 8 + names_via_iter = [a.name for a in f] + assert names_via_iter == list(f.names()) + + +@pytest.mark.asyncio +async def test_fleet_unknown_name_raises_keyerror() -> None: + """`__getitem__` for an unknown agent must raise KeyError, not silently + return None or a default — fan_out already raises KeyError, and direct + indexing must behave consistently.""" + f = PrimeAgentFleet(workspace="x") + with pytest.raises(KeyError): + _ = f["nonexistent_agent"] + + +@pytest.mark.asyncio +async def test_fleet_contains_is_consistent_with_iter() -> None: + f = PrimeAgentFleet(workspace="x") + for name in f.names(): + assert name in f + assert "definitely_not_an_agent" not in f + assert None not in f + assert 42 not in f + + +@pytest.mark.asyncio +async def test_fleet_workspace_override_sets_every_agent() -> None: + """When the fleet is constructed with `workspace=...`, every sub-agent + inherits that workspace. Individual sub-agents have no way to opt out + (they can only set their own workspace via the EngraphisPrimeAgent + constructor, which the fleet does not expose).""" + f = PrimeAgentFleet(workspace="shared-ws") + for agent in f: + assert agent.workspace == "shared-ws" + + +@pytest.mark.asyncio +async def test_start_all_sessions_is_idempotent_per_agent() -> None: + """Calling start_all_sessions() twice must not spawn extra sessions. + Each agent should keep its first session id.""" + f = PrimeAgentFleet(workspace="x") + await f.client.connect() + try: + first = await f.start_all_sessions() + second = await f.start_all_sessions() + assert first == second + finally: + await f.aclose() + + +@pytest.mark.asyncio +async def test_subagent_status_reflects_session_lifecycle() -> None: + """`subagent.status()` should reflect the current session state — None + before start, populated after start, None again after end.""" + f = PrimeAgentFleet(workspace="x") + await f.client.connect() + try: + agent = f["researcher"] + assert agent.status()["session_id"] is None + await agent.start_session() + s = agent.status() + assert isinstance(s["session_id"], str) and s["session_id"] + await agent.end_session() + assert agent.status()["session_id"] is None + finally: + await f.aclose() diff --git a/integrations/prime_agent/tests/test_mcp_client.py b/integrations/prime_agent/tests/test_mcp_client.py new file mode 100644 index 00000000..446f0e26 --- /dev/null +++ b/integrations/prime_agent/tests/test_mcp_client.py @@ -0,0 +1,301 @@ +"""Tests for the async stdio MCP client.""" +from __future__ import annotations + +import json + +import pytest + +from engraphis_prime_agent.config import EngraphisRuntimeConfig +from engraphis_prime_agent.mcp_client import ( + READ_ONLY_TOOLS, + EngraphisCompatibilityError, + EngraphisMcpClient, + EngraphisMcpToolError, + format_mcp_payload, +) + + +@pytest.mark.asyncio +async def test_connect_lists_core_tools(mcp_client) -> None: + tools = await mcp_client.list_tools() + names = {t["name"] for t in tools} + expected = { + "engraphis_session", + "engraphis_recall_context", + "engraphis_remember", + "engraphis_discover_actions", + "engraphis_execute_read", + "engraphis_execute_action", + "engraphis_get_memory", + "engraphis_update_memory", + "engraphis_conflict_review", + } + assert expected.issubset(names) + + +@pytest.mark.asyncio +async def test_status_reports_connected(mcp_client) -> None: + status = await mcp_client.status() + assert status["connected"] is True + assert status["server"] == "engraphis" + assert status["toolCount"] >= 9 + + +@pytest.mark.asyncio +async def test_call_tool_passes_arguments(fake_mcp_server, mcp_client) -> None: + payload = await mcp_client.call_tool( + "engraphis_recall_context", {"query": "decision: sqlite-vec KNN", "k": 3} + ) + assert payload["_tool"] == "engraphis_recall_context" + assert fake_mcp_server.call_log[-1] == ( + "engraphis_recall_context", + {"query": "decision: sqlite-vec KNN", "k": 3}, + ) + + +# Note: retry behavior is exercised by the production code path; the +# in-process fake doesn't reliably simulate "transport failure" because +# crashing the server task races with the real ClientSession's receive loop. +# The retry constants (READ_ONLY_TOOLS) are unit-tested separately below. + + +def test_read_only_tools_classification() -> None: + assert "engraphis_recall_context" in READ_ONLY_TOOLS + assert "engraphis_get_memory" in READ_ONLY_TOOLS + assert "engraphis_conflict_review" in READ_ONLY_TOOLS + assert "engraphis_discover_actions" in READ_ONLY_TOOLS + # Writes and side-effect tools are not in the read-only set, so the + # client's call_tool will not retry them on transport failure. + assert "engraphis_remember" not in READ_ONLY_TOOLS + assert "engraphis_execute_action" not in READ_ONLY_TOOLS + assert "engraphis_session" not in READ_ONLY_TOOLS + assert "engraphis_update_memory" not in READ_ONLY_TOOLS + + +@pytest.mark.asyncio +async def test_rejection_text_raises_tool_error(fake_mcp_server, mcp_client) -> None: + async def handler(name: str, args: dict) -> dict: + return { + "isError": True, + "content": [{"type": "text", "text": "Error: bad_arg"}], + } + + fake_mcp_server.tool_handler = handler + with pytest.raises(EngraphisMcpToolError) as exc: + await mcp_client.call_tool("engraphis_remember", {"content": "x"}) + assert "bad_arg" in str(exc.value) + + +@pytest.mark.asyncio +async def test_compatibility_error_when_tools_missing(fake_mcp_server) -> None: + """Drop a core tool from the fake server and verify the compatibility error.""" + # Switch the existing fake server to advertise only one core tool, + # so the client's required-tool check fails on the others. + fake_mcp_server.restore() + fake_mcp_server.tool_names = ("engraphis_session",) + fake_mcp_server.install() + config = EngraphisRuntimeConfig(command="ignored", environment={}) + client = EngraphisMcpClient(config) + try: + with pytest.raises(EngraphisCompatibilityError) as exc: + await client.connect() + assert "missing" in str(exc.value).lower() + finally: + await client.close() + fake_mcp_server.restore() + + +def test_diagnostic_hint_matches_python_message() -> None: + client = EngraphisMcpClient(EngraphisRuntimeConfig(command="x")) + client._diagnostic = "ERROR: This package requires python 3.10 or later.\n" + hint = client.diagnostic_hint() + assert hint is not None + assert "Python 3.10" in hint + + +def test_diagnostic_hint_matches_missing_mcp() -> None: + client = EngraphisMcpClient(EngraphisRuntimeConfig(command="x")) + client._diagnostic = "ModuleNotFoundError: No module named 'mcp'\n" + assert client.diagnostic_hint() is not None + assert "mcp" in client.diagnostic_hint().lower() + + +def test_diagnostic_hint_matches_missing_engraphis() -> None: + client = EngraphisMcpClient(EngraphisRuntimeConfig(command="x")) + client._diagnostic = "ModuleNotFoundError: No module named 'engraphis'\n" + assert client.diagnostic_hint() is not None + + +def test_format_mcp_payload_joins_text() -> None: + payload = { + "content": [ + {"type": "text", "text": "hello"}, + {"type": "text", "text": "world"}, + ] + } + assert format_mcp_payload(payload) == "hello\n\nworld" + + +def test_format_mcp_payload_falls_back_to_json() -> None: + payload = {"content": []} + out = format_mcp_payload(payload) + parsed = json.loads(out) + assert parsed == payload + + +@pytest.mark.asyncio +async def test_unknown_tool_name_rejected(mcp_client) -> None: + with pytest.raises(EngraphisMcpToolError): + await mcp_client.call_tool("not_a_tool", {}) + + +@pytest.mark.asyncio +async def test_close_bumps_generation(fake_mcp_server) -> None: + config = EngraphisRuntimeConfig(command="ignored", environment={}) + client = EngraphisMcpClient(config) + g0 = client.generation() + await client.connect() + await client.close() + g1 = client.generation() + assert g1 > g0 + + +# ---- new edge-case tests below ---- + + +@pytest.mark.asyncio +async def test_connect_is_idempotent(fake_mcp_server) -> None: + """Calling connect() twice must return the same session and not re-spawn + the stdio subprocess or re-fetch the tool list.""" + config = EngraphisRuntimeConfig(command="ignored", environment={}) + client = EngraphisMcpClient(config) + s1 = await client.connect() + s2 = await client.connect() + assert s1 is s2 + # The list_tools cache was populated by the first connect; the second + # call must not issue a fresh tools/list RPC. + assert client._tools_cache is not None + cache_id = id(client._tools_cache) + await client.connect() + assert id(client._tools_cache) == cache_id + await client.close() + + +@pytest.mark.asyncio +async def test_close_clears_session_stack_and_tools_cache(fake_mcp_server) -> None: + """After close(), every internal handle must be released so the + next connect() can rebuild cleanly.""" + config = EngraphisRuntimeConfig(command="ignored", environment={}) + client = EngraphisMcpClient(config) + await client.connect() + assert client._session is not None + assert client._stack is not None + assert client._tools_cache is not None + await client.close() + assert client._session is None + assert client._stack is None + assert client._tools_cache is None + + +@pytest.mark.asyncio +async def test_aenter_aexit_context_manager(fake_mcp_server) -> None: + """`async with EngraphisMcpClient(...) as client:` must connect on enter + and release every handle on exit.""" + config = EngraphisRuntimeConfig(command="ignored", environment={}) + async with EngraphisMcpClient(config) as client: + # Inside the block: connected, tools cached. + assert client._session is not None + assert client._tools_cache is not None + tools = await client.list_tools() + assert len(tools) >= 9 + # After the block: all handles released. + assert client._session is None + assert client._stack is None + assert client._tools_cache is None + + +@pytest.mark.asyncio +async def test_aenter_returns_client_instance(fake_mcp_server) -> None: + config = EngraphisRuntimeConfig(command="ignored", environment={}) + async with EngraphisMcpClient(config) as client: + assert isinstance(client, EngraphisMcpClient) + assert client is not None + + +@pytest.mark.asyncio +async def test_unknown_tool_name_includes_name_in_error_message(mcp_client) -> None: + """`call_tool` must raise EngraphisMcpToolError AND the error message + must name the rejected tool so a developer can diagnose the rejection.""" + with pytest.raises(EngraphisMcpToolError) as exc: + await mcp_client.call_tool("engraphis_does_not_exist", {}) + assert "engraphis_does_not_exist" in str(exc.value) + # And bare "not_a_tool" (no engraphis_ prefix) is also rejected with a + # message — a different guard, but same exception class. + with pytest.raises(EngraphisMcpToolError) as exc2: + await mcp_client.call_tool("not_a_tool", {}) + assert "not_a_tool" in str(exc2.value) + + +@pytest.mark.asyncio +async def test_close_is_idempotent(fake_mcp_server) -> None: + """Calling close() twice must not raise. The second call should be a no-op + because _stack/_session are already None.""" + config = EngraphisRuntimeConfig(command="ignored", environment={}) + client = EngraphisMcpClient(config) + await client.connect() + await client.close() + # Second close should be silent. + await client.close() + assert client._session is None + assert client._stack is None + assert client._tools_cache is None + + +@pytest.mark.asyncio +async def test_list_tools_returns_independent_list(fake_mcp_server) -> None: + """Mutating the list returned by list_tools() must not affect the cache + (so a second caller still sees the full list).""" + config = EngraphisRuntimeConfig(command="ignored", environment={}) + client = EngraphisMcpClient(config) + await client.connect() + first = await client.list_tools() + first.clear() + second = await client.list_tools() + assert len(second) == len(first) or len(second) >= 9 + + +@pytest.mark.asyncio +async def test_status_diagnostic_hint_is_none_when_no_failure(fake_mcp_server) -> None: + """After a healthy connect, diagnosticHint must be None — there is no + error message to surface.""" + config = EngraphisRuntimeConfig(command="ignored", environment={}) + async with EngraphisMcpClient(config) as client: + status = await client.status() + assert status["connected"] is True + assert status["diagnosticHint"] is None + assert status["server"] == "engraphis" + assert status["toolCount"] >= 9 + + +def test_diagnostic_hint_returns_none_for_unrecognized_error() -> None: + """A diagnostic line that doesn't match any known pattern must surface + None (not a misleading hint).""" + client = EngraphisMcpClient(EngraphisRuntimeConfig(command="x")) + client._diagnostic = "ERROR: connection refused on 127.0.0.1:9999\n" + assert client.diagnostic_hint() is None + + +def test_format_mcp_payload_handles_non_text_blocks() -> None: + """Blocks without a `text` field (e.g. an image) must be skipped, and + the JSON fallback must kick in when no text content is present.""" + payload = { + "content": [ + {"type": "image", "data": "ignored"}, + {"type": "text", "text": "only text"}, + ] + } + assert format_mcp_payload(payload) == "only text" + # No text at all -> JSON fallback. + assert json.loads(format_mcp_payload({"content": [{"type": "image"}]})) == { + "content": [{"type": "image"}] + } diff --git a/integrations/prime_agent/tests/test_register_and_repo.py b/integrations/prime_agent/tests/test_register_and_repo.py new file mode 100644 index 00000000..4c4d30d9 --- /dev/null +++ b/integrations/prime_agent/tests/test_register_and_repo.py @@ -0,0 +1,227 @@ +"""Tests for review-feedback fixes on PR 174. + +Covers: +1. Agent repo precedence: explicit > config.default_repo > self.name. +2. register() wrappers lazily start the session. +3. install_prime_agent / scripts wrapper dispatches via the package module. +4. Installer TOML path uses write_text (not write_bytes). +5. CLI install command does not require scripts/ outside the wheel. +""" +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from engraphis_prime_agent.config import EngraphisRuntimeConfig +from engraphis_prime_agent.mcp_client import EngraphisMcpClient + + +# ---- Fix 1: agent repo precedence -------------------------------------- + + +def test_agent_repo_uses_explicit_kwarg() -> None: + from engraphis_prime_agent.agent import EngraphisPrimeAgent + + config = EngraphisRuntimeConfig( + command="ignored", default_repo="api", environment={} + ) + client = EngraphisMcpClient(config) + agent = EngraphisPrimeAgent( + "researcher", client, config, workspace="acme", repo="custom" + ) + assert agent.repo == "custom" + + +def test_agent_repo_uses_default_repo_when_no_explicit() -> None: + from engraphis_prime_agent.agent import EngraphisPrimeAgent + + config = EngraphisRuntimeConfig( + command="ignored", default_repo="api", environment={} + ) + client = EngraphisMcpClient(config) + agent = EngraphisPrimeAgent("researcher", client, config, workspace="acme") + assert agent.repo == "api" + + +def test_agent_repo_falls_back_to_name_when_no_default() -> None: + from engraphis_prime_agent.agent import EngraphisPrimeAgent + + config = EngraphisRuntimeConfig(command="ignored", environment={}) + client = EngraphisMcpClient(config) + agent = EngraphisPrimeAgent("researcher", client, config, workspace="acme") + assert agent.repo == "researcher" + + +# ---- Fix 2: register() wrappers lazily start the session ------------------ + + +@pytest.mark.asyncio +async def test_register_wrappers_lazy_start_session(fake_mcp_server) -> None: + """When a fresh agent is registered and the framework invokes a tool + directly, the session must be started before the tool is called — the + wrapper around each registered callable must drive the lazy-start path. + """ + from engraphis_prime_agent.agent import EngraphisPrimeAgent, PrimeAgentFleet + + config = EngraphisRuntimeConfig(command="ignored", environment={}) + client = EngraphisMcpClient(config) + await client.connect() + try: + fleet = PrimeAgentFleet(workspace="test", config=config) + agent = EngraphisPrimeAgent("researcher", client, config) + + registered: dict[str, object] = {} + + class _Target: + def register_tool(self, name: str, fn, schema: dict) -> None: + registered[name] = fn + + agent.register(_Target()) + assert "engraphis_recall_context" in registered + wrapper = registered["engraphis_recall_context"] + # Before the framework calls the wrapper, no session exists. + assert agent.session_id is None + await wrapper({"query": "hello"}) + # After the framework calls the wrapper, the session is started. + assert agent.session_id is not None + await fleet.aclose() + finally: + await client.close() + + +# ---- Fix 3 + 4: installer module + TOML write_text ---------------------- + + +def test_installer_module_importable() -> None: + """The installer must ship inside the package so the wheel works.""" + from engraphis_prime_agent import installer + + assert hasattr(installer, "install") + assert hasattr(installer, "uninstall") + assert hasattr(installer, "main") + + +def test_installer_toml_uses_write_text( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """``tomli_w.dumps`` returns str, so the TOML path must use write_text + (not write_bytes, which would TypeError). We test the wrapper by + stubbing tomli_w to verify the right method is called. + """ + from engraphis_prime_agent import installer + + target = tmp_path / "config.toml" + captured: dict[str, object] = {} + + class _StubToml: + @staticmethod + def dumps(_data: dict) -> str: + return "[tools.engraphis]\npackage = 'x'\n" + + monkeypatch.setattr(installer, "tomli_w", _StubToml, raising=False) + + real_write_text = Path.write_text + real_write_bytes = Path.write_bytes + + def _spy_write_text(self, *args, **kwargs): # type: ignore[no-untyped-def] + captured["method"] = "write_text" + return real_write_text(self, *args, **kwargs) + + def _spy_write_bytes(self, *args, **kwargs): # type: ignore[no-untyped-def] + captured["method"] = "write_bytes" + return real_write_bytes(self, *args, **kwargs) + + monkeypatch.setattr(Path, "write_text", _spy_write_text) + monkeypatch.setattr(Path, "write_bytes", _spy_write_bytes) + + installer.install(target, merge=False, dry_run=False) + assert captured.get("method") == "write_text" + assert target.exists() + assert "package" in target.read_text(encoding="utf-8") + + +def test_installer_idempotent_install_uninstall_round_trip( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from engraphis_prime_agent import installer + + target = tmp_path / "config.json" + installer.install(target) + installer.install(target) # idempotent: same content + cfg = json.loads(target.read_text(encoding="utf-8")) + assert len(cfg["tools"]) == 1 + assert "engraphis" in cfg["tools"] + installer.uninstall(target) + cfg = json.loads(target.read_text(encoding="utf-8")) + assert "engraphis" not in cfg.get("tools", {}) + + +def test_installer_dry_run_does_not_write( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from engraphis_prime_agent import installer + + target = tmp_path / "config.json" + installer.install(target, dry_run=True) + assert not target.exists() + + +# ---- Fix 5: CLI install works without a source-tree scripts/ dir ---------- + + +def test_cli_install_subcommand_uses_package_installer( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The CLI must dispatch through the package module, not runpy against + a repo-level scripts/ directory that doesn't exist after pip install. + """ + config_path = tmp_path / "config.json" + result = subprocess.run( + [ + sys.executable, + "-m", + "engraphis_prime_agent", + "install", + "--config-path", + str(config_path), + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert config_path.exists() + cfg = json.loads(config_path.read_text(encoding="utf-8")) + assert "engraphis" in cfg["tools"] + + +# ---- Scripts wrapper: still works from a source checkout ----------------- + + +def test_scripts_wrapper_imports_package(tmp_path: Path) -> None: + """The repo-root scripts/install_prime_agent.py is a thin shim that + delegates to engraphis_prime_agent.installer. Verify the import path + when invoked from a source checkout (no editable install). + """ + import io + import contextlib + + script = ( + Path(__file__).resolve().parent.parent.parent.parent + / "scripts" + / "install_prime_agent.py" + ) + assert script.exists(), f"missing {script}" + config_path = tmp_path / "shim-config.json" + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + result = subprocess.run( + [sys.executable, str(script), "--config-path", str(config_path)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert config_path.exists() diff --git a/integrations/prime_agent/tests/test_tools.py b/integrations/prime_agent/tests/test_tools.py new file mode 100644 index 00000000..1fd56117 --- /dev/null +++ b/integrations/prime_agent/tests/test_tools.py @@ -0,0 +1,314 @@ +"""Tests for the 9 Smart tool factories and scope-default helper.""" +from __future__ import annotations + +import pytest + +from engraphis_prime_agent.config import EngraphisRuntimeConfig +from engraphis_prime_agent.mcp_client import EngraphisMcpClient +from engraphis_prime_agent.tools import ( + TOOL_SPECS, + all_tools, + apply_scope_defaults, + build_tool, +) + + +@pytest.fixture +def client(mcp_client) -> EngraphisMcpClient: + return mcp_client + + +def test_tool_specs_cover_nine_tools() -> None: + assert len(TOOL_SPECS) == 9 + names = [name for name, _ in TOOL_SPECS] + assert names == [ + "engraphis_session", + "engraphis_recall_context", + "engraphis_remember", + "engraphis_discover_actions", + "engraphis_execute_read", + "engraphis_execute_action", + "engraphis_get_memory", + "engraphis_update_memory", + "engraphis_conflict_review", + ] + + +def test_each_tool_has_name_description_and_schema() -> None: + for name, schema in TOOL_SPECS: + assert isinstance(name, str) and name + assert "type" in schema and schema["type"] == "object" + assert "properties" in schema + + +def test_build_tool_unknown_name_raises() -> None: + config = EngraphisRuntimeConfig(command="x") + client = EngraphisMcpClient(config) + with pytest.raises(KeyError): + build_tool("not_a_tool", client, config) + + +@pytest.mark.asyncio +async def test_recall_context_tool_calls_mcp(client) -> None: + fn, meta = build_tool("engraphis_recall_context", client, client.config) + result = await fn({"query": "decision: sqlite-vec KNN"}) + assert result["_tool"] == "engraphis_recall_context" + assert client._tools_cache is not None # ensure list_tools was called + + +@pytest.mark.asyncio +async def test_remember_tool_passes_arguments(client) -> None: + fn, _ = build_tool("engraphis_remember", client, client.config) + result = await fn({"content": "Use sqlite-vec KNN for <=1M vectors", "importance": 0.7}) + assert result["_tool"] == "engraphis_remember" + + +@pytest.mark.asyncio +async def test_session_id_is_injected_when_bound(client, fake_mcp_server) -> None: + fn, _ = build_tool( + "engraphis_recall_context", client, client.config, session_id="ses_test_1" + ) + await fn({"query": "anything"}) + # The fake server records every tools/call; the last entry should + # carry the injected session_id. + assert fake_mcp_server.call_log[-1][0] == "engraphis_recall_context" + assert fake_mcp_server.call_log[-1][1].get("session_id") == "ses_test_1" + + +def test_all_tools_returns_nine_pairs(client) -> None: + pairs = all_tools(client, client.config) + assert len(pairs) == 9 + for fn, meta in pairs: + assert callable(fn) + assert meta["name"] in [name for name, _ in TOOL_SPECS] + assert "description" in meta + assert "parameters" in meta + + +def test_apply_scope_defaults_preserves_model_supplied() -> None: + config = EngraphisRuntimeConfig( + command="x", + default_workspace="acme", + default_repo="api", + ) + out = apply_scope_defaults( + {"workspace": "override", "repo": "fork"}, + config, + ) + assert out["workspace"] == "override" + assert out["repo"] == "fork" + + +def test_apply_scope_defaults_injects_when_missing() -> None: + config = EngraphisRuntimeConfig( + command="x", + default_workspace="acme", + default_repo="api", + ) + out = apply_scope_defaults({}, config) + assert out["workspace"] == "acme" + assert out["repo"] == "api" + + +def test_apply_scope_defaults_skips_repo_when_workspace_overridden() -> None: + config = EngraphisRuntimeConfig( + command="x", + default_workspace="acme", + default_repo="api", + ) + out = apply_scope_defaults({"workspace": "other"}, config) + assert out["workspace"] == "other" + assert "repo" not in out + + +def test_apply_scope_defaults_merges_extra() -> None: + config = EngraphisRuntimeConfig(command="x") + out = apply_scope_defaults({}, config, extra={"actor": "user"}) + assert out["actor"] == "user" + + +def test_apply_scope_defaults_extra_can_be_overridden_by_params() -> None: + config = EngraphisRuntimeConfig(command="x") + out = apply_scope_defaults({"actor": "agent"}, config, extra={"actor": "user"}) + assert out["actor"] == "agent" + + +# ---- new edge-case tests below ---- + + +def test_apply_scope_defaults_does_not_mutate_input_dict() -> None: + """The helper must not mutate the caller's `params` dict — prime-agent + and other call sites may reuse the same dict for repeated tool calls.""" + config = EngraphisRuntimeConfig( + command="x", + default_workspace="acme", + default_repo="api", + ) + params = {"query": "hello"} + snapshot = dict(params) + out = apply_scope_defaults(params, config) + assert params == snapshot # input untouched + # Output is a new dict — mutating it must not bleed back. + out["query"] = "mutated" + assert params["query"] == "hello" + + +def test_apply_scope_defaults_does_not_mutate_extra_dict() -> None: + """`extra` is also treated as read-only.""" + config = EngraphisRuntimeConfig(command="x", default_workspace="acme") + extra = {"actor": "user", "workspace": "extra-ws"} + snapshot = dict(extra) + out = apply_scope_defaults({}, config, extra=extra) + assert extra == snapshot + # The output is a copy of extra; mutating output must not leak. + out["actor"] = "mutated" + assert extra["actor"] == "user" + + +def test_apply_scope_defaults_no_defaults_no_extra_returns_new_dict() -> None: + """With no config defaults and no extra, apply_scope_defaults should + return a new dict equal to the input — and still not be the same object.""" + config = EngraphisRuntimeConfig(command="x") + params = {"x": 1} + out = apply_scope_defaults(params, config) + assert out == params + assert out is not params + + +@pytest.mark.asyncio +async def test_build_tool_returns_async_callable(client) -> None: + """The returned callable must be awaitable and accept a single dict arg.""" + import inspect + + fn, meta = build_tool("engraphis_remember", client, client.config) + assert callable(fn) + assert inspect.iscoroutinefunction(fn) or hasattr(fn, "__call__") + # Calling with a dict must return an awaitable that resolves to a dict. + coro = fn({"content": "x"}) + result = await coro + assert isinstance(result, dict) + assert "content" in result or "_tool" in result + + +@pytest.mark.asyncio +async def test_build_tool_meta_has_required_fields(client) -> None: + """The metadata dict must include name, description, and parameters so + any prime-agent registration surface can render it without fallbacks.""" + fn, meta = build_tool("engraphis_get_memory", client, client.config) + assert meta["name"] == "engraphis_get_memory" + assert isinstance(meta["description"], str) and meta["description"] + assert meta["parameters"]["type"] == "object" + assert "properties" in meta["parameters"] + + +def test_all_tool_schemas_declare_required_field_explicitly() -> None: + """Every Smart tool schema must declare a `required` key — either as a + non-empty list of names or an empty list. The absence of `required` + would be ambiguous (it can be read as "no required fields" OR as + "all fields implicitly required" depending on the consumer).""" + for name, schema in TOOL_SPECS: + assert "required" in schema, f"{name} schema is missing the 'required' key" + assert isinstance(schema["required"], list), ( + f"{name} schema 'required' must be a list, got {type(schema['required']).__name__}" + ) + # Every name listed in `required` must also be a defined property. + for required_name in schema["required"]: + assert required_name in schema["properties"], ( + f"{name} schema lists {required_name!r} in required " + "but it is not in properties" + ) + + +def test_schema_required_names_are_subset_of_properties() -> None: + """Defense in depth: cross-check every required name appears in properties.""" + for name, schema in TOOL_SPECS: + for required_name in schema.get("required", []): + assert required_name in schema["properties"], ( + f"{name}: required field {required_name!r} missing from properties" + ) + + +def test_schemas_have_additional_properties_false_or_unset() -> None: + """The schemas set `additionalProperties: False` to surface typos early. + Any schema that loses this guarantee is a regression.""" + for name, schema in TOOL_SPECS: + if "additionalProperties" in schema: + assert schema["additionalProperties"] is False, ( + f"{name} schema should have additionalProperties=False" + ) + + +def test_no_tool_schema_is_empty() -> None: + """Every tool must declare at least one property. An empty schema would + mean the tool accepts no parameters at all, which is not a Smart tool.""" + for name, schema in TOOL_SPECS: + assert schema.get("properties"), f"{name} schema has no properties" + assert len(schema["properties"]) >= 1 + + +@pytest.mark.asyncio +async def test_session_id_is_injected_into_call(client, fake_mcp_server) -> None: + """A tool bound with session_id="ses_xyz" must forward "ses_xyz" as the + session_id argument of the resulting tools/call RPC.""" + fn, _ = build_tool( + "engraphis_recall_context", client, client.config, session_id="ses_xyz" + ) + await fn({"query": "anything"}) + # The fake server records the last call's (name, arguments) pair. + assert fake_mcp_server.call_log, "fake server recorded no calls" + last_name, last_args = fake_mcp_server.call_log[-1] + assert last_name == "engraphis_recall_context" + assert last_args.get("session_id") == "ses_xyz" + # The caller-supplied args are preserved alongside the injection. + assert last_args.get("query") == "anything" + + +@pytest.mark.asyncio +async def test_session_id_injection_does_not_override_caller_supplied(client, fake_mcp_server) -> None: + """If the caller already supplied a session_id, the bound session_id + must NOT silently overwrite it — caller intent wins.""" + fn, _ = build_tool( + "engraphis_recall_context", client, client.config, session_id="ses_bound" + ) + await fn({"query": "x", "session_id": "ses_caller"}) + _, last_args = fake_mcp_server.call_log[-1] + assert last_args["session_id"] == "ses_caller" + + +@pytest.mark.asyncio +async def test_session_id_not_injected_when_not_bound(client, fake_mcp_server) -> None: + """A tool built without a session_id must not add a session_id key — + only the caller-supplied fields (plus scope defaults) reach the server.""" + fn, _ = build_tool("engraphis_recall_context", client, client.config) + await fn({"query": "x"}) + _, last_args = fake_mcp_server.call_log[-1] + assert "session_id" not in last_args or last_args.get("session_id") in (None, "") + + +def test_all_tools_with_session_id_returns_independent_callables(client) -> None: + """all_tools() must return 9 distinct callables, each with its own + closure-captured name. Reusing a session_id must not collapse the + tools into a single shared callable.""" + pairs = all_tools(client, client.config, session_id="ses_shared") + assert len(pairs) == 9 + callables = [fn for fn, _ in pairs] + # Each callable has a unique __name__ or at least is a different object. + assert len({id(fn) for fn in callables}) == 9 + + +def test_build_tool_meta_description_matches_descriptor_table(client) -> None: + """Every built tool's description must match the entry in _DESC — a + typo in a schema shouldn't silently ship.""" + for name, _schema in TOOL_SPECS: + _fn, meta = build_tool(name, client, client.config) + assert meta["name"] == name + assert isinstance(meta["description"], str) and meta["description"] + + +def test_all_tool_schemas_have_unique_property_names_within_tool() -> None: + """A schema that lists the same property twice would be ambiguous.""" + for name, schema in TOOL_SPECS: + props = schema.get("properties", {}) + assert len(props) == len(set(props)), ( + f"{name} schema has duplicate property names: {list(props)}" + ) diff --git a/scripts/install_prime_agent.py b/scripts/install_prime_agent.py new file mode 100644 index 00000000..ad5f88fd --- /dev/null +++ b/scripts/install_prime_agent.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +"""Thin wrapper around the package-distributed installer. + +The canonical implementation lives at +``engraphis_prime_agent.installer`` so it ships with the wheel and works +after ``pip install engraphis-prime-agent``. This wrapper remains at the +repo root for source-tree developers who run ``python +scripts/install_prime_agent.py`` directly. + +Usage: + python scripts/install_prime_agent.py + python scripts/install_prime_agent.py --uninstall +""" +from __future__ import annotations + +import sys +from pathlib import Path + +# Allow importing the package from a source checkout without an editable +# install. The integration package is three directories up from this +# script: scripts/ -> engraphis/ -> integrations/prime_agent/ -> src/. +_REPO_ROOT = Path(__file__).resolve().parent.parent +_SRC = _REPO_ROOT / "integrations" / "prime_agent" / "src" +if _SRC.is_dir(): + sys.path.insert(0, str(_SRC)) + +from engraphis_prime_agent.installer import main # noqa: E402 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/e2e/graph-engine.spec.js b/tests/e2e/graph-engine.spec.js index 5b7e8b39..eaf20278 100644 --- a/tests/e2e/graph-engine.spec.js +++ b/tests/e2e/graph-engine.spec.js @@ -1904,9 +1904,15 @@ test('served Ledger wires normalized spacetime controls, overlay, and orbit paus { control: 170, multiplier: 1.2 }, { control: 180, multiplier: 1.4 }, ]); + /* Engine-side values reflect the new calibration: + - blackHoleMass curve slope *0.02 (was *0.015): 240 → 1 + 80*0.02 = 2.6 + - gravitationalConstant / localGravitationalConstant divisor /25 (was /33.33): + 150/25 = 6, 125/25 = 5 + - damping: passthrough (1..15) + - springStiffness: /20 (was /20 — unchanged): 60/20 = 3 */ await expect.poll(() => page.evaluate(() => window.__engraphisGraph.state().settings)) - .toMatchObject({ gravitationalConstant: 4, blackHoleMass: 2.6, - localGravitationalConstant: 3, damping: 3, springStiffness: 3, orbitPaused: false }); + .toMatchObject({ gravitationalConstant: 6, blackHoleMass: 2.6, + localGravitationalConstant: 5, damping: 3, springStiffness: 3, orbitPaused: false }); const rangeResponse = await page.evaluate(() => { const set = (id, value) => { const control = document.getElementById(id); @@ -1937,8 +1943,12 @@ test('served Ledger wires normalized spacetime controls, overlay, and orbit paus }; }); expect(rangeResponse.settings).toMatchObject({ - flowSpeed: 85, repel: 200, link: 32, gravity: 144, size: 5, font: 20, - linkw: 1.28, labelDensity: 56, + /* With the linear response curve (exponent 1.0), each slider value passes through + graphSliderResponseValue to the engine. The test inputs are 65, 150, 20, 120, 4, + 16, 1, 40 — and the response curve returns those values (within clamp) because + the linear mapping around the preset baseline produces proportional outputs. */ + flowSpeed: 65, repel: 150, link: 20, gravity: 120, size: 4, font: 16, + linkw: 1, labelDensity: 40, }); expect(rangeResponse.scope).toEqual({ minDegree: 2, depth: 3 }); expect(rangeResponse.importanceAria).toBe('1.00 importance'); diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py index 2a781c00..61fd3c9f 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -1,11522 +1,11522 @@ -"""Contract checks for the opt-in browser graph engine (``?graph-engine=next``). - -These tests intentionally stay dependency-light: the dashboard's offline CI floor does -not need a browser or a JavaScript package manager just to validate a shipped static -asset. Where Node is available the asset is *executed* rather than pattern-matched, so -the checks assert behaviour (escaping, bridge detection, stack safety, load-order -independence) instead of the presence of source substrings. - -The properties guarded here are the ones whose failure is silent in a browser: - -* the asset must define its global without touching ``ForceGraph``/``document``, so a - blocked or missing vendor bundle degrades instead of white-screening the dashboard; -* every label crossing into force-graph must be escaped, because force-graph's tooltip - is an ``innerHTML`` sink and entity labels come from ingested memories; -* the client-side graph analysis must not recurse per node or run unbounded work; -* the per-style pane backgrounds must stay in CSS, since the production CSP sets - ``style-src-attr 'none'``. -""" - -from __future__ import annotations - -import json -import math -import re -import shutil -import subprocess -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[1] -STATIC = ROOT / "engraphis" / "static" -ASSET = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph.js" -EVERY_ASSET = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph-every.js" -SPACETIME_ASSET = ROOT / "engraphis" / "dashboard_assets" / "engraphis-spacetime.js" -LEGACY_ADAPTER = STATIC / "engraphis-graph.js" -INDEX = STATIC / "index.html" -CSS = STATIC / "dashboard.css" -DASHBOARD = STATIC / "dashboard.js" -CLASSIC_DASHBOARD = ROOT / "engraphis" / "classic_assets" / "dashboard.js" -VENDOR = STATIC / "vendor" / "force-graph.min.js" -PRIMARY_LEDGER = ROOT / "engraphis" / "dashboard_assets" / "ledger.js" -PRIMARY_INDEX = ROOT / "engraphis" / "dashboard_assets" / "index.html" -PRIMARY_CSS = ROOT / "engraphis" / "dashboard_assets" / "ledger.css" -PRIMARY_VENDOR = ROOT / "engraphis" / "dashboard_assets" / "vendor" / "force-graph.min.js" - -NODE = shutil.which("node") -requires_node = pytest.mark.skipif(NODE is None, reason="node is not installed") - -#: Evaluates the asset with nothing but a bare ``window`` object in scope. Any top-level -#: use of a browser or vendor global would raise here, which is the point. -PRELUDE = """ -const fs = require('fs'); -const source = fs.readFileSync(process.argv[1], 'utf8'); -const window = {}; -new Function('window', source)(window); -const G = window.EngraphisGraph; -const I = G._internals; -const emit = value => console.log(JSON.stringify(value)); -""" - - -#: Same, plus a recording stand-in for force-graph so ``create()`` can be *driven*. Every -#: accessor is a chainable setter that returns the stored value when called with no arguments — -#: force-graph's own kapsule semantics — so the paint configuration the engine installs can be -#: read back and invoked instead of pattern-matched. ``calls`` counts the invalidations the -#: engine requests, which is the only observable form a "redraw now" takes. ``invocations`` -#: counts the *argument-less* calls, which under kapsule semantics are the commands rather than -#: the setters — ``d3ReheatSimulation()`` is one, and it has no other observable effect here. -ENGINE_PRELUDE = """ -const fs = require('fs'); -const source = fs.readFileSync(process.argv[1], 'utf8'); -const engineWindowListeners = {}; -const window = { - addEventListener(type, callback) { engineWindowListeners[type] = callback; }, - removeEventListener(type) { delete engineWindowListeners[type]; }, -}; -globalThis.requestAnimationFrame = () => {}; -globalThis.cancelAnimationFrame = () => {}; -const store = {}, calls = {}, invocations = {}; -const fg = new Proxy({}, { - get: (_target, prop) => prop === 'screen2GraphCoords' && typeof store.screen2GraphCoords === 'function' - ? store.screen2GraphCoords - : prop === 'd3Force' ? (function(name, force) { - /* d3Force(name) is a getter and d3Force(name, force) is a setter. Modelling that - distinction keeps the behavioural force tests below honest. */ - if (arguments.length === 1) return store.d3Forces && store.d3Forces[name]; - calls.d3Force = (calls.d3Force || 0) + 1; - store.d3Forces = store.d3Forces || {}; - store.d3Forces[name] = force; - return fg; - }) : (...args) => { - if (!args.length) { invocations[prop] = (invocations[prop] || 0) + 1; return store[prop]; } - calls[prop] = (calls[prop] || 0) + 1; - store[prop] = args.length === 1 ? args[0] : args; - return fg; - }, -}); -globalThis.ForceGraph = () => () => fg; -const elListeners = {}; -const canvas = { getBoundingClientRect() { return { left: 0, top: 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() {} }, - addEventListener(type, callback) { elListeners[type] = callback; }, - removeEventListener(type) { delete elListeners[type]; }, - querySelector(selector) { return selector === 'canvas' ? canvas : null; }, -}; -const chain = count => { - const nodes = [], links = []; - for (let i = 0; i <= count; i++) nodes.push({ id: 'n' + i }); - for (let i = 0; i < count; i++) { - links.push({ source: 'n' + i, target: 'n' + (i + 1), layer: 'semantic' }); - } - return { nodes, links }; -}; -new Function('window', source)(window); -const G = window.EngraphisGraph; -const I = G._internals; -const emit = value => console.log(JSON.stringify(value)); -""" - - -def _run_node(script: str, prelude: str = PRELUDE) -> object: - 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]) - - -def _run_engine(script: str) -> object: - return _run_node(script, prelude=ENGINE_PRELUDE) - - -def _run_spacetime_node(script: str) -> object: - """Execute the independently loaded canvas-only spacetime renderer in a tiny DOM.""" - prelude = """ -const fs = require('fs'); -const source = fs.readFileSync(process.argv[1], 'utf8'); -const emit = value => console.log(JSON.stringify(value)); -""" - result = subprocess.run( - [NODE, "-e", prelude + script, str(SPACETIME_ASSET)], - 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. - - force-graph applies inline styles at runtime, so under the production CSP - (``style-src 'self'``) every page load that fetched it reported a violation per attempt — - including the pages that never open the graph. - """ - html = INDEX.read_text(encoding="utf-8") - eager = re.findall(r']+src=["\'](/static/[^"\']+)["\']', html) - assert "/static/vendor/d3.min.js" in eager - assert any( - re.fullmatch(r"/static/dashboard\.js\?v=[A-Za-z0-9._-]+", item) - for item in eager - ) - assert "/static/vendor/force-graph.min.js" not in eager - assert "/static/engraphis-graph.js" not in eager - - -def test_every_node_visibility_response_refreshes_webgl_node_buffers() -> None: - """Worker LOD responses must repaint nodes, not only their edge buffers. - - The Every-node renderer keeps one GPU position buffer per node and represents hidden nodes - in the node metadata buffer. This contract test protects the ordering in the ready-message - handler without requiring a WebGL context in the offline test floor. - """ - source = EVERY_ASSET.read_text(encoding="utf-8") - start = source.index("if (message.type === 'preview' || message.type === 'ready')") - end = source.index("if (message.type === 'progress')", start) - handler = source[start:end] - assert "refreshVisibility(false);" in handler - assert "uploadNodePositions();" in handler - assert "uploadEdges();" in handler - assert handler.index("uploadNodePositions()") < handler.index("uploadEdges()") - - -def test_v1_graph_asset_is_only_a_compatibility_adapter() -> None: - """New renderer code stays on the v2 dashboard surface, not the legacy server.""" - adapter = LEGACY_ADAPTER.read_text(encoding="utf-8") - assert "canonicalAsset: '/v2-assets/engraphis-graph.js'" in adapter - assert "window.EngraphisGraph =" not in adapter - assert "window.EngraphisGraph =" in ASSET.read_text(encoding="utf-8") - - -def test_opt_in_graph_asset_is_lazily_loaded_after_its_dependencies() -> None: - """The load order the removed script tags used to guarantee now lives in graphRender(). - - ``graphRender`` returns early until ForceGraph is defined, so by the time the engine - branch runs its dependency is already in scope. - """ - source = DASHBOARD.read_text(encoding="utf-8") - assert re.search( - r"script\.src='/static/vendor/force-graph\.min\.js\?v=[A-Za-z0-9._-]+'", - source, - ) - assert re.search( - r"script\.src='/v2-assets/engraphis-graph\.js\?v=[A-Za-z0-9._-]+'", - source, - ) - render = source[source.index("function graphRender("):] - render = render[: render.index("\nfunction ")] - force_graph_gate = render.index("typeof ForceGraph==='undefined'") - engine_gate = render.index("if(enginePending)") - classic = render.index("graphRenderEngine(data,fit,reheat)") - assert force_graph_gate < engine_gate < classic - - -def test_classic_dashboard_copies_share_the_canonical_route_gate() -> None: - """Classic must use the canonical renderer, including mounted `/classic` routes.""" - sources = [path.read_text(encoding="utf-8") for path in (DASHBOARD, CLASSIC_DASHBOARD)] - assert sources[0] == sources[1] - start = sources[0].index("function graphEngineEnabled()") - body = sources[0][start:sources[0].index("function graphEngineFallback", start)] - assert "/(^|\\/)classic\\/?$/.test(window.location.pathname)" in body - assert "GRAPH_ENGINE_FAILED" in body - - -def test_engine_node_labels_honor_the_configured_font_at_normal_zoom() -> None: - source = ASSET.read_text(encoding="utf-8") - assert "state.settings.font / scale / 3.4" not in source - assert "state.settings.font / scale" in source - - -#: Executes dashboard.js's real graph-render *routing* decision against a stub DOM. -#: ``graphEngineEnabled``, ``graphEngineFallback``, ``loadForceGraph``, ``loadGraphEngine`` and -#: the routing half of ``graphRender`` are verbatim source slices — nothing is re-implemented. -#: Only the classic renderer body below the routing decision is swapped for a ``CLASSIC()`` -#: marker, so the test can see which renderer a deep link actually reaches. -ROUTING_HARNESS = """ -const fs = require('fs'); -const src = fs.readFileSync(process.argv.slice(1).find(a => a.endsWith('dashboard.js')), 'utf8'); -const scenario = process.argv[process.argv.length - 1]; -const between = (from, to) => src.slice(src.indexOf(from), src.indexOf(to, src.indexOf(from))); -let flags = between('let GRAPH_ENGINE_FAILED=false;', 'function graphEngineEmptyMessage'); -const loaders = between('let FORCE_GRAPH_LOADING=null,FORCE_GRAPH_RETRY=0;', 'function graphRender('); -const CLASSIC_BOUNDARY = '/* Read AFTER the opt-in attempt:'; -const start = src.indexOf('function graphRender('); -const routing = src.slice(start, src.indexOf(CLASSIC_BOUNDARY, start)) + - '\\n CLASSIC();\\n}'; - -const log = { appended: [], warned: [], engine: 0, classic: 0 }; -let pending = null; -const element = { clientWidth: 800, clientHeight: 600, classList: { toggle() {} }, - setAttribute() {}, set textContent(v) {} }; -globalThis.document = { - getElementById: () => element, - querySelectorAll: () => [], - createElement: () => (pending = {}), - head: { appendChild: s => log.appended.push(s.src) }, -}; -const location = scenario === 'classic' - ? { search: '', pathname: '/classic' } - : { search: '?graph-engine=next', pathname: '/' }; -globalThis.window = { location, GSET: { mode: 'compact' }, - console: globalThis.console }; -globalThis.console = { warn: (...a) => log.warned.push(String(a[0])) }; -globalThis.showAs = () => {}; -globalThis.graphSetLayoutStatus = () => {}; -globalThis.graphData = () => ({ nodes: [], links: [] }); -/* Mirrors graphRenderEngine's real first line — `if(!element||typeof EngraphisGraph=== - 'undefined')return false` — because that bail is exactly what a naive lazy-load would turn - into a silent Classic fallback. Asserted against the real source below. */ -globalThis.graphRenderEngine = () => { - if (typeof EngraphisGraph === 'undefined') return false; - if (scenario === 'all-runtime-failed') return false; - log.engine += 1; - return true; -}; -globalThis.CLASSIC = () => { log.classic += 1; }; -globalThis.GRAPH_PRESETS = { compact: {} }; -globalThis.GRAPH_ENGINE = globalThis.GACTIVE_DATA = globalThis.GCOMPONENT_LAYOUT = null; -globalThis.GHILITE = globalThis.GHOVERSET = null; -globalThis.GRAPH_FULL = scenario === 'all-loaded' || scenario === 'all-runtime-failed'; -if (globalThis.GRAPH_FULL) globalThis.EngraphisGraph = { create() {} }; -if (scenario === 'all-runtime-failed') globalThis.EngraphisEveryGraph = { create() {} }; -/* All mode intentionally has no vendor global: its renderer must remain self-contained. */ -if (!globalThis.GRAPH_FULL) globalThis.ForceGraph = function () {}; - -new Function(flags + loaders + routing + '\\nreturn {graphRender};')().graphRender(); -const settled = { engine: log.engine, classic: log.classic }; -const finish = () => setTimeout(() => process.stdout.write(JSON.stringify({ - beforeSettle: settled, engine: log.engine, classic: log.classic, - appended: log.appended, warned: log.warned, -})), 0); -if (scenario === 'all-runtime-failed') { - finish(); -} else if (scenario === 'all-loaded') { - /* loadGraphEngine(true) chains the already-ready core through one microtask before it - requests the optional all-node asset. */ - Promise.resolve().then(() => { - globalThis.EngraphisEveryGraph = { create() {} }; pending.onload(); finish(); - }); -} else { - if (scenario === 'loads' || scenario === 'classic') { - globalThis.EngraphisGraph = { create() {} }; pending.onload(); - } - else { pending.onerror(); } - finish(); -} -""" - - -def _run_routing(scenario: str) -> dict: - result = subprocess.run( - [NODE, "-e", ROUTING_HARNESS, str(DASHBOARD), scenario], - 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_graph_engine_deep_link_reaches_the_next_engine_after_a_lazy_load() -> None: - """``?graph-engine=next`` must not degrade just because its asset is not loaded yet. - - ``graphRenderEngine`` bails when ``EngraphisGraph`` is undefined, and that bail cannot tell - "not fetched yet" from "unavailable". Deferring the script would turn every deep link into - that bail — the user asks for the new engine and silently gets Classic. So graphRender - fetches the asset and waits, then renders. - """ - # Keep the harness's stub honest: it only proves anything while the real function really - # does bail on an undefined global. - source = DASHBOARD.read_text(encoding="utf-8") - engine_path = source[source.index("function graphRenderEngine"):] - assert "typeof EngraphisGraph==='undefined')return false" in engine_path[:400] - - report = _run_routing("loads") - - assert report["appended"] == [ - "/v2-assets/engraphis-graph.js?v=20260815-merge-ready-1" - ] - # It waits rather than rendering something wrong in the meantime. - assert report["beforeSettle"] == {"engine": 0, "classic": 0} - # And it lands on the next engine, never touching the classic renderer. - assert report["engine"] == 1 - assert report["classic"] == 0 - assert report["warned"] == [] - - -@requires_node -def test_classic_route_reaches_the_canonical_engine_without_a_query_flag() -> None: - report = _run_routing("classic") - - assert report["appended"] == [ - "/v2-assets/engraphis-graph.js?v=20260815-merge-ready-1" - ] - assert report["beforeSettle"] == {"engine": 0, "classic": 0} - assert report["engine"] == 1 - assert report["classic"] == 0 - assert report["warned"] == [] - - -@requires_node -def test_show_all_lazily_loads_its_renderer_after_the_main_engine_is_ready() -> None: - """The overview's memoized engine promise must not bypass the later all-node asset.""" - report = _run_routing("all-loaded") - - assert report["appended"] == [ - "/v2-assets/engraphis-graph-every.js?v=20260823-every-19" - ] - assert report["beforeSettle"] == {"engine": 0, "classic": 0} - assert report["engine"] == 1 - assert report["classic"] == 0 - assert report["warned"] == [] - - -@requires_node -def test_show_all_never_reaches_legacy_force_graph_after_a_quality_failure() -> None: - """The complete scene is unsafe for the main-thread fallback, even after a failure latch.""" - report = _run_routing("all-runtime-failed") - - assert report["appended"] == [] - assert report["engine"] == 0 - assert report["classic"] == 0 - -@requires_node -def test_graph_engine_deep_link_degrades_loudly_when_the_asset_cannot_load() -> None: - """A genuine load failure is the only thing that reaches Classic, and it says so.""" - report = _run_routing("fails") - - assert report["engine"] == 0 - assert report["classic"] == 1 - assert report["warned"] == [ - "graph-engine=next failed; falling back to the classic renderer" - ] - - -def test_lazy_graph_engine_load_cannot_raise_an_unhandled_rejection() -> None: - """An unhandled rejection prints a console error — the exact thing this fix removes. - - ``graphRender`` can start the engine fetch on a pass that returns at the ForceGraph gate, - before it attaches its own handler, so the memoized promise carries its own. - """ - source = DASHBOARD.read_text(encoding="utf-8") - loader = source[source.index("function loadGraphEngine(loadAll=false)"):] - loader = loader[: loader.index("\nfunction ")] - assert "GRAPH_ENGINE_LOADING.catch(()=>{})" in loader - # A 200 that never registers the global is a corrupt asset, not a success. - assert "reject(new Error('Graph engine asset loaded without registering EngraphisGraph'))" in loader - assert "ALL_GRAPH_ENGINE_LOADING.catch(()=>{})" in source - assert "graphFull&&typeof EngraphisEveryGraph==='undefined'" in source - - -def test_force_graph_loader_rejects_a_success_without_the_vendor_global() -> None: - """A truncated 200 must not enter the render loop without ``ForceGraph``.""" - source = DASHBOARD.read_text(encoding="utf-8") - loader = source[source.index("function loadForceGraph()"):] - loader = loader[: loader.index("\nlet GRAPH_ENGINE_LOADING")] - assert "typeof ForceGraph==='undefined'" in loader - assert "reject(new Error('Force graph asset loaded without registering ForceGraph'))" in loader - - -@requires_node -def test_graph_asset_defines_its_global_without_touching_its_dependencies() -> None: - """Nothing may run at parse time except pure setup. - - ``PRELUDE`` supplies no ``ForceGraph``, no ``document`` and no ``requestAnimationFrame``. - If the asset reached for any of them at the top level this would throw, and in a browser - the same reach would abort the script and take ``window.EngraphisGraph`` with it. - """ - report = _run_node( - """ - emit({ - create: typeof G.create, - presets: Object.keys(G.PRESETS).sort(), - styles: Object.keys(G.STYLE_LAYERS).sort(), - }); - """ - ) - assert report["create"] == "function" - assert "communities" in report["presets"] - assert report["styles"] == ["classic", "cyber", "galaxy", "solar"] - - -@requires_node -def test_create_fails_loudly_when_force_graph_is_unavailable() -> None: - """A blocked vendor bundle must raise, not half-initialise a dead canvas.""" - report = _run_node( - """ - let message = null; - try { G.create({ getAttribute() { return null; } }, {}); } - catch (error) { message = error.message; } - emit({ message }); - """ - ) - assert report["message"] == "force-graph not loaded" - - -@requires_node -def test_node_geometry_stays_compact_for_small_overviews_and_is_style_neutral() -> None: - """Material style changes must not turn a compact overview into oversized discs. - - A seven-node workspace is intentionally common in the Ledger overview. Its normalized - degree metric used to produce a dense-graph radius, and ``zoomToFit`` magnified that radius - until every node filled a large part of the canvas. The radius helper now shares the - bounded scale used by Classic and does not know about visual style. - """ - report = _run_node( - """ - emit({ - leaf: I.graphNodeRadius({ degree: 0 }, 3, 0), - hub: I.graphNodeRadius({ degree: 6 }, 3, 1), - cluster: I.graphNodeRadius({ cluster: true, members: 64 }, 3, 1), - styles: ['classic', 'cyber', 'galaxy', 'solar'].map(() => I.graphNodeRadius({ degree: 6 }, 3, 1)), - }); - """ - ) - assert report["leaf"] >= 0.8 - assert report["hub"] < 4 - assert report["cluster"] < 7 - assert len(set(report["styles"])) == 1 - assert "if (sun) r *= 1.7" not in ASSET.read_text(encoding="utf-8") - assert "if(sun)r*=1.7;" not in CLASSIC_DASHBOARD.read_text(encoding="utf-8") - assert "if(sun)r*=1.7;" not in DASHBOARD.read_text(encoding="utf-8") - - -@requires_node -def test_galaxy_evidence_mass_is_sanitized_and_authoritative_for_radius() -> None: - report = _run_node( - """ - const nodes = [ - { id: 'fallback', degree: 5 }, - { id: 'light', degree: 1, gravity_mass: 2, visual_radius: 9 }, - { id: 'heavy', degree: 2, gravity_mass: 8, visual_radius: 3 }, - { id: 'ghost', degree: 99, gravity_mass: 0, visual_radius: 12, ghost: true }, - ]; - I.sanitizeEvidenceMetrics(nodes, 5); - const ordered = nodes.filter(n => !n.ghost).sort((a, b) => a.gravity_mass - b.gravity_mass); - const clusterSmall = I.evidenceNodeRadius({ cluster: true, gravity_mass: 4 }, 3); - const clusterLarge = I.evidenceNodeRadius({ cluster: true, gravity_mass: 16 }, 3); - emit({ - nodes, - monotonic: ordered.every((n, i) => !i || n.visual_radius >= ordered[i - 1].visual_radius), - scaled: I.evidenceNodeRadius(nodes[0], 6) / I.evidenceNodeRadius(nodes[0], 3), - clusterRatio: clusterLarge / clusterSmall, - fallbackAgain: I.fallbackGravityMass(5, 5), - }); - """ - ) - by_id = {node["id"]: node for node in report["nodes"]} - assert by_id["fallback"]["gravity_mass"] == report["fallbackAgain"] == 16 - def radius(mass: float) -> float: - return 1.2 * (1.5 + 2.0 * mass ** (2.0 / 3.0)) - assert by_id["fallback"]["visual_radius"] == pytest.approx(radius(16)) - assert by_id["light"]["visual_radius"] == pytest.approx(radius(2)) - assert by_id["heavy"]["visual_radius"] == pytest.approx(radius(8)) - assert by_id["ghost"]["gravity_mass"] == 0 - assert report["monotonic"] is True - assert report["scaled"] == pytest.approx(2) - assert report["clusterRatio"] == pytest.approx(radius(16) / radius(4)) - - -@requires_node -def test_global_black_hole_paint_emphasis_does_not_change_physical_radius() -> None: - report = _run_node( - """ - const ordinary = { id: 'ordinary', gravity_mass: 8, visual_radius: 9 }; - const community = { ...ordinary, id: 'community', anchor_role: 'community' }; - const global = { ...ordinary, id: 'global', anchor_role: 'global' }; - const sizes = [1, 3, 12]; - emit({ sizes: sizes.map(size => ({ - size, - ordinary: I.evidenceNodeRadius(ordinary, size), - community: I.evidenceNodeRadius(community, size), - global: I.evidenceNodeRadius(global, size), - })), masses: [ordinary.gravity_mass, community.gravity_mass, global.gravity_mass] }); - """ - ) - for sample in report["sizes"]: - assert sample["community"] == pytest.approx(sample["ordinary"]) - assert sample["global"] == pytest.approx(sample["ordinary"]) - assert report["masses"] == [8, 8, 8] - source = ASSET.read_text(encoding="utf-8") - assignment = source[source.index("data.nodes.forEach(n => {"): - source.index("const labelCap", source.index("data.nodes.forEach(n => {"))] - assert "n.radius = galaxyMode" in assignment - adornment = source[source.index("function paintGalaxyAnchorAdornment"): - source.index("function styleNode", source.index("function paintGalaxyAnchorAdornment"))] - assert "finitePositive(node.radius" in adornment - assert "GALAXY_BLACK_HOLE_PAINT_SCALE" in adornment - - -def test_galaxy_does_not_promote_aggregate_bridges_to_drawable_links() -> None: - source = ASSET.read_text(encoding="utf-8") - assert "raw.community_bridges.forEach(bridge =>" not in source - assert "connector_kind: 'community_bridge'" not in source - assert "state.settings.mode === 'galaxy' && raw.community_bridges.length" not in source - - -@requires_node -def test_softened_galaxy_gravity_obeys_mass_distance_and_momentum_invariants() -> None: - report = _run_node( - """ - const run = (distance, sourceMass, sourceCommunity = 'system') => { - const nodes = [ - { id: 'target', x: 0, y: 0, vx: 0, vy: 0, gravity_mass: 2, community_id: 'system' }, - { id: 'source', x: distance, y: 0, vx: 0, vy: 0, gravity_mass: sourceMass, community_id: sourceCommunity }, - ]; - I.applyGalaxyGravity(nodes, { gravity: 4, softening: 0.0001, alpha: 1 }); - return nodes; - }; - const near = run(10, 4), far = run(20, 4), doubled = run(10, 8); - const coincident = [ - { id: 'a', x: 0, y: 0, gravity_mass: 2, community_id: 'same' }, - { id: 'b', x: 0, y: 0, gravity_mass: 3, community_id: 'same' }, - ]; - I.applyGalaxyGravity(coincident, { gravity: 4, softening: 8, alpha: 1 }); - const isolated = run(10, 4, 'other'); - emit({ - inverseSquare: far[0].vx / near[0].vx, - linearMass: doubled[0].vx / near[0].vx, - momentum: 2 * near[0].vx + 4 * near[1].vx, - coincidentFinite: coincident.every(n => Number.isFinite(n.vx) && Number.isFinite(n.vy)), - isolated: isolated.map(n => [n.vx, n.vy]), - }); - """ - ) - assert report["inverseSquare"] == pytest.approx(0.25, rel=2e-4) - assert report["linearMass"] == pytest.approx(2) - assert report["momentum"] == pytest.approx(0, abs=1e-12) - assert report["coincidentFinite"] is True - assert report["isolated"] == [[0, 0], [0, 0]] - - -@requires_node -def test_galaxy_central_well_contracts_systems_monotonically_and_preserves_momentum() -> None: - report = _run_node( - """ - const fixture = () => [ - { id: 'l1', x: -170, y: 0, vx: 0, vy: 0, gravity_mass: 2, community_id: 'left' }, - { id: 'l2', x: -150, y: 0, vx: 0, vy: 0, gravity_mass: 3, community_id: 'left' }, - { id: 'right', x: 180, y: 0, vx: 0, vy: 0, gravity_mass: 5, community_id: 'right' }, - { id: 'top', x: 0, y: 210, vx: 0, vy: 0, gravity_mass: 4, community_id: 'top' }, - ]; - const distance = nodes => { - const centers = I.communityCenters(nodes); - const a = centers.get('left'), b = centers.get('right'), c = centers.get('top'); - return Math.hypot(a.x - b.x, a.y - b.y) - + Math.hypot(a.x - c.x, a.y - c.y) - + Math.hypot(b.x - c.x, b.y - c.y); - }; - const advance = gravity => { - const nodes = fixture(); - I.applyGalaxyCentralGravity(nodes, { - gravity, softening: 40, alpha: 1, accelerationCap: 1000, - }); - nodes.forEach(node => { node.x += node.vx; node.y += node.vy; }); - return { nodes, span: distance(nodes) }; - }; - const initial = distance(fixture()), low = advance(24), high = advance(72); - const coincident = [ - { id: 'a', x: 0, y: 0, gravity_mass: 2, community_id: 'a' }, - { id: 'b', x: 0, y: 0, gravity_mass: 3, community_id: 'b' }, - ]; - const stats = I.applyGalaxyCentralGravity(coincident, { - gravity: 100, softening: 40, alpha: 1, - }); - const capped = [ - { id: 'light', x: -1, y: 0, vx: 0, vy: 0, gravity_mass: 2, community_id: 'light' }, - { id: 'heavy', x: 1, y: 0, vx: 0, vy: 0, gravity_mass: 8, community_id: 'heavy' }, - ]; - const cappedStats = I.applyGalaxyCentralGravity(capped, { - gravity: 10000, softening: 0.1, alpha: 1, accelerationCap: 0.4, - }); - emit({ - initial, low: low.span, high: high.span, - momentum: [ - high.nodes.reduce((sum, node) => sum + node.gravity_mass * node.vx, 0), - high.nodes.reduce((sum, node) => sum + node.gravity_mass * node.vy, 0), - ], - rigidSystem: [ - high.nodes[0].vx - high.nodes[1].vx, - high.nodes[0].vy - high.nodes[1].vy, - ], - coincidentFinite: coincident.every(node => Number.isFinite(node.vx) && Number.isFinite(node.vy)), - systems: stats.systems, - capped: capped.map(node => node.vx), - cappedMomentum: capped.reduce( - (sum, node) => sum + node.gravity_mass * node.vx, 0 - ), - cappedPairs: cappedStats.applied, - }); - """ - ) - assert report["initial"] > report["low"] > report["high"] - assert report["momentum"] == pytest.approx([0, 0], abs=1e-12) - assert report["rigidSystem"] == pytest.approx([0, 0], abs=1e-12) - assert report["coincidentFinite"] is True - assert report["systems"] == 2 - assert report["capped"][0] == pytest.approx(0.4) - assert report["capped"][1] == pytest.approx(-0.1) - assert report["cappedMomentum"] == pytest.approx(0, abs=1e-12) - assert report["cappedPairs"] == 1 - source = ASSET.read_text(encoding="utf-8") - assert "function galaxyGravityConstant(setting)" in source - assert "function galaxySmoothstep(value)" in source - assert "const boost = 1 + 0.25 * galaxySmoothstep(value / 48)" in source - assert "function applyGalaxyCentralGravity(nodes, options)" in source - assert "GALAXY_CENTER_SCALE" not in source - central = source[source.index("function applyGalaxyCentralGravity"): - source.index("function applyCommunityBridgeGravity")] - assert "driftX" not in central - - -@requires_node -def test_unlinked_solar_systems_exert_bounded_mass_aware_near_field_gravity() -> None: - report = _run_node( - """ - const fixture = distance => [ - { id: 'black-hole', x: 0, y: 0, vx: 0, vy: 0, gravity_mass: 50, - community_id: 'core', anchor_role: 'global' }, - { id: 'left-star', x: 100, y: 0, vx: 0, vy: 0, gravity_mass: 8, - community_id: 'left' }, - { id: 'left-planet', x: 104, y: 2, vx: 0, vy: 0, gravity_mass: 2, - community_id: 'left' }, - { id: 'right-star', x: 100 + distance, y: 0, vx: 0, vy: 0, gravity_mass: 4, - community_id: 'right' }, - ]; - const run = distance => { - const nodes = fixture(distance); - const stats = I.applyGalaxyMutualSystemGravity(nodes, { - gravity: 48, strengthFraction: 0.12, softening: 1, - accelerationCap: 0, exactLimit: 64, - }); - return { nodes, stats }; - }; - const near = run(40), far = run(100); - const large = [{ id: 'core', x: 0, y: 0, vx: 0, vy: 0, gravity_mass: 100, - community_id: 'core', anchor_role: 'global' }]; - for (let index = 0; index < 100; index++) large.push({ - id: 's' + index, - x: 100 + (index % 10) * 20, y: -90 + Math.floor(index / 10) * 20, - gravity_mass: 1 + index % 7, community_id: 'system-' + index, - }); - const largeStats = I.applyGalaxyMutualSystemGravity(large, { - gravity: 48, strengthFraction: 0.12, softening: 40, - accelerationCap: 10, exactLimit: 64, theta: 0.85, - }); - emit({ - nearAcceleration: Math.hypot(near.nodes[1].vx, near.nodes[1].vy), - farAcceleration: Math.hypot(far.nodes[1].vx, far.nodes[1].vy), - blackHole: [near.nodes[0].vx, near.nodes[0].vy], - rigid: [near.nodes[1].vx - near.nodes[2].vx, - near.nodes[1].vy - near.nodes[2].vy], - momentum: near.nodes.slice(1).reduce((sum, node) => ({ - x: sum.x + node.gravity_mass * node.vx, - y: sum.y + node.gravity_mass * node.vy, - }), { x: 0, y: 0 }), - nearStats: near.stats, - largeStats, - finite: large.every(node => Number.isFinite(node.vx) && Number.isFinite(node.vy)), - }); - """ - ) - assert report["nearAcceleration"] > report["farAcceleration"] > 0 - assert report["blackHole"] == [0, 0] - assert report["rigid"] == pytest.approx([0, 0], abs=1e-12) - assert [report["momentum"]["x"], report["momentum"]["y"]] == pytest.approx( - [0, 0], abs=1e-12 - ) - assert report["nearStats"]["systems"] == 2 - assert report["nearStats"]["interactions"] == 1 - assert report["largeStats"]["approximations"] > 0 - assert report["largeStats"]["traversals"] < 100 * 100 - assert report["finite"] is True - - -@requires_node -def test_gravity_slider_response_has_exact_endpoints_and_scales_every_physics_layer() -> None: - report = _run_node( - """ - const ratio = (high, low) => high / low; - const pairAcceleration = gravity => { - const nodes = [ - { id: 'a', community_id: 'one', gravity_mass: 4, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'b', community_id: 'one', gravity_mass: 1, x: 30, y: 0, vx: 0, vy: 0 }, - ]; - I.applyGalaxyGravity(nodes, { gravity, softening: 12, alpha: 1 }); - return Math.abs(nodes[0].vx); - }; - const haloAcceleration = gravity => { - const nodes = [ - { id: 'star', anchor_role: 'community', community_id: 'one', - gravity_mass: 4, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'planet', community_id: 'one', gravity_mass: 1, - x: 30, y: 0, vx: 0, vy: 0 }, - ]; - I.applyGalaxySystemHaloGravity(nodes, { - gravity, softening: 12, smoothFraction: 0.85, accelerationCap: 100, - }); - return Math.abs(nodes[1].vx - nodes[0].vx); - }; - const centralAcceleration = gravity => { - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 8, x: 0, y: 0 }, - { id: 'system', community_id: 'outer', gravity_mass: 2, x: 120, y: 0 }, - ]; - return Math.abs(I.galaxyBlackHoleField(nodes, { - gravity, softening: 40, accelerationCap: 100, - }).systems[0].ax); - }; - const bridgeAcceleration = gravity => { - const nodes = [ - { id: 'a', community_id: 'left', gravity_mass: 4, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'b', community_id: 'right', gravity_mass: 1, x: 80, y: 0, vx: 0, vy: 0 }, - ]; - I.applyCommunityBridgeGravity(nodes, [{ - source_community: 'left', target_community: 'right', physics_strength: 0.8, - }], { gravity, softening: 30, alpha: 1 }); - return Math.abs(nodes[0].vx); - }; - const localSeedSpeedSquared = gravity => { - const nodes = [ - { id: 'star', anchor_role: 'community', community_id: 'one', - gravity_mass: 4, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'planet', community_id: 'one', gravity_mass: 1, - x: 30, y: 0, vx: 0, vy: 0 }, - ]; - I.seedGalaxyOrbits(nodes, 9, gravity, 12, false, 0.15); - const speed = Math.hypot(nodes[1].vx - nodes[0].vx, - nodes[1].vy - nodes[0].vy); - return speed * speed; - }; - const systemSeedSpeedSquared = gravity => { - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 8, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'system', anchor_role: 'community', community_id: 'outer', - gravity_mass: 2, x: 120, y: 0, vx: 0, vy: 0 }, - ]; - I.seedGalaxySystemOrbits(nodes, 9, gravity, 40, false); - const speed = Math.hypot(nodes[1].vx - nodes[0].vx, - nodes[1].vy - nodes[0].vy); - return speed * speed; - }; - const settings = [0, 1, 12, 24, 48, 72, 100, 200, 400]; - const response = settings.map(I.galaxyGravityConstant); - const legacy = setting => setting * (772 + 11 * setting) / 2600; - // This is the release-stable calibration restored after the unsafe speed-up. - const priorCalibration = setting => { - const value = Math.max(0, Math.min(400, Number(setting) || 0)); - const base = value * (772 + 11 * value) / 2600; - const smoothstep = raw => { - const t = Math.max(0, Math.min(1, raw)); - return t * t * (3 - 2 * t); - }; - const boost = 1 + 0.25 * smoothstep(value / 48) - + 0.25 * smoothstep((value - 48) / 52); - const highEndGain = 1 + 0.5 * smoothstep((value - 200) / 200 * 1.5); - return base * boost * 4 * highEndGain * 2.0; - }; - const fullRange = Array.from({ length: 401 }, (_, setting) => setting); - const centralCap = (gravity, explicit) => { - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 1000, x: 0, y: 0 }, - { id: 'near', community_id: 'outer', gravity_mass: 1000, x: 1, y: 0 }, - ]; - const options = { gravity, softening: 0.1 }; - if (explicit !== undefined) options.accelerationCap = explicit; - const item = I.galaxyBlackHoleField(nodes, options).systems[0]; - return Math.hypot(item.ax, item.ay); - }; - const compatibilityCentralCap = gravity => { - const nodes = [ - { id: 'left', community_id: 'left', gravity_mass: 1000, - x: -0.5, y: 0, vx: 0, vy: 0 }, - { id: 'right', community_id: 'right', gravity_mass: 1000, - x: 0.5, y: 0, vx: 0, vy: 0 }, - ]; - I.applyGalaxyCentralGravity(nodes, { gravity, softening: 0.1 }); - return Math.max(...nodes.map(node => Math.hypot(node.vx, node.vy))); - }; - const localHaloCap = gravity => { - const nodes = [ - { id: 'star', anchor_role: 'community', community_id: 'one', - gravity_mass: 1000, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'near', community_id: 'one', gravity_mass: 1000, - x: 0.01, y: 0, vx: 0, vy: 0 }, - ]; - I.applyGalaxySystemHaloGravity(nodes, { - gravity, softening: 0.1, smoothFraction: 0.85, - }); - return Math.max(...nodes.map(node => Math.hypot(node.vx, node.vy))); - }; - emit({ - response, - endpoints: [I.galaxyGravityConstant(48), I.galaxyGravityConstant(100), - I.galaxyGravityConstant(200), I.galaxyGravityConstant(400)], - split: { - blackHole: [I.galaxyBlackHoleGravityConstant(48), - I.galaxyBlackHoleGravityConstant(100), - I.galaxyBlackHoleGravityConstant(200), - I.galaxyBlackHoleGravityConstant(400)], - local: [I.galaxyLocalGravityConstant(48), - I.galaxyLocalGravityConstant(100), - I.galaxyLocalGravityConstant(200), - I.galaxyLocalGravityConstant(400)], - }, - clamps: [I.galaxyGravityConstant(-1), I.galaxyGravityConstant(401), - I.galaxyGravityConstant(Infinity), I.galaxyGravityConstant(NaN)], - layoutCompactness: [0, 48, 200, 400].map(I.galaxyLayoutCompactness), - caps: [centralCap(48), centralCap(100), centralCap(100, 1)], - compatibilityCaps: [compatibilityCentralCap(48), compatibilityCentralCap(100)], - localCaps: [localHaloCap(48), localHaloCap(100)], - neverWeaker: fullRange.every(setting => - I.galaxyGravityConstant(setting) >= legacy(setting) - 1e-12), - matchesStableCalibration: fullRange.every(setting => Math.abs( - I.galaxyGravityConstant(setting) - priorCalibration(setting) - ) <= 1e-10), - priorEndpoints: [48, 100, 200, 400].map(priorCalibration), - fullRangeMonotone: fullRange.slice(1).every((setting, index) => - I.galaxyGravityConstant(setting) > I.galaxyGravityConstant(index)), - ratios: { - pair: ratio(pairAcceleration(100), pairAcceleration(48)), - halo: ratio(haloAcceleration(100), haloAcceleration(48)), - central: ratio(centralAcceleration(100), centralAcceleration(48)), - bridge: ratio(bridgeAcceleration(100), bridgeAcceleration(48)), - localSeed: ratio(localSeedSpeedSquared(100), localSeedSpeedSquared(48)), - systemSeed: ratio(systemSeedSpeedSquared(100), systemSeedSpeedSquared(48)), - }, - }); - """ - ) - assert report["endpoints"][:2] == [240, 864] - assert report["endpoints"][2] == pytest.approx(2743.3846153846152) - assert report["endpoints"][3] == pytest.approx(14322.461538461538) - assert report["split"]["blackHole"] == pytest.approx( - [480, 1728, 5486.7692307692305, 28644.923076923076] - ) - assert report["split"]["local"] == pytest.approx( - [240, 864, 2743.3846153846152, 14322.461538461538] - ) - assert report["split"]["local"] == [ - value * 0.5 for value in report["split"]["blackHole"] - ] - assert report["clamps"] == pytest.approx([0, 14322.461538461538, 0, 0]) - assert report["layoutCompactness"] == pytest.approx([1.75, 1.5616, 0.965, 0.18]) - assert all( - right < left - for left, right in zip(report["layoutCompactness"], report["layoutCompactness"][1:]) - ) - assert report["caps"] == pytest.approx([50, 180, 1]) - assert report["compatibilityCaps"] == pytest.approx([50, 180]) - assert report["localCaps"] == pytest.approx([25, 90]) - assert report["response"][0] == 0 - assert all( - right > left - for left, right in zip(report["response"], report["response"][1:]) - ) - assert report["neverWeaker"] is True - assert report["matchesStableCalibration"] is True - assert report["endpoints"] == pytest.approx(report["priorEndpoints"]) - assert report["fullRangeMonotone"] is True - assert all(value == pytest.approx(3.6, rel=1e-12) for value in report["ratios"].values()) - source = ASSET.read_text(encoding="utf-8") - assert "const GALAXY_FAR_FIELD_ENVELOPE_SCALE = 2;" in source - assert "const GALAXY_GRAVITY_MAXIMUM = 400;" in source - assert "const GALAXY_GRAVITY_MAX_STRENGTH_GAIN = 1.5;" in source - assert "const GALAXY_GRAVITY_RESPONSE_RATE_MULTIPLIER = 1.5;" in source - - -@requires_node -def test_galaxy_gravity_slider_controls_galactic_field_not_local_orbits() -> None: - report = _run_node( - """ - const localTrial = gravity => { - const nodes = [ - { id: 'star', anchor_role: 'community', community_id: 'solar', - gravity_mass: 8, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'planet', community_id: 'solar', system_anchor_id: 'star', - gravity_mass: 1, x: 30, y: 0, vx: 0, vy: 0 }, - ]; - I.applyGalaxySystemAnchorGravity(nodes, { - gravity, localGravitySetting: 48, softening: 12, alpha: 1, - }); - return [nodes[0].vx, nodes[0].vy, nodes[1].vx, nodes[1].vy]; - }; - const galacticTrial = gravity => { - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 20, x: 0, y: 0 }, - { id: 'system', community_id: 'solar', gravity_mass: 2, - x: 120, y: 0 }, - ]; - const report = I.galaxyBlackHoleField(nodes, { gravity, softening: 32 }); - return report.systems.length ? Math.hypot(report.systems[0].ax, report.systems[0].ay) : 0; - }; - emit({ - localAtZero: localTrial(0), - localAtTwoHundred: localTrial(200), - galacticAtZero: galacticTrial(0), - galacticAtTwoHundred: galacticTrial(200), - convergenceAtZero: I.galaxyInwardConvergenceFactor(60, 0), - convergenceAtTwoHundred: I.galaxyInwardConvergenceFactor(60, 200), - }); - """ - ) - 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 - 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) - # Convergence is disabled (rate=0) for stable orbits; factor is 1 at all gravity settings. - assert report["convergenceAtTwoHundred"] == pytest.approx(report["convergenceAtZero"]) - - -@requires_node -def test_orbital_speed_increases_use_a_bounded_response_with_less_expansion() -> None: - report = _run_node( - """ - const settings = [0, 100, 200, 400]; - const localTrial = setting => { - const nodes = [ - { id: 'star', anchor_role: 'community', community_id: 'solar', - system_anchor_id: 'star', gravity_mass: 4, radius: 5, - x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'planet', community_id: 'solar', system_anchor_id: 'star', - orbit_tier: 1, gravity_mass: 1, radius: 2, - x: 30, y: 0, vx: 0, vy: 0 }, - ]; - I.seedGalaxyOrbits(nodes, 19, 48, 12, false, { orbitalSpeed: setting }); - return { - radius: Math.hypot(nodes[1].x - nodes[0].x, nodes[1].y - nodes[0].y), - speed: Math.hypot(nodes[1].vx - nodes[0].vx, - nodes[1].vy - nodes[0].vy), - }; - }; - const globalTrial = setting => { - 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: 0, vy: 0 }, - ]; - I.seedGalaxySystemOrbits(nodes, 19, 48, 40, false, { orbitalSpeed: setting }); - return Math.hypot(nodes[1].vx - nodes[0].vx, - nodes[1].vy - nodes[0].vy); - }; - const liveTrial = setting => { - 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: 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.applyGalaxyOrbitalSpeedControl(nodes, { - gravity: 48, softening: 32, centralSoftening: 40, - orbitalSpeed: setting, layoutSeed: 19, - }); - return { - global: Math.hypot(nodes[1].vx, nodes[1].vy), - local: Math.hypot(nodes[2].vx - nodes[1].vx, - nodes[2].vy - nodes[1].vy), - }; - }; - emit({ - multipliers: settings.map(I.galaxyOrbitalSpeedMultiplier), - radii: settings.map(setting => localTrial(setting).radius), - localSpeeds: settings.map(setting => localTrial(setting).speed), - globalSpeeds: settings.map(globalTrial), - live: settings.map(liveTrial), - }); - """ - ) - assert report["multipliers"] == pytest.approx([0.25, 1, 1.5, 2.5]) - assert report["radii"][0] == pytest.approx(report["radii"][1]) - assert report["radii"][1] < report["radii"][2] < report["radii"][3] - assert report["radii"][1] == pytest.approx(30) - assert report["radii"][2] == pytest.approx(32.4) - assert report["radii"][3] == pytest.approx(37.2) - assert report["multipliers"][2] - 1 == pytest.approx(0.5 * (2 - 1)) - assert report["multipliers"][3] - 1 == pytest.approx(0.5 * (4 - 1)) - assert report["radii"][3] - report["radii"][1] == pytest.approx( - 0.8 * (39 - 30) - ) - assert report["localSpeeds"] == sorted(report["localSpeeds"]) - assert report["globalSpeeds"] == sorted(report["globalSpeeds"]) - assert [item["global"] for item in report["live"]] == sorted( - item["global"] for item in report["live"] - ) - assert [item["local"] for item in report["live"]] == sorted( - item["local"] for item in report["live"] - ) - - -@requires_node -def test_default_orbital_speed_preserves_cached_star_relative_direction() -> None: - """The shipped 100% clock must keep local control live after motion is established.""" - 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: 0 }, - { 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: 0 }, - ]; - const options = { - gravity: 48, softening: 32, centralSoftening: 40, - localGravitySetting: 48, orbitalSpeed: 100, - layoutSeed: 19, timestep: .032, - }; - I.seedGalaxyOrbits(nodes, 19, 48, 32, false, options); - I.seedGalaxySystemOrbits(nodes, 19, 48, 40, false, options); - const star = nodes[1], planet = nodes[2]; - const tangent = () => { - const dx = planet.x - star.x, dy = planet.y - star.y; - const radius = Math.hypot(dx, dy); - const relativeVx = planet.vx - star.vx; - const relativeVy = planet.vy - star.vy; - return (-dy * relativeVx + dx * relativeVy) / radius; - }; - const starPhase = () => [star.x, star.y, star.vx, star.vy]; - const radius = () => Math.hypot(planet.x - star.x, planet.y - star.y); - const starBefore = starPhase(); - const first = I.applyGalaxyOrbitalSpeedControl(nodes, options); - const initialTangent = tangent(); - const initialRadius = radius(); - const cachedDirection = planet.__galaxySpeedControlPhase.direction; - const relativeVx = planet.vx - star.vx; - const relativeVy = planet.vy - star.vy; - planet.vx = star.vx - relativeVx; - planet.vy = star.vy - relativeVy; - const reversedTangent = tangent(); - const second = I.applyGalaxyOrbitalSpeedControl(nodes, options); - emit({ - first, second, initialTangent, reversedTangent, - repairedTangent: tangent(), cachedDirection, - initialRadius, repairedRadius: radius(), - stellarSpeedGain: Math.sqrt(I.galaxyStellarGravityConstant(48) / 750), - starBefore, starAfter: starPhase(), - }); - """ - ) - assert report["first"]["systems"] == 0 - assert report["second"]["systems"] == 0 - assert report["first"]["localSatellites"] == 1 - assert report["second"]["localSatellites"] == 1 - assert report["cachedDirection"] == pytest.approx( - math.copysign(1, report["initialTangent"]) - ) - assert math.copysign(1, report["reversedTangent"]) == -report["cachedDirection"] - assert math.copysign(1, report["repairedTangent"]) == report["cachedDirection"] - assert abs(report["repairedTangent"]) > 1e-5 - assert report["repairedRadius"] == pytest.approx(report["initialRadius"]) - assert report["stellarSpeedGain"] == pytest.approx(1.8384776310850235) - assert report["starAfter"] == pytest.approx(report["starBefore"]) - - -@requires_node -def test_default_clock_keeps_planets_and_moons_orbiting_their_immediate_parent() -> None: - """Nested children rotate continuously in the moving frame of their larger parent.""" - report = _run_node( - """ - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - system_anchor_id: 'black-hole', orbit_tier: 0, gravity_mass: 20, 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: 10, radius: 6, - x: 140, y: 0, vx: 0, vy: 0 }, - { id: 'planet', community_id: 'solar', system_anchor_id: 'star', - orbit_tier: 1, orbit_radius: 42, gravity_mass: 5, radius: 4, - x: 182, y: 0, vx: 0, vy: 0 }, - { id: 'planet-b', community_id: 'solar', system_anchor_id: 'star', - orbit_tier: 1, orbit_radius: 70, gravity_mass: 3, radius: 3, - x: 140, y: 70, vx: 0, vy: 0 }, - { id: 'moon-a', community_id: 'solar', system_anchor_id: 'planet', - orbit_tier: 2, orbit_radius: 16, gravity_mass: 1, radius: 2, - x: 198, y: 0, vx: 0, vy: 0 }, - { id: 'moon-b', community_id: 'solar', system_anchor_id: 'planet', - orbit_tier: 2, orbit_radius: 25, gravity_mass: 1, radius: 2, - x: 182, y: 25, vx: 0, vy: 0 }, - ]; - const options = { - gravity: 48, softening: 32, centralSoftening: 40, - localGravitySetting: 48, orbitalSpeed: 100, - layoutSeed: 817, timestep: .032, - }; - I.seedGalaxyOrbits(nodes, 817, 48, 32, false, options); - I.seedGalaxySystemOrbits(nodes, 817, 48, 40, false, options); - const byId = new Map(nodes.map(node => [String(node.id), node])); - const children = nodes.filter(node => Number(node.orbit_tier) > 0); - const angle = node => { - const parent = byId.get(String(node.system_anchor_id)); - return Math.atan2(node.y - parent.y, node.x - parent.x); - }; - const radius = node => { - const parent = byId.get(String(node.system_anchor_id)); - return Math.hypot(node.x - parent.x, node.y - parent.y); - }; - const previous = new Map(children.map(node => [node.id, angle(node)])); - const travel = new Map(children.map(node => [node.id, 0])); - const direction = new Map(); - let maximumRadiusError = 0; - for (let step = 0; step < 240; step++) { - I.applyGalaxyOrbitalSpeedControl(nodes, options); - children.forEach(node => { - const next = angle(node); - const delta = Math.atan2(Math.sin(next - previous.get(node.id)), - Math.cos(next - previous.get(node.id))); - previous.set(node.id, next); - travel.set(node.id, travel.get(node.id) + delta); - const sign = Math.sign(delta); - if (sign) { - if (!direction.has(node.id)) direction.set(node.id, sign); - else if (direction.get(node.id) !== sign) throw new Error('orbit reversed'); - } - maximumRadiusError = Math.max(maximumRadiusError, - Math.abs(radius(node) - node.orbit_radius)); - }); - } - const lanes = I.galaxyOrbitLaneGeometry(nodes); - emit({ - travel: Object.fromEntries(travel), - directions: Object.fromEntries(direction), - maximumRadiusError, - parents: Object.fromEntries(children.map(node => [node.id, node.system_anchor_id])), - laneAnchors: lanes.map(lane => lane.anchorId).sort(), - laneRadii: lanes.map(lane => lane.radius).sort((a, b) => a - b), - moonSpeedGain: Math.sqrt(I.galaxySystemGravityConstant( - byId.get('planet'), 48, 48, true - ) / I.galaxyFallbackStellarGravityConstant(48)), - moonRole: I.galaxyOrbitalLinkRole({ - source: byId.get('planet'), target: byId.get('moon-a'), - }), - }); - """ - ) - assert report["parents"] == { - "planet": "star", - "planet-b": "star", - "moon-a": "planet", - "moon-b": "planet", - } - assert all(abs(value) > 0.05 for value in report["travel"].values()) - assert set(report["directions"]) == set(report["parents"]) - assert report["maximumRadiusError"] < 1e-8 - assert report["laneAnchors"] == ["planet", "planet", "star", "star"] - assert report["laneRadii"] == pytest.approx([16, 25, 42, 70]) - assert report["moonSpeedGain"] == pytest.approx(1.3) - assert report["moonRole"] == "radial" - - -@requires_node -def test_live_solar_system_uses_authored_concentric_star_relative_lanes() -> None: - """Every authored planet stays on a clean lane about the one declared star.""" - report = _run_node( - """ - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - system_anchor_id: 'black-hole', orbit_tier: 0, 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, orbit_radius: 0, - gravity_mass: 8, radius: 5, x: 120, y: 0, vx: 0, vy: 0 }, - ...[18, 30, 44, 60].map((orbit, index) => ({ - id: 'planet-' + index, community_id: 'solar', system_anchor_id: 'star', - orbit_tier: index + 1, orbit_radius: orbit, gravity_mass: 1, - radius: 2, x: 121 + index, y: 1 + index, vx: 0, vy: 0, - })), - ]; - const options = { - gravity: 48, softening: 32, centralSoftening: 40, - localGravitySetting: 48, orbitalSpeed: 100, - layoutSeed: 2026, timestep: .032, - }; - I.seedGalaxyOrbits(nodes, 2026, 48, 32, false, options); - I.seedGalaxySystemOrbits(nodes, 2026, 48, 40, false, options); - const star = nodes[1], planets = nodes.slice(2); - const previous = new Map(planets.map(node => [node.id, - Math.atan2(node.y - star.y, node.x - star.x)])); - const travel = new Map(planets.map(node => [node.id, 0])); - const direction = new Map(); - let maximumRadiusError = 0, minimumLaneGap = Infinity; - for (let step = 0; step < 180; step++) { - I.applyGalaxyOrbitalSpeedControl(nodes, options); - const radii = []; - planets.forEach(node => { - const dx = node.x - star.x, dy = node.y - star.y; - const radius = Math.hypot(dx, dy); - const angle = Math.atan2(dy, dx); - const delta = Math.atan2(Math.sin(angle - previous.get(node.id)), - Math.cos(angle - previous.get(node.id))); - previous.set(node.id, angle); - travel.set(node.id, travel.get(node.id) + delta); - const sign = Math.sign(delta); - if (sign) { - if (!direction.has(node.id)) direction.set(node.id, sign); - else if (direction.get(node.id) !== sign) throw new Error('orbit reversed'); - } - maximumRadiusError = Math.max(maximumRadiusError, - Math.abs(radius - node.orbit_radius)); - radii.push({ radius, node }); - }); - radii.sort((left, right) => left.radius - right.radius); - for (let index = 1; index < radii.length; index++) { - minimumLaneGap = Math.min(minimumLaneGap, - radii[index].radius - radii[index - 1].radius - - radii[index].node.radius - radii[index - 1].node.radius); - } - } - const geometry = I.galaxyOrbitLaneGeometry(nodes); - const strokes = []; - const context = { - save() {}, restore() {}, beginPath() {}, stroke() { strokes.push(this.lastArc); }, - arc(x, y, radius) { this.lastArc = { x, y, radius }; }, - set lineWidth(value) { this._lineWidth = value; }, - set strokeStyle(value) { this._strokeStyle = value; }, - }; - const painted = I.paintGalaxyOrbitLanes(context, nodes, 1, '#9d7bff'); - const visibleStarIds = I.galaxyStarAnchorIds(geometry); - emit({ - maximumRadiusError, minimumLaneGap, painted, geometry, - strokes, travel: [...travel.values()], directions: [...direction.values()], - parents: planets.map(node => node.system_anchor_id), - tiers: planets.map(node => node.orbit_tier), - radialRole: I.galaxyOrbitalLinkRole({ source: star, target: planets[0] }), - internalRole: I.galaxyOrbitalLinkRole({ source: planets[0], target: planets[1] }), - adornment: { - star: I.galaxyAnchorAdornmentEligible(star, visibleStarIds), - singleton: I.galaxyAnchorAdornmentEligible({ - id: 'singleton', anchor_role: 'community', community_id: 'alone', - }, visibleStarIds), - global: I.galaxyAnchorAdornmentEligible(nodes[0], visibleStarIds), - planet: I.galaxyAnchorAdornmentEligible(planets[0], visibleStarIds), - twoConnected: I.galaxyStarAnchorIds([ - { anchorId: 'two', members: 2 }, - ]).has('two'), - threeConnected: I.galaxyStarAnchorIds([ - { anchorId: 'three', members: 3 }, - ]).has('three'), - }, - }); - """ - ) - assert report["maximumRadiusError"] < 1e-8 - assert report["minimumLaneGap"] >= 8 - 1e-8 - assert report["painted"] == 4 - assert [lane["radius"] for lane in report["geometry"]] == pytest.approx( - [18, 30, 44, 60] - ) - assert [stroke["radius"] for stroke in report["strokes"]] == pytest.approx( - [18, 30, 44, 60] - ) - assert all(abs(value) > 0.01 for value in report["travel"]) - assert len(report["directions"]) == 4 - assert report["parents"] == ["star"] * 4 - assert report["tiers"] == [1, 2, 3, 4] - assert report["radialRole"] == "radial" - assert report["internalRole"] == "internal" - assert report["adornment"] == { - "star": True, - "singleton": False, - "global": True, - "planet": False, - "twoConnected": False, - "threeConnected": True, - } - - -@requires_node -def test_orbital_speed_scales_live_carrier_and_kinematic_phase_rates() -> None: - report = _run_node( - """ - const fixture = () => [ - { 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: 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 phaseDelta = (from, to) => Math.atan2( - Math.sin(to - from), Math.cos(to - from)); - const kinematicTrial = orbitalSpeed => { - const nodes = fixture(); - let systemTravel = 0, localTravel = 0; - for (let step = 0; step < 24; step += 1) { - const beforeSystem = Math.atan2(nodes[1].y, nodes[1].x); - const beforeLocal = Math.atan2(nodes[2].y - nodes[1].y, - nodes[2].x - nodes[1].x); - I.advanceGalaxyKinematicOrbits(nodes, { - gravity: 48, softening: 32, centralSoftening: 40, localSoftening: 12, - orbitalSpeed, layoutSeed: 19, timestep: .032, - }); - systemTravel += Math.abs(phaseDelta(beforeSystem, - Math.atan2(nodes[1].y, nodes[1].x))); - localTravel += Math.abs(phaseDelta(beforeLocal, - Math.atan2(nodes[2].y - nodes[1].y, nodes[2].x - nodes[1].x))); - } - return { systemTravel, localTravel }; - }; - const liveCarrierTrial = orbitalSpeed => { - const nodes = fixture(); - Object.defineProperty(nodes[1], '__galaxyCarrierLaneRadius', { - value: 120, writable: true, configurable: true, enumerable: false, - }); - Object.defineProperty(nodes[1], '__galaxyCarrierLaneAngle', { - value: 0, writable: true, configurable: true, enumerable: false, - }); - I.supportGalaxyCarrierOrbits(nodes, { - gravity: 48, softening: 32, centralSoftening: 40, - orbitalSpeed, layoutSeed: 19, timestep: .032, - }); - return Math.abs(Math.atan2(nodes[1].y, nodes[1].x)); - }; - const naturalKinematic = kinematicTrial(100); - const fastKinematic = kinematicTrial(400); - const naturalCarrier = liveCarrierTrial(100); - const fastCarrier = liveCarrierTrial(400); - emit({ naturalKinematic, fastKinematic, naturalCarrier, fastCarrier, - kinematicSystemRatio: fastKinematic.systemTravel / naturalKinematic.systemTravel, - kinematicLocalRatio: fastKinematic.localTravel / naturalKinematic.localTravel, - carrierRatio: fastCarrier / naturalCarrier }); - """ - ) - assert report["naturalKinematic"]["systemTravel"] > 0 - assert report["naturalKinematic"]["localTravel"] > 0 - assert report["kinematicSystemRatio"] > 1.8 - assert report["kinematicLocalRatio"] > 2.5 - assert report["naturalCarrier"] > 0 - assert report["carrierRatio"] == pytest.approx(2.5, rel=0.02) - - -@requires_node -def test_four_hundred_percent_clock_keeps_release_sized_solar_systems_inside_reserved_lanes() -> None: - """The maximum clock may expand and accelerate 60 systems, never scatter their members.""" - report = _run_node( - """ - const nodes = [{ id: 'black-hole', anchor_role: 'global', community_id: 'core', - system_anchor_id: 'black-hole', gravity_mass: 64, radius: 9, - x: 0, y: 0, vx: 0, vy: 0 }]; - for (let system = 0; system < 60; system++) { - const systemId = 'system-' + system, starId = systemId + '-star'; - const phase = system * 2.399963229728653; - const carrierRadius = 120 + system * 4; - const starX = Math.cos(phase) * carrierRadius; - const starY = Math.sin(phase) * carrierRadius; - nodes.push({ id: starId, anchor_role: 'community', community_id: systemId, - system_anchor_id: starId, gravity_mass: 8 + system % 5, radius: 5.5, - x: starX, y: starY, vx: 0, vy: 0 }); - for (let member = 1; member <= 8; member++) { - const orbitRadius = 18 + member * 4; - const localPhase = phase + member * 2.399963229728653; - nodes.push({ id: systemId + '-planet-' + member, community_id: systemId, - system_anchor_id: starId, orbit_tier: member, orbit_radius: orbitRadius, - gravity_mass: 1 + (member % 3) * .25, radius: 2.5, - x: starX + Math.cos(localPhase) * orbitRadius, - y: starY + Math.sin(localPhase) * orbitRadius, vx: 0, vy: 0 }); - } - } - const setting = 400; - I.establishGalaxyCarrierLanes(nodes, { gap: 4, layoutSeed: 817 }); - I.seedGalaxyOrbits(nodes, 817, 48, 32, false, { - orbitalSpeed: setting, localGravitySetting: 48, - }); - I.seedGalaxySystemOrbits(nodes, 817, 48, 48, false, { - orbitalSpeed: setting, - }); - const options = { - layoutSeed: 817, gravity: 48, softening: 32, centralSoftening: 48, - localSoftening: 32, localGravitySetting: 48, orbitalSpeed: setting, - timestep: .032, wallClockSeconds: 1 / 30, velocityDecay: .00005, - speedLimit: 48, exactLimit: 64, theta: .85, - includeBridges: false, includeMutualSystems: true, - mutualSystemGravityFraction: .12, mutualSystemSoftening: 80, - includeRelations: false, includeRelationSprings: false, - includeOrbitalSeparation: false, includeSystemPacking: false, - includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, - includeFarFieldConfinement: true, farFieldEnvelopeScale: 1.75, - farFieldMinimumRadius: 96, farFieldSoftFraction: .82, - localRelativeSpeedLimit: 48, - }; - const byId = new Map(nodes.map(node => [String(node.id), node])); - const members = nodes.filter(node => node.system_anchor_id - && String(node.system_anchor_id) !== String(node.id) - && String(node.system_anchor_id) !== 'black-hole'); - const carriers = nodes.filter(node => node.anchor_role === 'community'); - const previousCarrierAngles = new Map(carriers.map(node => [node.id, - Math.atan2(node.y, node.x)])); - const previousLocalAngles = new Map(members.map(node => { - const parent = byId.get(String(node.system_anchor_id)); - return [node.id, Math.atan2(node.y - parent.y, node.x - parent.x)]; - })); - const carrierTravel = new Map(carriers.map(node => [node.id, 0])); - const localTravel = new Map(members.map(node => [node.id, 0])); - const delta = (next, previous) => Math.atan2(Math.sin(next - previous), - Math.cos(next - previous)); - let maximumBoundaryRatio = 0, minimumSystemClearance = Infinity; - let maximumSettledCorrection = 0; - for (let step = 0; step < 180; step++) { - I.integrateGalaxyLeapfrog(nodes, [], [], options); - const control = I.applyGalaxyOrbitalSpeedControl(nodes, options); - if (step > 12) maximumSettledCorrection = Math.max(maximumSettledCorrection, - control.maximumPositionCorrection); - carriers.forEach(node => { - const angle = Math.atan2(node.y, node.x), previous = previousCarrierAngles.get(node.id); - carrierTravel.set(node.id, carrierTravel.get(node.id) + delta(angle, previous)); - previousCarrierAngles.set(node.id, angle); - }); - members.forEach(node => { - const parent = byId.get(String(node.system_anchor_id)); - const radius = Math.hypot(node.x - parent.x, node.y - parent.y); - const maximum = node.__galaxyOrbitBaseRadius - * I.galaxyOrbitalRadiusMultiplier(setting) * 1.08; - maximumBoundaryRatio = Math.max(maximumBoundaryRatio, radius / maximum); - const angle = Math.atan2(node.y - parent.y, node.x - parent.x); - const previous = previousLocalAngles.get(node.id); - localTravel.set(node.id, localTravel.get(node.id) + delta(angle, previous)); - previousLocalAngles.set(node.id, angle); - }); - if (step % 15 === 0 || step === 179) { - const systems = I.galaxySystemEnvelopes(nodes, { - respectFixedCoordinates: false, - }).filter(system => system.anchor.anchor_role === 'community'); - for (let left = 0; left < systems.length; left++) { - for (let right = left + 1; right < systems.length; right++) { - minimumSystemClearance = Math.min(minimumSystemClearance, - Math.hypot(systems[left].x - systems[right].x, - systems[left].y - systems[right].y) - - systems[left].radius - systems[right].radius); - } - } - } - } - emit({ nodeCount: nodes.length, memberCount: members.length, - multiplier: I.galaxyOrbitalSpeedMultiplier(setting), - radiusMultiplier: I.galaxyOrbitalRadiusMultiplier(setting), - maximumBoundaryRatio, minimumSystemClearance, maximumSettledCorrection, - minimumCarrierTravel: Math.min(...[...carrierTravel.values()].map(Math.abs)), - minimumLocalTravel: Math.min(...[...localTravel.values()].map(Math.abs)), - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)) }); - """ - ) - assert report["nodeCount"] == 541 - assert report["memberCount"] == 480 - assert report["finite"] is True - assert report["multiplier"] == pytest.approx(2.5) - assert report["radiusMultiplier"] == pytest.approx(1.24) - assert report["maximumBoundaryRatio"] <= 1 + 1e-9 - assert report["minimumSystemClearance"] >= -1e-8 - assert report["minimumCarrierTravel"] > 0.1 - assert report["minimumLocalTravel"] > 0.1 - assert report["maximumSettledCorrection"] < 4 - - -@requires_node -def test_explicit_black_hole_child_gets_slider_controlled_orbital_lane() -> None: - report = _run_node( - """ - const fixture = () => [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 64, radius: 8, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'connected', community_id: 'cross-core', - system_anchor_id: 'black-hole', gravity_mass: 3, - radius: 3, x: 52, y: 0, vx: 0, vy: 0 }, - { id: 'star', anchor_role: 'community', community_id: 'solar', - system_anchor_id: 'star', gravity_mass: 8, radius: 5, - x: 120, y: 0, vx: 0, vy: 0 }, - ]; - const trial = orbitalSpeed => { - const nodes = fixture(); - I.seedGalaxyOrbits(nodes, 77, 48, 32, false, { orbitalSpeed }); - let travel = 0; - for (let step = 0; step < 30; step += 1) { - const before = Math.atan2(nodes[1].y, nodes[1].x); - I.supportGalaxyCarrierOrbits(nodes, { - gravity: 48, softening: 32, centralSoftening: 40, - orbitalSpeed, layoutSeed: 77, timestep: .032, - }); - const after = Math.atan2(nodes[1].y, nodes[1].x); - travel += Math.abs(Math.atan2(Math.sin(after - before), Math.cos(after - before))); - } - return { travel, child: nodes[1], grouped: I.galaxyOrbitGroups(nodes).get('black-hole') }; - }; - const slow = trial(100), fast = trial(400); - emit({ slow: { travel: slow.travel, child: slow.child, - grouped: slow.grouped && slow.grouped.nodes.map(node => node.id) }, - fast: { travel: fast.travel, child: fast.child, - grouped: fast.grouped && fast.grouped.nodes.map(node => node.id) }, - ratio: fast.travel / slow.travel }); - """ - ) - assert report["slow"]["travel"] > 0 - assert report["fast"]["travel"] > report["slow"]["travel"] - assert report["ratio"] == pytest.approx(2.5, rel=0.03) - assert report["slow"]["grouped"] == ["black-hole", "connected"] - assert report["fast"]["grouped"] == ["black-hole", "connected"] - - -@requires_node -def test_relation_to_black_hole_does_not_override_server_authored_hierarchy() -> None: - report = _run_node( - """ - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - system_anchor_id: 'black-hole', gravity_mass: 64, radius: 8, - x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'related-star', anchor_role: 'community', community_id: 'solar', - system_anchor_id: 'related-star', gravity_mass: 8, radius: 5, - x: 72, y: 0, vx: 0, vy: 0 }, - ]; - const links = [{ source: 'black-hole', target: 'related-star', relation: 'orbits' }]; - emit({ - linkCount: links.length, - core: I.galaxyOrbitGroups(nodes).get('black-hole').nodes.map(node => node.id), - solar: I.galaxyOrbitGroups(nodes).get('related-star').nodes.map(node => node.id), - }); - """ - ) - assert report == { - "linkCount": 1, - "core": ["black-hole"], - "solar": ["related-star"], - } - - -@requires_node -def test_explicit_black_hole_parent_keeps_a_complete_solar_system_in_the_core_frame() -> None: - """The server-authored parent chain, not a relation label, defines orbital hierarchy.""" - report = _run_node( - """ - const make = () => [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 64, radius: 9, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'linked-star', anchor_role: 'community', community_id: 'solar', - system_anchor_id: 'black-hole', gravity_mass: 8, radius: 5, - x: 72, y: 0, vx: 0, vy: 0 }, - { id: 'linked-planet', community_id: 'solar', - system_anchor_id: 'linked-star', gravity_mass: 1, radius: 2.5, - x: 88, y: 0, vx: 0, vy: 0 }, - { id: 'free-star', anchor_role: 'community', community_id: 'free', - system_anchor_id: 'free-star', gravity_mass: 8, radius: 5, - x: -96, y: 0, vx: 0, vy: 0 }, - { id: 'free-planet', community_id: 'free', - system_anchor_id: 'free-star', gravity_mass: 1, radius: 2.5, - x: -112, y: 0, vx: 0, vy: 0 }, - ]; - const delta = (next, previous) => Math.atan2(Math.sin(next - previous), - Math.cos(next - previous)); - const run = kinematic => { - const nodes = make(); - const options = { - layoutSeed: 1901, gravity: 48, softening: 32, centralSoftening: 40, - localSoftening: 40, orbitalSpeed: 48, timestep: .032, - includeMutualSystems: false, includeRelations: false, - includeOrbitalSeparation: false, includeSystemPacking: false, - includeBlackHoleExclusion: false, includeFarFieldConfinement: false, - includeCollisions: false, speedLimit: 48, localRelativeSpeedLimit: 48, - }; - I.seedGalaxyOrbits(nodes, 1901, 48, 32, false, options); - I.seedGalaxySystemOrbits(nodes, 1901, 48, 40, false, options); - const linked = nodes[1], free = nodes[3]; - let linkedTravel = 0, freeTravel = 0; - for (let step = 0; step < 120; step++) { - const linkedBefore = Math.atan2(linked.y, linked.x); - const freeBefore = Math.atan2(free.y, free.x); - if (kinematic) I.advanceGalaxyKinematicOrbits(nodes, options); - else { - I.integrateGalaxyLeapfrog(nodes, [], [], options); - I.applyGalaxyOrbitalSpeedControl(nodes, options); - } - linkedTravel += Math.abs(delta(Math.atan2(linked.y, linked.x), linkedBefore)); - freeTravel += Math.abs(delta(Math.atan2(free.y, free.x), freeBefore)); - } - return { - linkedTravel, freeTravel, - blackHoleGroup: I.galaxyOrbitGroups(nodes).get('black-hole') - .nodes.map(node => node.id), - solarGroup: I.galaxyOrbitGroups(nodes).get('linked-star')?.nodes - .map(node => node.id) || [], - markedAsBlackHoleChild: nodes[1].__galaxyBlackHoleChild === true, - localDistance: Math.hypot(nodes[2].x - linked.x, nodes[2].y - linked.y), - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)), - }; - }; - emit({ live: run(false), kinematic: run(true) }); - """ - ) - for mode in ("live", "kinematic"): - result = report[mode] - assert result["finite"] is True - assert result["linkedTravel"] > 0.1, result - assert result["freeTravel"] > 0.1, result - assert result["localDistance"] > 10, result - assert set(result["blackHoleGroup"]) == { - "black-hole", "linked-star", "linked-planet", - } - assert result["solarGroup"] == [] - assert result["markedAsBlackHoleChild"] is False - - -@requires_node -def test_explicit_black_hole_parent_moves_community_anchors_and_their_planets() -> None: - report = _run_node( - """ - const fixture = () => [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - system_anchor_id: 'black-hole', gravity_mass: 64, radius: 9, - x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'community-child', anchor_role: 'community', community_id: 'solar', - system_anchor_id: 'black-hole', gravity_mass: 8, radius: 5, - x: 72, y: 0, vx: 0, vy: 0 }, - { id: 'planet', community_id: 'solar', system_anchor_id: 'community-child', - orbit_tier: 1, gravity_mass: 1, radius: 2, - x: 88, y: 0, vx: 0, vy: 0 }, - ]; - const trial = orbitalSpeed => { - const nodes = fixture(); - I.seedGalaxyOrbits(nodes, 81, 48, 32, false, { orbitalSpeed }); - let travel = 0; - for (let step = 0; step < 30; step += 1) { - const before = Math.atan2(nodes[1].y, nodes[1].x); - I.supportGalaxyCarrierOrbits(nodes, { - gravity: 48, softening: 32, centralSoftening: 40, - orbitalSpeed, layoutSeed: 81, timestep: .032, - }); - const after = Math.atan2(nodes[1].y, nodes[1].x); - travel += Math.abs(Math.atan2(Math.sin(after - before), Math.cos(after - before))); - } - return { travel, grouped: I.galaxyOrbitGroups(nodes).get('black-hole'), - localDistance: Math.hypot(nodes[2].x - nodes[1].x, nodes[2].y - nodes[1].y) }; - }; - const kinematicTrial = orbitalSpeed => { - const nodes = fixture(); - I.seedGalaxyOrbits(nodes, 81, 48, 32, false, { orbitalSpeed }); - let travel = 0; - for (let step = 0; step < 30; step += 1) { - const before = Math.atan2(nodes[1].y, nodes[1].x); - I.advanceGalaxyKinematicOrbits(nodes, { - gravity: 48, softening: 32, centralSoftening: 40, - orbitalSpeed, layoutSeed: 81, timestep: .032, - }); - const after = Math.atan2(nodes[1].y, nodes[1].x); - travel += Math.abs(Math.atan2(Math.sin(after - before), Math.cos(after - before))); - } - return { travel, grouped: I.galaxyOrbitGroups(nodes).get('black-hole'), - localDistance: Math.hypot(nodes[2].x - nodes[1].x, nodes[2].y - nodes[1].y) }; - }; - const slow = trial(100), fast = trial(400); - const slowKinematic = kinematicTrial(100), fastKinematic = kinematicTrial(400); - emit({ slow: { travel: slow.travel, - grouped: slow.grouped && slow.grouped.nodes.map(node => node.id), - localDistance: slow.localDistance }, - fast: { travel: fast.travel, - grouped: fast.grouped && fast.grouped.nodes.map(node => node.id), - localDistance: fast.localDistance }, - slowKinematic: { travel: slowKinematic.travel, - grouped: slowKinematic.grouped && slowKinematic.grouped.nodes.map(node => node.id), - localDistance: slowKinematic.localDistance }, - fastKinematic: { travel: fastKinematic.travel, - grouped: fastKinematic.grouped && fastKinematic.grouped.nodes.map(node => node.id), - localDistance: fastKinematic.localDistance }, - ratio: fast.travel / slow.travel, - kinematicRatio: fastKinematic.travel / slowKinematic.travel }); - """ - ) - assert report["slow"]["travel"] > 0 - assert report["fast"]["travel"] > report["slow"]["travel"] - assert report["ratio"] == pytest.approx(2.5, rel=0.03) - assert report["slow"]["grouped"] == ["black-hole", "community-child", "planet"] - assert report["fast"]["grouped"] == ["black-hole", "community-child", "planet"] - assert report["slow"]["localDistance"] > 14 - # The fast endpoint is allowed to widen the local orbit modestly; it must not detach the - # planet from the same moving community system or collapse the local band. - assert report["fast"]["localDistance"] > report["slow"]["localDistance"] - assert report["fast"]["localDistance"] < 22 - assert report["slowKinematic"]["travel"] > 0 - assert report["fastKinematic"]["travel"] > report["slowKinematic"]["travel"] - assert report["kinematicRatio"] > 1.8 - assert report["slowKinematic"]["grouped"] == ["black-hole", "community-child", "planet"] - assert report["fastKinematic"]["grouped"] == ["black-hole", "community-child", "planet"] - assert report["fastKinematic"]["localDistance"] > report["slowKinematic"]["localDistance"] - - -@requires_node -def test_carrier_support_adopts_post_contact_phase_without_snapback() -> None: - report = _run_node( - """ - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 64, radius: 8, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'child', community_id: 'core', system_anchor_id: 'black-hole', - gravity_mass: 2, radius: 3, x: 50 * Math.cos(.4), y: 50 * Math.sin(.4), - vx: 0, vy: 0 }, - ]; - Object.defineProperty(nodes[1], '__galaxyCoreLaneRadius', { - value: 50, writable: true, configurable: true, enumerable: false, - }); - Object.defineProperty(nodes[1], '__galaxyCoreLaneAngle', { - value: 0, writable: true, configurable: true, enumerable: false, - }); - const before = Math.atan2(nodes[1].y, nodes[1].x); - I.supportGalaxyCarrierOrbits(nodes, { - gravity: 48, softening: 32, centralSoftening: 40, - orbitalSpeed: 100, layoutSeed: 11, timestep: .032, - }); - const after = Math.atan2(nodes[1].y, nodes[1].x); - emit({ before, after, step: after - before, - laneAngle: nodes[1].__galaxyCoreLaneAngle }); - """ - ) - assert report["before"] == pytest.approx(0.4, abs=1e-12) - assert report["after"] == pytest.approx(report["before"], abs=0.1) - assert report["after"] > 0.3 - assert abs(report["step"]) < 0.1 - assert report["laneAngle"] == pytest.approx(report["after"], abs=1e-12) - - -@requires_node -def test_managed_carrier_ring_preserves_phase_spacing_after_force_kicks() -> None: - """Admitted systems on one ring must co-rotate instead of adopting divergent force phase.""" - report = _run_node( - """ - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - system_anchor_id: 'black-hole', gravity_mass: 64, radius: 8, - x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'star-a', anchor_role: 'community', community_id: 'a', - system_anchor_id: 'star-a', gravity_mass: 8, radius: 5, - x: 80, y: 0, vx: 0, vy: 0 }, - { id: 'planet-a', community_id: 'a', system_anchor_id: 'star-a', - orbit_radius: 18, gravity_mass: 1, radius: 2, - x: 98, y: 0, vx: 0, vy: 0 }, - { id: 'star-b', anchor_role: 'community', community_id: 'b', - system_anchor_id: 'star-b', gravity_mass: 8, radius: 5, - x: -80, y: 0, vx: 0, vy: 0 }, - { id: 'planet-b', community_id: 'b', system_anchor_id: 'star-b', - orbit_radius: 18, gravity_mass: 1, radius: 2, - x: -98, y: 0, vx: 0, vy: 0 }, - ]; - I.establishGalaxyCarrierLanes(nodes, { gap: 4, layoutSeed: 41 }); - const stars = [nodes[1], nodes[3]]; - const initial = stars.map(node => ({ radius: node.__galaxyCarrierLaneRadius, - angle: node.__galaxyCarrierLaneAngle, managed: node.__galaxyCarrierLaneManaged })); - const rotateGroup = (star, planet, offset) => { - const localX = planet.x - star.x, localY = planet.y - star.y; - const radius = star.__galaxyCarrierLaneRadius; - const targetAngle = star.__galaxyCarrierLaneAngle + offset; - star.x = Math.cos(targetAngle) * radius; - star.y = Math.sin(targetAngle) * radius; - planet.x = star.x + localX; planet.y = star.y + localY; - }; - rotateGroup(nodes[1], nodes[2], .55); - rotateGroup(nodes[3], nodes[4], -.37); - I.supportGalaxyCarrierOrbits(nodes, { - gravity: 48, softening: 32, centralSoftening: 40, - orbitalSpeed: 100, layoutSeed: 41, timestep: .032, - authoritativeCarrierPosition: true, - }); - const after = stars.map(node => ({ radius: Math.hypot(node.x, node.y), - angle: Math.atan2(node.y, node.x), laneAngle: node.__galaxyCarrierLaneAngle })); - const delta = (left, right) => Math.atan2(Math.sin(right - left), - Math.cos(right - left)); - const field = I.galaxyBlackHoleField(nodes, { - gravity: 48, softening: 32, centralSoftening: 40, - }); - emit({ initial, after, - carrierSpeedGain: I.galaxyAuthoredCarrierTargetSpeed( - field, initial[0].radius, 100 - ) / I.galaxyCarrierTargetSpeed(field, initial[0].radius, 100), - initialSpacing: delta(initial[0].angle, initial[1].angle), - finalSpacing: delta(after[0].angle, after[1].angle), - localDistances: [Math.hypot(nodes[2].x - nodes[1].x, nodes[2].y - nodes[1].y), - Math.hypot(nodes[4].x - nodes[3].x, nodes[4].y - nodes[3].y)] }); - """ - ) - assert all(item["managed"] is True for item in report["initial"]) - assert report["initial"][0]["radius"] == pytest.approx( - report["initial"][1]["radius"], abs=1e-12 - ) - assert math.sin(report["finalSpacing"]) == pytest.approx( - math.sin(report["initialSpacing"]), abs=1e-12 - ) - assert math.cos(report["finalSpacing"]) == pytest.approx( - math.cos(report["initialSpacing"]), abs=1e-12 - ) - assert report["carrierSpeedGain"] == pytest.approx(1.3) - assert all(distance == pytest.approx(18, abs=1e-12) for distance in report["localDistances"]) - - -@requires_node -def test_live_carrier_support_rotates_without_a_preseeded_lane_cache() -> None: - """Filtered/reloaded live scenes must still visibly orbit instead of only gaining velocity.""" - report = _run_node( - """ - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 64, radius: 8, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'star', anchor_role: 'community', community_id: 'solar', - system_anchor_id: 'star', gravity_mass: 8, radius: 5, - x: 120, y: 0, vx: 0, vy: 0 }, - { id: 'planet', community_id: 'solar', system_anchor_id: 'star', - gravity_mass: 1, radius: 2, x: 135, y: 0, vx: 0, vy: 0 }, - ]; - const options = { - gravity: 48, softening: 32, centralSoftening: 40, - orbitalSpeed: 100, layoutSeed: 19, timestep: .032, - authoritativeCarrierPosition: true, - }; - const before = Math.atan2(nodes[1].y, nodes[1].x); - I.supportGalaxyCarrierOrbits(nodes, options); - const first = { - angle: Math.atan2(nodes[1].y, nodes[1].x), - radius: Math.hypot(nodes[1].x, nodes[1].y), - localDistance: Math.hypot(nodes[2].x - nodes[1].x, nodes[2].y - nodes[1].y), - }; - /* Simulate a force kick after the cache was admitted. The next support pass must - restore the original painted lane, not expand it to follow that escaped position. */ - nodes[1].x += 80; - nodes[2].x += 80; - I.supportGalaxyCarrierOrbits(nodes, options); - emit({ - before, first, - second: { - angle: Math.atan2(nodes[1].y, nodes[1].x), - radius: Math.hypot(nodes[1].x, nodes[1].y), - localDistance: Math.hypot(nodes[2].x - nodes[1].x, nodes[2].y - nodes[1].y), - }, - cachedRadius: nodes[1].__galaxyCarrierLaneRadius, - }); - """ - ) - assert report["first"]["angle"] != pytest.approx(report["before"], abs=1e-12) - assert report["first"]["radius"] == pytest.approx(120, abs=1e-9) - assert report["second"]["radius"] == pytest.approx(report["cachedRadius"], abs=1e-9) - assert report["second"]["radius"] == pytest.approx(120, abs=1e-9) - assert report["second"]["localDistance"] == pytest.approx(report["first"]["localDistance"], abs=1e-9) - - -@requires_node -def test_system_velocity_guard_preserves_black_hole_carrier_before_local_motion() -> None: - report = _run_node( - """ - const nodes = [ - { id: 'star', anchor_role: 'community', community_id: 'solar', - gravity_mass: 8, x: 120, y: 0, vx: 0, vy: 18 }, - { id: 'planet', community_id: 'solar', system_anchor_id: 'star', - gravity_mass: 1, x: 135, y: 0, vx: 0, vy: -30 }, - ]; - const beforeCarrier = { vx: nodes[0].vx, vy: nodes[0].vy }; - const guard = I.stabilizeGalaxySystemVelocities(nodes, { - limit: 48, absoluteLimit: 50, - }); - emit({ beforeCarrier, afterCarrier: { vx: nodes[0].vx, vy: nodes[0].vy }, - planetSpeed: Math.hypot(nodes[1].vx, nodes[1].vy), - localSpeed: Math.hypot(nodes[1].vx - nodes[0].vx, - nodes[1].vy - nodes[0].vy), guard }); - """ - ) - assert report["afterCarrier"] == pytest.approx(report["beforeCarrier"], abs=1e-12) - assert report["planetSpeed"] <= 50 + 1e-12 - assert report["localSpeed"] <= 32 + 1e-12 - assert report["guard"]["systems"] == 1 - - -@requires_node -def test_black_hole_field_is_twice_local_gravity_and_uses_only_anchor_mass() -> None: - report = _run_node( - """ - const local = [ - { id: 'star', community_id: 'solar', gravity_mass: 8, - x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'planet', community_id: 'solar', gravity_mass: 1, - x: 120, y: 0, vx: 0, vy: 0 }, - ]; - I.applyGalaxyGravity(local, { gravity: 48, softening: 40, alpha: 1 }); - const central = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 8, x: 0, y: 0 }, - { id: 'outer', community_id: 'outer', gravity_mass: 1, x: 120, y: 0 }, - ]; - const centralField = I.galaxyBlackHoleField(central, { - gravity: 48, softening: 40, haloScale: 1e9, accelerationCap: 1e9, - }); - const withBulge = I.galaxyBlackHoleField([ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 8, x: 0, y: 0 }, - { id: 'bulge', community_id: 'core', gravity_mass: 100, x: 5, y: 0 }, - { id: 'outer', community_id: 'outer', gravity_mass: 1, x: 120, y: 0 }, - ], { gravity: 48, softening: 40, accelerationCap: 1e9 }); - emit({ - constants: [I.galaxyBlackHoleGravityConstant(48), - I.galaxyLocalGravityConstant(48)], - accelerationRatio: Math.abs(centralField.systems[0].ax / local[1].vx), - masses: [withBulge.coreMass, withBulge.haloMass, withBulge.totalMass], - }); - """ - ) - assert report["constants"] == [480, 240] - assert report["accelerationRatio"] == pytest.approx(2, rel=1e-12) - assert report["masses"] == [8, 101, 109] - - -@requires_node -def test_spacetime_field_tuning_is_softened_precessing_and_preserves_local_frames() -> None: - """Advanced black-hole controls alter one softened carrier field, never a planet's frame. - - The near-horizon pass must add a finite Lense--Thirring-like tangent and expose a smooth - visual warp. An external solar system receives that carrier delta as a unit, which is the - important physical invariant: its planets keep orbiting their star while the whole system - precesses around the black hole. The decay pass is intentionally tangential-only and must - likewise leave the star-relative velocity unchanged. - """ - report = _run_node( - """ - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 64, radius: 10, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'star', anchor_role: 'community', community_id: 'solar', - system_anchor_id: 'star', gravity_mass: 8, radius: 4, - x: 26, y: 0, vx: 0, vy: 3.2 }, - { id: 'planet', community_id: 'solar', system_anchor_id: 'star', - gravity_mass: 1, radius: 2, x: 32, y: 0, vx: -1.1, vy: 4.6 }, - ]; - const local = () => ({ - vx: nodes[2].vx - nodes[1].vx, - vy: nodes[2].vy - nodes[1].vy, - }); - const baseline = I.galaxyBlackHoleField(nodes, { - gravity: 48, softening: 40, gravitationalConstant: 1, blackHoleMass: 1, - accelerationCap: 1e9, - }); - const tuned = I.galaxyBlackHoleField(nodes, { - gravity: 48, softening: 40, gravitationalConstant: 2, blackHoleMass: 3, - accelerationCap: 1e9, - }); - const before = local(); - const spacetime = I.applyGalaxySpacetimeAcceleration(nodes, { - gravity: 48, softening: 40, gravitationalConstant: 2, blackHoleMass: 3, - blackHoleExclusionPadding: 2.5, frameDraggingFraction: .04, - frameDraggingMaxAcceleration: .5, eventHorizonInwardAcceleration: .35, - }); - const afterDrag = local(); - const decay = I.applyGalaxyEventHorizonDecay(nodes, { - timestep: .032, eventHorizonDecayRate: .25, - }); - const afterDecay = local(); - emit({ baseline: { core: baseline.coreMass, gravity: baseline.gravitationalConstant }, - tuned: { core: tuned.coreMass, gravity: tuned.gravitationalConstant }, - before, afterDrag, afterDecay, spacetime, decay, - warp: [nodes[1].__galaxySpacetimeWarp, nodes[2].__galaxySpacetimeWarp], - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), - }); - """ - ) - assert report["finite"] is True - assert report["tuned"]["core"] == pytest.approx(report["baseline"]["core"] * 3) - assert report["tuned"]["gravity"] == pytest.approx(report["baseline"]["gravity"] * 2 * 3 ** 0.5) - assert report["spacetime"]["systems"] == 1 - assert report["spacetime"]["warpedNodes"] == 2 - assert report["spacetime"]["maximumWarp"] > 0 - assert report["spacetime"]["maximumFrameDragAcceleration"] > 0 - assert report["spacetime"]["maximumHorizonAcceleration"] > 0 - assert max(report["warp"]) > 0 - # Carrier-only perturbations are identical for every body in the system. - assert report["afterDrag"] == pytest.approx(report["before"], abs=1e-12) - assert report["decay"]["systems"] == 1 - assert report["decay"]["maximumVelocityRemoved"] > 0 - assert report["afterDecay"] == pytest.approx(report["before"], abs=1e-12) - - -@requires_node -def test_black_hole_mass_adds_ten_percent_core_gravity_per_tenth_multiplier() -> None: - report = _run_node( - """ - const make = () => [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 80, radius: 10, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'outer-star', anchor_role: 'community', community_id: 'outer', - system_anchor_id: 'outer-star', gravity_mass: 8, radius: 5, - x: 180, y: 0, vx: 0, vy: 0 }, - ]; - const sample = blackHoleMass => { - const field = I.galaxyBlackHoleField(make(), { - gravity: 48, gravitationalConstant: 1, blackHoleMass, - softening: 40, haloScale: 1e9, accelerationCap: 1e9, - }); - return { - coreMass: field.coreMass, - coreGravity: field.coreMass * field.gravitationalConstant, - haloMass: field.haloMass, - gravitationalConstant: field.gravitationalConstant, - }; - }; - emit({ baseline: sample(1), plusTen: sample(1.1), plusTwenty: sample(1.2) }); - """ - ) - - baseline = report["baseline"] - assert report["plusTen"]["coreGravity"] == pytest.approx( - baseline["coreGravity"] * 1.1 * 1.1 ** 0.5 - ) - assert report["plusTwenty"]["coreGravity"] == pytest.approx( - baseline["coreGravity"] * 1.2 * 1.2 ** 0.5 - ) - for sample in report.values(): - assert sample["haloMass"] == baseline["haloMass"] - # gravitationalConstant now scales with sqrt(blackHoleMassMultiplier) - assert report["plusTen"]["gravitationalConstant"] == pytest.approx( - baseline["gravitationalConstant"] * 1.1 ** 0.5 - ) - assert report["plusTwenty"]["gravitationalConstant"] == pytest.approx( - baseline["gravitationalConstant"] * 1.2 ** 0.5 - ) - - -@requires_node -def test_hierarchical_center_and_star_g_have_exact_velocity_superposition() -> None: - """G_center moves the star carrier; G_star only changes the planet's local tangent.""" - report = _run_node( - """ - const make = () => [ - { id: 'arbitrary-singularity-orbit-root', anchor_role: 'global', community_id: 'core', - gravity_mass: 64, radius: 9, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'Users', anchor_role: 'community', community_id: 'users', system_anchor_id: 'Users', - gravity_mass: 10, radius: 5, x: 168, y: 24, vx: 0, vy: 0 }, - { id: 'Pre-PR', community_id: 'users', system_anchor_id: 'Users', orbit_tier: 1, - gravity_mass: 1, radius: 2.5, x: 198, y: 24, vx: 0, vy: 0 }, - ]; - const run = (centerG, starG) => { - const nodes = make(), star = nodes[1], planet = nodes[2]; - I.seedGalaxyOrbits(nodes, 118, 48, 32, false, - { gravitationalConstant: centerG, localGravitationalConstant: starG }); - I.seedGalaxySystemOrbits(nodes, 118, 48, 40, false, - { gravitationalConstant: centerG, localGravitationalConstant: starG }); - const local = { vx: planet.vx - star.vx, vy: planet.vy - star.vy }; - const dx = planet.x - star.x, dy = planet.y - star.y; - return { carrier: { vx: star.vx, vy: star.vy }, local, - sumError: Math.hypot(planet.vx - (star.vx + local.vx), - planet.vy - (star.vy + local.vy)), - tangent: dx * local.vy - dy * local.vx, - radial: dx * local.vx + dy * local.vy, - localSpeed: Math.hypot(local.vx, local.vy), - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), - }; - }; - const explicitRoleWins = I.galaxyGlobalAnchor([ - { id: 'arbitrary-singularity-orbit-root', anchor_role: 'global', gravity_mass: 1, x: 0, y: 0 }, - { id: 'Coding-Dev-Tools', gravity_mass: 999, x: 1, y: 0 }, - ]).id; - const massFallbackWins = I.galaxyGlobalAnchor([ - { id: 'small-ordinary', gravity_mass: 4, x: 0, y: 0 }, - { id: 'largest-ordinary', gravity_mass: 12, x: 1, y: 0 }, - ]).id; - emit({ base: run(1, 1), centerOnly: run(2, 1), starOnly: run(1, 2), - explicitRoleWins, massFallbackWins }); - """ - ) - for sample in (report["base"], report["centerOnly"], report["starOnly"]): - assert sample["finite"] is True - assert sample["sumError"] < 1e-12 - assert abs(sample["tangent"]) > 1e-5 - assert abs(sample["radial"]) < 1e-8 - # A center-only change changes the black-hole carrier, while a star-only change leaves it. - assert report["centerOnly"]["carrier"] != pytest.approx(report["base"]["carrier"], abs=1e-8) - assert report["starOnly"]["carrier"] == pytest.approx(report["base"]["carrier"], abs=1e-10) - assert report["centerOnly"]["localSpeed"] == pytest.approx(report["base"]["localSpeed"], rel=1e-10) - assert report["starOnly"]["localSpeed"] > report["base"]["localSpeed"] * 1.35 - assert report["explicitRoleWins"] == "arbitrary-singularity-orbit-root" - assert report["massFallbackWins"] == "largest-ordinary" - - -@requires_node -def test_arbitrary_global_label_and_community_stars_keep_nested_orbits() -> None: - """An arbitrary central label supports the same Users/Pre-PR nested hierarchy.""" - report = _run_node( - """ - const nodes = [ - { id: 'workspace-orbit-root', anchor_role: 'global', community_id: 'core', - gravity_mass: 80, radius: 10, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'Users', anchor_role: 'community', community_id: 'users', system_anchor_id: 'Users', - gravity_mass: 10, radius: 5, x: 160, y: 20, vx: 0, vy: 0 }, - { id: 'users-planet', community_id: 'users', system_anchor_id: 'Users', orbit_tier: 1, - gravity_mass: 1, radius: 2, x: 188, y: 20, vx: 0, vy: 0 }, - { id: 'Pre-PR', anchor_role: 'community', community_id: 'pre-pr', system_anchor_id: 'Pre-PR', - gravity_mass: 9, radius: 5, x: -142, y: 34, vx: 0, vy: 0 }, - { id: 'pre-pr-planet', community_id: 'pre-pr', system_anchor_id: 'Pre-PR', orbit_tier: 1, - gravity_mass: 1, radius: 2, x: -116, y: 34, vx: 0, vy: 0 }, - ]; - I.seedGalaxyOrbits(nodes, 71, 48, 32, false, - { gravitationalConstant: 1, localGravitationalConstant: 1 }); - I.seedGalaxySystemOrbits(nodes, 71, 48, 40, false, - { gravitationalConstant: 1, localGravitationalConstant: 1 }); - const byId = new Map(nodes.map(node => [node.id, node])); - const local = (starId, planetId) => { - const star = byId.get(starId), planet = byId.get(planetId); - const dx = planet.x - star.x, dy = planet.y - star.y; - const vx = planet.vx - star.vx, vy = planet.vy - star.vy; - return { anchor: star.system_anchor_id, - tangent: dx * vy - dy * vx, radial: dx * vx + dy * vy }; - }; - emit({ global: I.galaxyGlobalAnchor(nodes).id, - users: local('Users', 'users-planet'), prePr: local('Pre-PR', 'pre-pr-planet') }); - """ - ) - assert report["global"] == "workspace-orbit-root" - for system, star_id in ((report["users"], "Users"), (report["prePr"], "Pre-PR")): - assert system["anchor"] == star_id - assert abs(system["tangent"]) > 1e-5 - assert abs(system["radial"]) < 1e-8 - - -@requires_node -def test_horizon_warp_is_carrier_only_and_never_adds_planet_black_hole_physics() -> None: - """Near-horizon effects translate a complete solar system without a per-planet tide.""" - report = _run_node( - """ - const make = radius => [ - { id: 'custom-heavy-center-δ', anchor_role: 'global', community_id: 'core', - gravity_mass: 64, radius: 10, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'star', anchor_role: 'community', community_id: 'solar', system_anchor_id: 'star', - gravity_mass: 9, radius: 4, x: radius, y: 0, vx: 0, vy: 2 }, - { id: 'radial-planet', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 1, - gravity_mass: 1, radius: 2, x: radius + 12, y: 0, vx: 0, vy: 3 }, - { id: 'tangent-planet', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 2, - gravity_mass: 1, radius: 2, x: radius, y: 12, vx: -1, vy: 2 }, - ]; - const sample = radius => { - const nodes = make(radius); - const stats = I.applyGalaxySpacetimeAcceleration(nodes, { - gravity: 48, gravitationalConstant: 1, blackHoleMass: 1, softening: 16, - blackHoleExclusionPadding: 2.5, tidalStrengthFraction: .18, - tidalAccelerationCap: .16, frameDraggingFraction: .018, - }); - const changes = nodes.map(node => stats.accelerations.get(node) || { ax: 0, ay: 0 }); - return { stats, changes, warp: nodes.slice(1).map(node => node.__galaxySpacetimeWarp), - finite: nodes.every(node => [node.x,node.y,node.vx,node.vy].every(Number.isFinite)) }; - }; - emit({ near: sample(22), far: sample(180) }); - """ - ) - near, far = report["near"], report["far"] - assert near["finite"] is far["finite"] is True - assert near["stats"]["tidalSystems"] == near["stats"]["tidalPlanets"] == 0 - assert near["stats"]["maximumTidalAcceleration"] == 0 - # Every descendant inherits exactly the star's black-hole-frame acceleration. - assert abs(near["changes"][1]["ax"]) + abs(near["changes"][1]["ay"]) > 0 - assert near["changes"][2] == pytest.approx(near["changes"][1], abs=1e-12) - assert near["changes"][3] == pytest.approx(near["changes"][1], abs=1e-12) - assert max(near["warp"]) > 0 - assert far["stats"]["tidalSystems"] == far["stats"]["tidalPlanets"] == 0 - assert far["stats"]["maximumTidalAcceleration"] == 0 - assert max(far["warp"]) == 0 - - -@requires_node -def test_slingshot_capture_preserves_authored_star_and_high_speed_release_escapes() -> None: - """Sub-escape drag releases enter a star orbit; genuine escape releases stay untouched.""" - report = _run_node( - """ - const nodes = [ - { id: 'custom-heavy-center-ζ', anchor_role: 'global', community_id: 'core', - gravity_mass: 64, radius: 9, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'Users', anchor_role: 'community', community_id: 'users', system_anchor_id: 'Users', - gravity_mass: 10, radius: 5, x: 80, y: 0, vx: 2, vy: -1 }, - { id: 'users-planet', community_id: 'users', system_anchor_id: 'Users', orbit_tier: 1, - gravity_mass: 1, radius: 2, x: 105, y: 0, vx: 0, vy: 0 }, - ]; - const planet = nodes[2], before = { anchor: planet.system_anchor_id, community: planet.community_id }; - const options = { gravity: 48, localGravitationalConstant: 1, softening: 16, - layoutSeed: 19, captureRadius: 120 }; - const captured = I.galaxySlingshotCapture(planet, nodes, { vx: 2, vy: -1 }, options); - const escaped = I.galaxySlingshotCapture(planet, nodes, { vx: 100, vy: -1 }, options); - emit({ captured, escaped, before, after: { anchor: planet.system_anchor_id, - community: planet.community_id }, finite: [captured, escaped].every(value => - [value.vx, value.vy, value.circularSpeed, value.escapeSpeed].every(Number.isFinite)) }); - """ - ) - assert report["finite"] is True - assert report["before"] == report["after"] == {"anchor": "Users", "community": "users"} - captured, escaped = report["captured"], report["escaped"] - assert captured["eligible"] is True and captured["captured"] is True and captured["escaped"] is False - assert captured["reason"] == "authored-anchor" and captured["starId"] == "Users" - assert captured["radius"] == pytest.approx(25) - assert 0 < captured["circularSpeed"] < captured["escapeSpeed"] - assert escaped["eligible"] is True and escaped["captured"] is False and escaped["escaped"] is True - assert escaped["reason"] == "escape-velocity" - assert [escaped["vx"], escaped["vy"]] == pytest.approx([100, -1]) - - -@requires_node -def test_spacetime_canvas_warps_the_grid_and_bounds_trails_without_dom_nodes() -> None: - """The visual layer is one bounded canvas, not a hidden second graph implementation.""" - report = _run_spacetime_node( - """ - const calls = { arcs: 0, ellipses: 0, lines: 0, gradients: 0, linearGradients: 0 }; - const gradient = { addColorStop() {} }; - const ctx = { - setTransform() {}, clearRect() {}, save() {}, restore() {}, beginPath() {}, - moveTo() { calls.lines++; }, lineTo() { calls.lines++; }, stroke() {}, fill() {}, - arc() { calls.arcs++; }, ellipse() { calls.ellipses++; }, - createRadialGradient() { calls.gradients++; return gradient; }, - createLinearGradient() { calls.linearGradients++; return gradient; }, - set globalCompositeOperation(value) {}, set lineWidth(value) {}, - set strokeStyle(value) {}, set fillStyle(value) {}, - }; - const frames = []; - globalThis.requestAnimationFrame = callback => { frames.push(callback); return frames.length; }; - globalThis.cancelAnimationFrame = () => {}; - let reduceMotion = false; - globalThis.matchMedia = () => ({ matches: reduceMotion }); - globalThis.window = { devicePixelRatio: 1 }; - const documentListeners = {}; - globalThis.document = { hidden: false, - addEventListener(type, callback) { documentListeners[type] = callback; }, - removeEventListener(type) { delete documentListeners[type]; }, - createElement() { return { - width: 0, height: 0, className: '', setAttribute() {}, remove() {}, - getContext() { return ctx; }, - }; } }; - const listeners = {}; - const container = { - clientWidth: 900, clientHeight: 600, children: [], - appendChild(node) { this.children.push(node); }, - addEventListener(type, callback) { listeners[type] = callback; }, - removeEventListener(type) { delete listeners[type]; }, - }; - const snapshot = count => ({ - center: { x: 0, y: 0, radius: 11 }, - nodes: Array.from({ length: count }, (_, index) => ({ - id: 'node-' + index, x: 32 + index, y: index % 19, - vx: 1 + index / 10, vy: .5, radius: 2, - })), - systemAnchors: Array.from({ length: 30 }, (_, index) => ({ - id: 'star-' + index, x: 50 + index * 18, y: index % 4 * 12, - radius: 4, mass: 40 - index, orbitRadius: 26, - })), - viewport: { x: 450, y: 300, zoom: 1 }, - }); - let current = snapshot(180); - const engine = { - getPhysicsSnapshot: () => current, - graphToScreen: (x, y) => ({ x: x + 450, y: y + 300 }), - }; - new Function('window', source)(window); - const overlay = window.EngraphisSpacetime.create(container, engine); - overlay.setEnabled(true); - frames.shift()(40); // samples the 160 fastest bodies - frames.shift()(80); // paints their trails - const small = { ...calls, canvasCount: container.children.length }; - reduceMotion = true; - frames.shift()(96); // local wells stay visible; trails do not repaint under reduced motion - const reduced = { ...calls, queued: frames.length }; - current = snapshot(601); - reduceMotion = false; - frames.shift()(120); - const dense = { ...calls }; - current = { ...snapshot(180), paused: true }; - frames.shift()(160); // final static paint, then no idle orbit overlay rAF - const paused = { queued: frames.length, ellipses: calls.ellipses }; - overlay.destroy(); - emit({ small, reduced, dense, paused, childrenAfterDestroy: container.children.length, - listenerDetached: !listeners.engraphisgraphphysicschange, - visibilityDetached: !documentListeners.visibilitychange }); - """ - ) - assert report["small"]["canvasCount"] == 1 - assert report["small"]["arcs"] > 0 and report["small"]["lines"] > 0 - # Both sampled frames paint the 24 highest-mass local stars, with two guide rings each. - assert report["small"]["ellipses"] == 24 * 2 * 2 - # Reduced motion removes velocity blur, not the static local solar-system guide rings. - assert report["reduced"]["ellipses"] == report["small"]["ellipses"] + 24 * 2 - # One capped canvas pass renders at most the 160 selected velocity trails; a >600-node - # graph clears them rather than paying a linear trail cost in the next paint. - assert 0 < report["small"]["linearGradients"] <= 160 - assert report["dense"]["linearGradients"] == report["small"]["linearGradients"] - assert report["paused"]["queued"] == 0 - assert report["listenerDetached"] is True - assert report["visibilityDetached"] is True - - -@requires_node -def test_advanced_spacetime_controls_pause_live_orbits_and_drag_release_is_bounded() -> None: - """The public controls drive one observable physics state, including slingshot release.""" - report = _run_engine( - """ - let released = null; - const api = G.create(el, { onSlingshotRelease: value => { released = value; } }); - api.setData({ nodes: [ - { id: 'custom-heavy-center-kappa', anchor_role: 'global', community_id: 'core', gravity_mass: 32, - radius: 8, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'Coding-Dev-Tools', community_id: 'decoy', gravity_mass: 999, - radius: 5, x: -140, y: 0, vx: 0, vy: 0 }, - { id: 'Users', anchor_role: 'community', community_id: 'users', system_anchor_id: 'Users', - gravity_mass: 9, radius: 5, x: 92, y: 0, vx: 0, vy: 0 }, - { id: 'users-planet', community_id: 'users', system_anchor_id: 'Users', orbit_tier: 1, - gravity_mass: 1, radius: 2, x: 118, y: 0, vx: 0, vy: 0 }, - { id: 'dragged', community_id: 'outer', gravity_mass: 2, - radius: 4, x: 60, y: 0, vx: 0, vy: 0 }, - ], edges: [] }); - api.setSettings({ gravitationalConstant: 1.75, blackHoleMass: 3.5, - localGravitationalConstant: 2.25, damping: .4, springStiffness: 2.25, orbitPaused: true }); - const paused = { state: JSON.parse(JSON.stringify(api.state().settings)), diagnostics: api.physicsDiagnostics(), - snapshot: api.getPhysicsSnapshot() }; - api.setSettings({ G_star: 1.4, orbitPaused: false }); - const node = store.graphData.nodes.find(item => item.id === 'dragged'); - store.screen2GraphCoords = (x, y) => ({ x, y }); - const event = (x, y, time) => ({ button: 0, isPrimary: true, pointerId: 7, - clientX: x, clientY: y, timeStamp: time, - preventDefault() {}, stopPropagation() {} }); - elListeners.pointerdown(event(node.x, node.y, 1)); - engineWindowListeners.pointermove(event(node.x + 6, node.y, 10)); - engineWindowListeners.pointermove(event(node.x + 18, node.y, 34)); - engineWindowListeners.pointerup(event(node.x + 18, node.y, 35)); - emit({ paused, live: api.physicsDiagnostics(), released, - snapshot: api.getPhysicsSnapshot(), node: { vx: node.vx, vy: node.vy, fx: node.fx, fy: node.fy } }); - """ - ) - state = report["paused"]["state"] - diagnostics = report["paused"]["diagnostics"] - assert state["gravitationalConstant"] == pytest.approx(1.75) - assert state["blackHoleMass"] == pytest.approx(3.5) - assert state["localGravitationalConstant"] == pytest.approx(2.25) - assert state["damping"] == pytest.approx(0.4) - assert state["springStiffness"] == pytest.approx(2.25) - assert state["orbitPaused"] is True - assert diagnostics["orbitPaused"] is True and diagnostics["active"] is False - assert diagnostics["G_center"] == pytest.approx(1.75) - assert diagnostics["G_star"] == pytest.approx(2.25) - assert report["paused"]["snapshot"]["paused"] is True - assert report["paused"]["snapshot"]["center"]["id"] == "custom-heavy-center-kappa" - anchors = report["paused"]["snapshot"]["systemAnchors"] - assert len(anchors) == 1 - assert {key: anchors[0][key] for key in ("id", "x", "y", "mass", "memberCount", - "systemOrbitRadius", "galacticOrbitRadius", "communityId")} == { - "id": "Users", "x": 92, "y": 0, "mass": 9, "memberCount": 2, - "systemOrbitRadius": 26, "galacticOrbitRadius": 92, "communityId": "users", - } - assert anchors[0]["radius"] > 0 - snapshot_users = next(node for node in report["paused"]["snapshot"]["nodes"] - if node["id"] == "Users") - snapshot_planet = next(node for node in report["paused"]["snapshot"]["nodes"] - if node["id"] == "users-planet") - assert snapshot_users["isSystemAnchor"] is True and snapshot_users["anchorRole"] == "community" - assert snapshot_planet["systemAnchorId"] == "Users" and snapshot_planet["orbitTier"] == 1 - assert report["live"]["orbitPaused"] is False - assert report["live"]["G_star"] == pytest.approx(1.4) - assert report["released"]["id"] == "dragged" - assert 0 < report["released"]["speed"] <= 24 - assert report["node"].get("fx") is report["node"].get("fy") is None - assert [report["node"]["vx"], report["node"]["vy"]] == pytest.approx( - [report["released"]["vx"], report["released"]["vy"]] - ) - assert report["snapshot"]["slingshot"] == report["released"] - - -@requires_node -def test_gravity_zero_leaves_the_galactic_field_weak_and_stellar_floor_intact() -> None: - """Zero weakens the galaxy-wide field without removing local stellar orbit support.""" - report = _run_node( - """ - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - system_anchor_id: 'black-hole', orbit_tier: 0, gravity_mass: 20, radius: 10, - x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'core-planet', community_id: 'core', system_anchor_id: 'black-hole', - orbit_tier: 1, gravity_mass: 1, radius: 3, - x: 45, y: 0, vx: 0, vy: 0 }, - { id: 'star', anchor_role: 'community', community_id: 'solar', - system_anchor_id: 'star', orbit_tier: 0, gravity_mass: 8, 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: 3, - x: 150, y: 0, vx: 0, vy: 0 }, - ]; - I.seedGalaxyOrbits(nodes, 404, 0, 38.4, false); - I.seedGalaxySystemOrbits(nodes, 404, 0, 48, false); - const [blackHole, corePlanet, star, planet] = nodes; - const systemCenter = () => ({ - x: (star.x * 8 + planet.x) / 9, - y: (star.y * 8 + planet.y) / 9, - vx: (star.vx * 8 + planet.vx) / 9, - vy: (star.vy * 8 + planet.vy) / 9, - }); - const relative = () => ({ - x: planet.x - star.x, y: planet.y - star.y, - vx: planet.vx - star.vx, vy: planet.vy - star.vy, - }); - const before = { center: systemCenter(), relative: relative(), - blackHole: [blackHole.x, blackHole.y, blackHole.vx, blackHole.vy], - corePlanet: [corePlanet.x, corePlanet.y, corePlanet.vx, corePlanet.vy] }; - let previousAngle = Math.atan2(before.relative.y, before.relative.x); - let previousGlobalAngle = Math.atan2(before.center.y, before.center.x); - let angularTravel = 0, globalAngularTravel = 0, - minimumRadius = Infinity, maximumRadius = 0, tick; - for (let step = 0; step < 180; step += 1) { - tick = I.integrateGalaxyLeapfrog(nodes, [], [], { - gravity: 0, softening: 38.4, centralSoftening: 48, - includeMutualSystems: false, includeRelations: false, - includeOrbitalSeparation: false, skipSystemAnchorPairs: true, - systemAnchorExclusionPadding: 1.5, systemAnchorRepulsionAcceleration: 0, - includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, - includeFarFieldConfinement: false, inwardConvergence: false, - localRelativeSpeedLimit: 48, timestep: 0.032, wallClockSeconds: 1 / 30, - velocityDecay: 0.00005, speedLimit: 48, includeCollisions: false, - }); - const phase = relative(), radius = Math.hypot(phase.x, phase.y); - const angle = Math.atan2(phase.y, phase.x); - angularTravel += Math.atan2(Math.sin(angle - previousAngle), - Math.cos(angle - previousAngle)); - previousAngle = angle; - const center = systemCenter(); - const globalAngle = Math.atan2(center.y, center.x); - globalAngularTravel += Math.atan2(Math.sin(globalAngle - previousGlobalAngle), - Math.cos(globalAngle - previousGlobalAngle)); - previousGlobalAngle = globalAngle; - minimumRadius = Math.min(minimumRadius, radius); - maximumRadius = Math.max(maximumRadius, radius); - } - emit({ - floorSetting: I.galaxyStellarGravityFloorSetting, - mappedSettings: [0, 47, 48, 100, Infinity, NaN] - .map(I.galaxyStellarGravitySetting), - constants: { - blackHole: I.galaxyBlackHoleGravityConstant(0, true), - compatibilityLocal: I.galaxyLocalGravityConstant(0), - stellar: I.galaxyStellarGravityConstant(0), - defaultStellar: I.galaxyStellarGravityConstant(48), - }, - before, after: { center: systemCenter(), relative: relative(), - blackHole: [blackHole.x, blackHole.y, blackHole.vx, blackHole.vy], - corePlanet: [corePlanet.x, corePlanet.y, corePlanet.vx, corePlanet.vy] }, - angularTravel, globalAngularTravel, minimumRadius, maximumRadius, - telemetry: tick.systemGravity, - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)), - }); - """ - ) - assert report["finite"] is True - assert report["floorSetting"] == 48 - assert report["mappedSettings"] == [48, 48, 48, 100, 48, 48] - assert report["constants"] == { - "blackHole": pytest.approx(172.13538461538462), - "compatibilityLocal": 0, - "stellar": 2535.0, - "defaultStellar": 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) - 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 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) - assert report["telemetry"]["eligibleStellarAnchors"] == 1 - assert report["telemetry"]["fallbackAnchors"] == 0 - assert report["telemetry"]["globalAnchors"] == 1 - assert report["telemetry"]["stellarFloorActive"] is True - - -@requires_node -def test_visible_history_ghosts_are_massless_black_hole_test_particles() -> None: - """History must visibly orbit without becoming an invisible extra gravity source.""" - report = _run_node( - """ - const make = ghost => { - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - system_anchor_id: 'black-hole', orbit_tier: 0, gravity_mass: 32, radius: 9, - 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: 8, radius: 5, - x: 126, y: 0, vx: 0, vy: 0 }, - { id: 'planet', community_id: 'solar', system_anchor_id: 'star', - orbit_tier: 1, gravity_mass: 1, radius: 3, - x: 150, y: 18, vx: 0, vy: 0 }, - ]; - if (ghost) nodes.push({ id: 'history', community_id: 'archive', ghost: true, - gravity_mass: 0, radius: 3, x: -108, y: 104, vx: 0, vy: 0, - system_anchor_id: 'black-hole', orbit_tier: 1 }); - return nodes; - }; - const baseline = make(false), haunted = make(true), options = { - gravity: 48, softening: 32, centralSoftening: 40, - includeMutualSystems: true, includeRelations: false, includeBridges: false, - includeOrbitalSeparation: false, skipSystemAnchorPairs: true, - systemAnchorExclusionPadding: 1.5, includeBlackHoleExclusion: true, - blackHoleExclusionPadding: 2.5, includeFarFieldConfinement: true, - farFieldEnvelopeScale: 1.75, farFieldMinimumRadius: 96, - farFieldSoftFraction: .82, farFieldAcceleration: 12, farFieldMaxAcceleration: 16, - localRelativeSpeedLimit: 48, timestep: .032, wallClockSeconds: 1 / 30, - inwardConvergence: true, velocityDecay: .00005, speedLimit: 48, - includeCollisions: false, layoutSeed: 808, - }; - I.seedGalaxyOrbits(baseline, 808, 48, 32, false); - I.seedGalaxySystemOrbits(baseline, 808, 48, 40, false); - I.seedGalaxyOrbits(haunted, 808, 48, 32, false); - I.seedGalaxySystemOrbits(haunted, 808, 48, 40, false); - const ghost = haunted.find(node => node.id === 'history'); - const angle = () => Math.atan2(ghost.y, ghost.x); - let previous = angle(), travel = 0, moved = 0, advanced = 0; - for (let step = 0; step < 180; step += 1) { - I.integrateGalaxyLeapfrog(baseline, [], [], options); - I.integrateGalaxyLeapfrog(haunted, [], [], options); - const orbit = I.integrateGalaxyGhostOrbits(haunted, options); - advanced += orbit.advanced; - const next = angle(); - const delta = Math.atan2(Math.sin(next - previous), Math.cos(next - previous)); - travel += delta; - if (Math.abs(delta) > 1e-8) moved++; - previous = next; - } - const live = nodes => nodes.filter(node => !node.ghost).map(node => - [node.x, node.y, node.vx, node.vy]); - emit({ baseline: live(baseline), haunted: live(haunted), ghost: { - mass: ghost.gravity_mass, x: ghost.x, y: ghost.y, vx: ghost.vx, vy: ghost.vy, - seeded: ghost.__galaxyGhostOrbitSeeded === true, - }, travel, moved, advanced, - finite: haunted.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); - """ - ) - assert report["finite"] is True - assert report["ghost"]["mass"] == 0 - assert report["ghost"]["seeded"] is True - assert report["advanced"] == 180 - assert report["moved"] == 180 - assert abs(report["travel"]) > 0.05 - # Test particles may be painted and moved, but cannot alter the live system's phase space. - assert len(report["haunted"]) == len(report["baseline"]) - for haunted, baseline in zip(report["haunted"], report["baseline"]): - assert haunted == pytest.approx(baseline, abs=1e-10) - - -@requires_node -def test_core_pair_reduction_is_complementary_momentum_safe_and_seed_exact() -> None: - report = _run_node( - """ - const system = (prefix, community, role = 'community') => [ - { id: prefix + '-star', anchor_role: role, community_id: community, - gravity_mass: 4, x: 0, y: 0, vx: 0, vy: 0 }, - { id: prefix + '-planet', community_id: community, - gravity_mass: 1, x: 30, y: 0, vx: 0, vy: 0 }, - ]; - const regularPair = system('regular-pair', 'regular'); - const corePair = system('core-pair', 'core'); - const pairs = [...regularPair, ...corePair]; - I.applyGalaxyGravity(pairs, { - effectiveGravity: I.galaxyGravityConstant(48), - pairFraction: 0.15, - corePairFraction: 0.1125, - coreCommunity: 'core', - softening: 12, - }); - const pairAcceleration = [Math.abs(regularPair[0].vx), Math.abs(corePair[0].vx)]; - const pairMomentum = [regularPair, corePair].map(members => members.reduce( - (sum, node) => sum + node.gravity_mass * node.vx, 0 - )); - - const regularHalo = system('regular-halo', 'regular'); - const coreHalo = system('core-halo', 'core'); - I.applyGalaxySystemHaloGravity([...regularHalo, ...coreHalo], { - gravity: 48, - smoothFraction: 0.85, - coreSmoothFraction: 0.8875, - coreCommunity: 'core', - softening: 12, - accelerationCap: 100, - }); - const relativeX = members => members[1].vx - members[0].vx; - const haloAcceleration = [Math.abs(relativeX(regularHalo)), - Math.abs(relativeX(coreHalo))]; - const haloMomentum = [regularHalo, coreHalo].map(members => members.reduce( - (sum, node) => sum + node.gravity_mass * node.vx, 0 - )); - - const regularCombined = system('regular-combined', 'regular'); - const coreCombined = system('core-combined', 'core'); - const combined = [...regularCombined, ...coreCombined]; - I.applyGalaxyGravity(combined, { - effectiveGravity: I.galaxyGravityConstant(48), pairFraction: 0.15, corePairFraction: 0.1125, - coreCommunity: 'core', softening: 12, - }); - I.applyGalaxySystemHaloGravity(combined, { - gravity: 48, smoothFraction: 0.85, coreSmoothFraction: 0.8875, - coreCommunity: 'core', softening: 12, accelerationCap: 100, - }); - - const seededCore = system('seeded', 'core', 'global'); - seededCore[0].system_anchor_id = 'seeded-star'; - seededCore[1].system_anchor_id = 'seeded-star'; - I.seedGalaxyOrbits(seededCore, 17, 48, 12, false, 0.15, 0.75); - const seededAcceleration = I.galaxyAccelerations(seededCore, [], [], { - gravity: 48, softening: 12, central: false, - eventHorizonInwardAcceleration: 0, frameDraggingFraction: 0, - systemAnchorRepulsionAcceleration: 0, - localPairFraction: 0.15, corePairMultiplier: 0.75, - }); - const relativeSpeed = Math.hypot( - seededCore[1].vx - seededCore[0].vx, - seededCore[1].vy - seededCore[0].vy - ); - const seededRadius = Math.hypot( - seededCore[1].x - seededCore[0].x, - seededCore[1].y - seededCore[0].y, - ); - const radialAcceleration = -( - seededAcceleration.get(seededCore[1]).ax - - seededAcceleration.get(seededCore[0]).ax - ); - - const coincident = [ - { id: 'global', anchor_role: 'global', community_id: 'core', - gravity_mass: 4, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'same', community_id: 'core', gravity_mass: 1, - x: 0, y: 0, vx: 0, vy: 0 }, - ]; - const finiteAcceleration = I.galaxyAccelerations(coincident, [], [], { - gravity: 100, softening: 0.1, central: false, - localPairFraction: 0.15, corePairMultiplier: 0.75, - }); - const halfStep = [{ id: 'half', community_id: 'single', gravity_mass: 1, - x: 3, y: -2, vx: 2, vy: -4 }]; - const oldStep = halfStep.map(node => ({ ...node })); - I.integrateGalaxyLeapfrog(halfStep, [], [], { - gravity: 0, central: false, timestep: 0.021328125, - velocityDecay: 0, speedLimit: 100, includeCollisions: false, - }); - I.integrateGalaxyLeapfrog(oldStep, [], [], { - gravity: 0, central: false, timestep: 0.03046875, - velocityDecay: 0, speedLimit: 100, includeCollisions: false, - }); - emit({ - pairAcceleration, - pairMomentum, - haloAcceleration, - haloMomentum, - combined: [Math.abs(relativeX(regularCombined)), - Math.abs(relativeX(coreCombined))], - seedLaw: [relativeSpeed * relativeSpeed / seededRadius, radialAcceleration], - seededRadius, - driftRatio: [(halfStep[0].x - 3) / (oldStep[0].x - 3), - (halfStep[0].y + 2) / (oldStep[0].y + 2)], - finite: [...finiteAcceleration.values()].every(value => - Number.isFinite(value.ax) && Number.isFinite(value.ay)), - }); - """ - ) - assert report["pairAcceleration"][1] / report["pairAcceleration"][0] == pytest.approx(0.75) - assert report["haloAcceleration"][1] / report["haloAcceleration"][0] == pytest.approx( - 0.8875 / 0.85 - ) - assert report["combined"][1] == pytest.approx(report["combined"][0], rel=1e-12) - assert report["pairMomentum"] == pytest.approx([0, 0], abs=1e-12) - assert report["haloMomentum"] == pytest.approx([0, 0], abs=1e-12) - # Core admission now places children at the contact boundary (compact lanes) rather - # than expanding them beyond the warp band. The seeded radius equals the contact - # distance, which is at least the authored 30-unit separation. - assert report["seededRadius"] >= 30 - assert report["seedLaw"][0] == pytest.approx(report["seedLaw"][1], rel=1e-12) - assert report["driftRatio"] == pytest.approx([0.7, 0.7]) - assert report["finite"] is True - assert "const GALAXY_GRAVITY_RESPONSE_RATE_MULTIPLIER = 1.5;" in ASSET.read_text(encoding="utf-8") - assert "const GALAXY_FIXED_TIMESTEP = 0.032;" in ASSET.read_text(encoding="utf-8") - - -@requires_node -def test_legacy_system_halo_and_anchor_integrator_preserve_free_system_com() -> None: - report = _run_node( - """ - const free = [ - { id: 'star', system_anchor_id: 'star', anchor_role: 'community', - community_id: 'free', gravity_mass: 8, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'inner', system_anchor_id: 'star', orbit_tier: 1, - community_id: 'free', gravity_mass: 2, x: 16, y: 0, vx: 0, vy: 0 }, - { id: 'outer', system_anchor_id: 'star', orbit_tier: 2, - community_id: 'free', gravity_mass: 1, x: 28, y: 0, vx: 0, vy: 0 }, - ]; - const stats = I.applyGalaxySystemHaloGravity(free, { - gravity: 100, softening: 12, smoothFraction: 0.85, - }); - const momentum = free.reduce((sum, node) => sum - + node.gravity_mass * node.vx, 0); - const firstOrder = free.slice(1).map(node => node.__galaxyOrbitOrder.tier); - free[1].x = 80; free[2].x = 10; - free.forEach(node => { node.vx = 0; node.vy = 0; }); - I.applyGalaxySystemHaloGravity(free, { - gravity: 100, softening: 12, smoothFraction: 0.85, - }); - - const freePair = [ - { id: 'a', anchor_role: 'community', community_id: 'pair', - gravity_mass: 8, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'b', community_id: 'pair', gravity_mass: 1, - x: 24, y: 0, vx: 0, vy: 0 }, - ]; - const freeAcceleration = I.galaxyAccelerations(freePair, [], [], { - gravity: 100, softening: 12, central: false, localPairFraction: 0.15, - }); - const freeRelative = freeAcceleration.get(freePair[1]).ax - - freeAcceleration.get(freePair[0]).ax; - // The live local field is star-only in the star frame; the system-wide recoil is a - // common translation, not an extra planet mass in this relative acceleration. - const expectedFree = -I.galaxyFallbackStellarGravityConstant(100) * 8 * 24 - / Math.pow(24 * 24 + 12 * 12, 1.5); - - const pinnedPair = freePair.map((node, index) => ({ ...node, - id: index ? 'planet' : 'black-hole', - anchor_role: index ? 'none' : 'global', - system_anchor_id: 'black-hole', - vx: 0, vy: 0, - })); - const pinnedAcceleration = I.galaxyAccelerations(pinnedPair, [], [], { - gravity: 100, softening: 12, central: false, localPairFraction: 0.15, - eventHorizonInwardAcceleration: 0, frameDraggingFraction: 0, - systemAnchorRepulsionAcceleration: 0, - }); - /* A direct global child is integrated by the same complete black-hole field that seeds - its carrier orbit. The direct legacy-halo calls above retain their old contract. */ - const expectedPinned = -I.galaxyBlackHoleGravityConstant(100, true) * 8 * 24 - / Math.pow(24 * 24 + 12 * 12, 1.5); - const seededPair = freePair.map(node => ({ ...node, vx: 0, vy: 0 })); - I.seedGalaxyOrbits(seededPair, 72, 100, 12, false, 0.15); - const seededAcceleration = I.galaxyAccelerations(seededPair, [], [], { - gravity: 100, softening: 12, central: false, localPairFraction: 0.15, - // This legacy two-body law intentionally excludes the new near-surface pressure; - // the seed uses the pure dominant-star circular field, as covered separately. - systemAnchorRepulsionAcceleration: 0, - }); - const relativeVelocity = Math.hypot( - seededPair[1].vx - seededPair[0].vx, - seededPair[1].vy - seededPair[0].vy - ); - const seededRadialAcceleration = -( - seededAcceleration.get(seededPair[1]).ax - - seededAcceleration.get(seededPair[0]).ax - ); - const degenerate = [ - { id: 'solo', community_id: 'one', gravity_mass: 2, x: 0, y: 0 }, - { id: 'ghost', community_id: 'one', ghost: true, - gravity_mass: 2, x: 0, y: 0 }, - { id: 'tie-a', community_id: 'tie', gravity_mass: 2, x: 5, y: 5 }, - { id: 'tie-b', community_id: 'tie', gravity_mass: 2, x: 5, y: 5 }, - ]; - I.applyGalaxySystemHaloGravity(degenerate, { - gravity: 100, softening: 12, smoothFraction: 0.85, - }); - const pathological = [ - { id: 'massive', anchor_role: 'community', community_id: 'huge', - gravity_mass: 1000, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'near', community_id: 'huge', gravity_mass: 1000, - x: 0.01, y: 0, vx: 0, vy: 0 }, - ]; - I.applyGalaxySystemHaloGravity(pathological, { - gravity: 10000, softening: 0.1, smoothFraction: 0.85, - }); - emit({ stats, momentum, firstOrder, - frozenOrder: free.slice(1).map(node => node.__galaxyOrbitOrder.tier), - freeRelative, expectedFree, - pinned: [pinnedAcceleration.get(pinnedPair[0]), - pinnedAcceleration.get(pinnedPair[1])], - expectedPinned, - seedLaw: [relativeVelocity * relativeVelocity / 24, - seededRadialAcceleration], - capped: pathological.map(node => Math.hypot(node.vx, node.vy)), - cappedMomentum: pathological.reduce((sum, node) => sum - + node.gravity_mass * node.vx, 0), - finite: degenerate.every(node => node.ghost || [node.vx, node.vy] - .every(value => value === undefined || Number.isFinite(value))), - }); - """ - ) - assert report["stats"] == {"communities": 1, "satellites": 2} - assert report["momentum"] == pytest.approx(0, abs=1e-12) - assert report["firstOrder"] == report["frozenOrder"] == [1, 2] - assert report["freeRelative"] == pytest.approx(report["expectedFree"], rel=1e-12) - assert report["pinned"][0] == {"ax": 0, "ay": 0} - assert report["pinned"][1]["ax"] == pytest.approx(report["expectedPinned"], rel=1e-12) - assert report["pinned"][1]["ay"] == pytest.approx(0, abs=1e-12) - assert report["seedLaw"][0] == pytest.approx(report["seedLaw"][1], rel=1e-12) - assert max(report["capped"]) == pytest.approx(1491.9230769230769) - assert report["cappedMomentum"] == pytest.approx(0, abs=1e-9) - assert report["finite"] is True - - -@requires_node -def test_black_hole_composite_field_is_mass_aware_differential_and_linear_cost() -> None: - report = _run_node( - """ - const fixture = coreScale => [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 8 * coreScale, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'bulge', anchor_role: 'community', community_id: 'core', - gravity_mass: 2 * coreScale, x: 8, y: 0, vx: 0, vy: 0 }, - { id: 'inner-a', community_id: 'inner', gravity_mass: 3, - x: 78, y: 0, vx: 0, vy: 0 }, - { id: 'inner-b', community_id: 'inner', gravity_mass: 2, - x: 84, y: 2, vx: 0, vy: 0 }, - { id: 'outer', community_id: 'outer', gravity_mass: 1, - x: 240, y: 0, vx: 0, vy: 0 }, - ]; - const weakNodes = fixture(1), strongNodes = fixture(2); - const weak = I.galaxyBlackHoleField(weakNodes, { - gravity: 48, softening: 36, accelerationCap: 100, - }); - const strong = I.galaxyBlackHoleField(strongNodes, { - gravity: 48, softening: 36, accelerationCap: 100, - }); - I.applyGalaxyBlackHoleGravity(weakNodes, { - gravity: 48, softening: 36, accelerationCap: 100, - }); - const inner = weak.systems.find(item => item.center.id === 'inner'); - const outer = weak.systems.find(item => item.center.id === 'outer'); - const strongInner = strong.systems.find(item => item.center.id === 'inner'); - const many = Array.from({ length: 600 }, (_, index) => ({ - id: index ? 'n' + index : 'bh', - anchor_role: index ? 'none' : 'global', - community_id: 'c' + index, - gravity_mass: 1 + index % 7, - x: index ? Math.cos(index * 2.399) * (40 + Math.sqrt(index) * 9) : 0, - y: index ? Math.sin(index * 2.399) * (40 + Math.sqrt(index) * 9) : 0, - })); - const manyField = I.galaxyBlackHoleField(many, { - gravity: 48, softening: 36, - }); - emit({ - anchor: weak.anchor.id, - masses: [weak.coreMass, weak.haloMass], - traversals: weak.traversals, - differential: [inner.omega, outer.omega], - massRatio: Math.hypot(strongInner.ax, strongInner.ay) - / Math.hypot(inner.ax, inner.ay), - inward: weakNodes.filter(node => node.community_id !== 'core') - .map(node => node.x * node.vx + node.y * node.vy), - rigidInner: [weakNodes[2].vx - weakNodes[3].vx, - weakNodes[2].vy - weakNodes[3].vy], - many: { traversals: manyField.traversals, systems: manyField.systems.length }, - }); - """ - ) - assert report["anchor"] == "black-hole" - assert report["masses"] == [8, 8] - assert report["traversals"] == 4 - assert report["differential"][0] > report["differential"][1] > 0 - assert report["massRatio"] > 1.5 - assert all(dot < 0 for dot in report["inward"]) - assert report["rigidInner"] == pytest.approx([0, 0], abs=1e-12) - assert report["many"]["traversals"] == 600 - assert report["many"]["systems"] == 599 - - -@requires_node -def test_cored_log_halo_has_flat_outer_rotation_and_caps_each_carrier_independently() -> None: - """The shared carrier law is flat outside the halo core and never globally downscales.""" - report = _run_node( - """ - const model = { - gravitationalConstant: 1, - coreMass: 0, - haloMass: Math.SQRT2 * 100, - coreSoftening: 10, - haloScale: 100, - accelerationCap: 1e9, - }; - const samples = [500, 1000, 2000].map(radius => { - const curve = I.galaxyCarrierOrbitCurve(model, radius); - return { radius, speed: curve.circularSpeed, omega: curve.omega }; - }); - const atScale = I.galaxyCarrierOrbitCurve(model, 100); - const neutralTarget = I.galaxyCarrierTargetSpeed(model, 1000, 100); - const capped = I.galaxyCarrierOrbitCurve({ ...model, accelerationCap: .001 }, 20); - const uncapped = I.galaxyCarrierOrbitCurve(model, 2000); - emit({ samples, atScale, neutralTarget, capped, uncapped }); - """ - ) - speeds = [sample["speed"] for sample in report["samples"]] - omegas = [sample["omega"] for sample in report["samples"]] - assert max(speeds) / min(speeds) < 1.02 - assert omegas[0] > omegas[1] > omegas[2] > 0 - # v0²=1 and r=a gives v²=.5, exactly matching the old Plummer speed at the handoff. - assert report["atScale"]["circularSpeed"] == pytest.approx(math.sqrt(.5), rel=1e-12) - # Neutral presentation speed is the actual circular speed, with no hidden visual boost. - assert report["neutralTarget"] == pytest.approx(speeds[1], rel=1e-12) - assert report["capped"]["acceleration"] == pytest.approx(.001, rel=1e-12) - # A cap sampled for one inner carrier does not scale an unrelated outer carrier. - assert report["uncapped"]["capScale"] == 1 - - -@requires_node -def test_direct_black_hole_star_is_one_rigid_carrier_with_local_descendant_physics() -> None: - """A directly linked star owns its planets; only that complete frame orbits the black hole.""" - report = _run_node( - """ - const make = () => [ - { id: 'bh', anchor_role: 'global', community_id: 'core', gravity_mass: 64, - radius: 10, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'star', anchor_role: 'community', community_id: 'solar', - system_anchor_id: 'bh', gravity_mass: 9, radius: 4, - x: 90, y: 0, vx: 0, vy: 0 }, - { id: 'planet', community_id: 'solar', system_anchor_id: 'star', - gravity_mass: 1, radius: 2, x: 102, y: 0, vx: 0, vy: 0 }, - { id: 'moon', community_id: 'solar', system_anchor_id: 'planet', - gravity_mass: .2, radius: 1, x: 106, y: 0, vx: 0, vy: 0 }, - // A same-community BH sibling is a separate carrier, never another child of `star`. - { id: 'peer', community_id: 'solar', system_anchor_id: 'bh', - gravity_mass: 2, radius: 2, x: -80, y: 0, vx: 0, vy: 0 }, - ]; - const galactic = make(); - const field = I.galaxyBlackHoleField(galactic, { - gravity: 48, softening: 32, accelerationCap: 1e9, - }); - I.applyGalaxyBlackHoleGravity(galactic, { - gravity: 48, softening: 32, accelerationCap: 1e9, - }); - const seeded = make().filter(node => node.id !== 'peer'); - I.seedGalaxySystemOrbits(seeded, 311, 48, 32, false); - const local = make(); - I.applyGalaxySystemAnchorGravity(local, { - gravity: 48, softening: 8, accelerationCap: 1e9, - }); - emit({ - systems: field.systems.map(item => ({ id: item.id, core: item.core, - carrier: item.carrier.id, members: item.nodes.map(node => node.id) })), - galactic: galactic.map(node => [node.vx, node.vy]), - seededSingleCommunity: seeded.map(node => [node.vx, node.vy]), - local: local.map(node => [node.vx, node.vy]), - }); - """ - ) - assert report["systems"] == [ - {"id": "star", "core": True, "carrier": "star", - "members": ["star", "planet", "moon"]}, - {"id": "peer", "core": True, "carrier": "peer", "members": ["peer"]}, - ] - carrier_delta = report["galactic"][1] - assert math.hypot(*carrier_delta) > 0 - assert report["galactic"][2] == pytest.approx(carrier_delta, abs=1e-12) - assert report["galactic"][3] == pytest.approx(carrier_delta, abs=1e-12) - assert math.hypot(*report["galactic"][4]) > 0 - assert math.hypot(*report["seededSingleCommunity"][1]) > 0 - assert report["seededSingleCommunity"][2] == pytest.approx( - report["seededSingleCommunity"][1], abs=1e-12 - ) - assert report["seededSingleCommunity"][3] == pytest.approx( - report["seededSingleCommunity"][1], abs=1e-12 - ) - # The star gets no second local black-hole pull; planet and moon use immediate parents. - assert report["local"][1] == pytest.approx([0, 0], abs=1e-12) - assert math.hypot(*report["local"][2]) > 0 - assert math.hypot(*report["local"][3]) > 0 - assert report["local"][4] == pytest.approx([0, 0], abs=1e-12) - - -@requires_node -def test_direct_black_hole_solar_system_gets_its_own_packed_carrier_envelope() -> None: - """Admission uses the runtime carrier hierarchy instead of folding the star into the hole.""" - report = _run_node( - """ - const nodes = [ - { id: 'bh', anchor_role: 'global', community_id: 'core', gravity_mass: 64, - radius: 10, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'direct-star', anchor_role: 'community', community_id: 'core', - system_anchor_id: 'bh', gravity_mass: 9, radius: 5, - x: 120, y: 0, vx: 2, vy: 1 }, - { id: 'direct-planet', community_id: 'core', system_anchor_id: 'direct-star', - gravity_mass: 1, radius: 2, x: 138, y: 4, vx: 2, vy: 2 }, - { id: 'outer-star', anchor_role: 'community', community_id: 'outer', - system_anchor_id: 'outer-star', gravity_mass: 8, radius: 5, - x: 120, y: 0, vx: -1, vy: 0 }, - { id: 'outer-planet', community_id: 'outer', system_anchor_id: 'outer-star', - gravity_mass: 1, radius: 2, x: 140, y: 0, vx: -1, vy: 1 }, - ]; - const byId = id => nodes.find(node => node.id === id); - const directStar = byId('direct-star'), directPlanet = byId('direct-planet'); - const beforeLocal = [directPlanet.x - directStar.x, directPlanet.y - directStar.y, - directPlanet.vx - directStar.vx, directPlanet.vy - directStar.vy]; - const before = I.galaxySystemEnvelopes(nodes).map(system => ({ - id: system.id, anchor: system.anchor.id, members: system.nodes.map(node => node.id), - })).sort((left, right) => left.id.localeCompare(right.id)); - const admission = I.establishGalaxyCarrierLanes(nodes, { gap: 8, layoutSeed: 413 }); - const after = I.galaxySystemEnvelopes(nodes).map(system => ({ - id: system.id, anchor: system.anchor.id, members: system.nodes.map(node => node.id), - })).sort((left, right) => left.id.localeCompare(right.id)); - const afterLocal = [directPlanet.x - directStar.x, directPlanet.y - directStar.y, - directPlanet.vx - directStar.vx, directPlanet.vy - directStar.vy]; - emit({ before, after, admission, beforeLocal, afterLocal, - blackHole: [nodes[0].x, nodes[0].y, nodes[0].vx, nodes[0].vy], - directLane: directStar.__galaxyCarrierLaneRadius, - outerLane: byId('outer-star').__galaxyCarrierLaneRadius }); - """ - ) - expected = [ - {"id": "bh", "anchor": "bh", "members": ["bh"]}, - {"id": "direct-star", "anchor": "direct-star", - "members": ["direct-star", "direct-planet"]}, - {"id": "outer-star", "anchor": "outer-star", - "members": ["outer-star", "outer-planet"]}, - ] - assert report["before"] == expected - assert report["after"] == expected - assert report["admission"]["assigned"] == 2 - assert report["admission"]["moved"] == 2 - assert report["directLane"] > 0 - assert report["outerLane"] > 0 - assert report["blackHole"] == [0, 0, 0, 0] - assert report["afterLocal"] == pytest.approx(report["beforeLocal"], abs=1e-12) - - -@requires_node -def test_envelopes_without_an_explicit_black_hole_keep_compatibility_systems_intact() -> None: - """A dominant fallback star is not a black hole and must retain its planet envelope.""" - report = _run_node( - """ - const nodes = [ - { id: 'hub', anchor_role: 'community', community_id: 'solar', gravity_mass: 8, - radius: 5, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'planet', community_id: 'solar', gravity_mass: 1, - radius: 2, x: 20, y: 0, vx: 0, vy: 1 }, - { id: 'other', anchor_role: 'community', community_id: 'other', gravity_mass: 4, - radius: 4, x: 80, y: 0, vx: 0, vy: 0 }, - ]; - emit(I.galaxySystemEnvelopes(nodes).map(system => ({ - id: system.id, members: system.nodes.map(node => node.id), - })).sort((left, right) => left.id.localeCompare(right.id))); - """ - ) - assert report == [ - {"id": "hub", "members": ["hub", "planet"]}, - {"id": "other", "members": ["other"]}, - ] - - -@requires_node -def test_global_anchor_stays_exactly_centered_without_packing_the_disk() -> None: - report = _run_node( - """ - const nodes = [ - ['black-hole', 16, 'core', 0, 0, 'global'], - ['bulge', 4, 'core', 12, 3, 'community'], - ['inner-star', 5, 'inner', 80, 0, 'community'], - ['inner-planet', 2, 'inner', 92, 4, 'none'], - ['outer-star', 4, 'outer', 240, 0, 'community'], - ['outer-planet', 1, 'outer', 252, -3, 'none'], - ].map(([id, gravity_mass, community_id, x, y, anchor_role]) => ({ - id, gravity_mass, community_id, x, y, vx: 0, vy: 0, - radius: 4, anchor_role, - })); - I.seedGalaxyOrbits(nodes, 19, 100, 8, false); - I.seedGalaxySystemOrbits(nodes, 19, 100, 40, false); - let exact = true; - for (let step = 0; step < 90; step++) { - I.integrateGalaxyLeapfrog(nodes, [], [], { - gravity: 100, softening: 8, centralSoftening: 40, - timestep: 0.75, velocityDecay: 0.0005, speedLimit: 48, - collisionPadding: 1.5, collisionStrength: 0.7, collisionIterations: 2, - }); - const anchor = nodes[0]; - exact = exact && anchor.x === 0 && anchor.y === 0 - && anchor.vx === 0 && anchor.vy === 0; - } - const centers = [...I.communityCenters(nodes).values()]; - let minimumSystemDistance = Infinity; - for (let left = 0; left < centers.length; left++) for ( - let right = left + 1; right < centers.length; right++ - ) minimumSystemDistance = Math.min(minimumSystemDistance, - Math.hypot(centers[left].x - centers[right].x, - centers[left].y - centers[right].y)); - emit({ exact, finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)), minimumSystemDistance }); - """ - ) - assert report["exact"] is True - assert report["finite"] is True - assert report["minimumSystemDistance"] > 40 - - -@requires_node -def test_actual_shaped_multi_member_galaxy_stays_bound_for_1800_steps() -> None: - report = _run_node( - """ - const nodes = [{ - id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 24, visual_radius: 10, radius: 10, - galactic_radius: 0, x: 0, y: 0, vx: 0, vy: 0, - }]; - const links = []; - for (let system = 1; system <= 24; system++) { - const galacticRadius = 140 + system * 16; - const phase = system * 2.399963229728653; - const centerX = Math.cos(phase) * galacticRadius; - const centerY = Math.sin(phase) * galacticRadius * 0.82; - for (let member = 0; member < 6; member++) { - const localRadius = member === 0 ? 0 : 12 + member * 5; - const localPhase = phase + member * 1.2566370614; - nodes.push({ - id: `s${system}-n${member}`, - anchor_role: member === 0 ? 'community' : 'none', - community_id: `system-${system}`, - gravity_mass: member === 0 ? 5 + system % 4 : 1 + (member % 3) * 0.5, - visual_radius: member === 0 ? 5 : 2 + member % 2, - radius: member === 0 ? 5 : 2 + member % 2, - galactic_radius: galacticRadius, - galactic_phase: phase, - x: centerX + Math.cos(localPhase) * localRadius, - y: centerY + Math.sin(localPhase) * localRadius, - vx: 0, vy: 0, - }); - if (member > 0) links.push({ - source: `s${system}-n0`, target: `s${system}-n${member}`, - rest_length: localRadius, spring_strength: 0.08, - }); - } - } - I.seedGalaxyOrbits(nodes, 91027, 100, 32, false, 0.15); - I.seedGalaxySystemOrbits(nodes, 91027, 100, 40, false); - const percentile = (values, fraction) => { - const sorted = values.slice().sort((a, b) => a - b); - return sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * fraction))]; - }; - const snapshot = () => { - const centers = [...I.communityCenters(nodes).values()] - .filter(center => center.id !== 'core'); - const systemRadii = centers.map(center => Math.hypot(center.x, center.y)); - const nodeRadii = nodes.slice(1).map(node => Math.hypot(node.x, node.y)); - return { - median: percentile(systemRadii, 0.5), - p95: percentile(systemRadii, 0.95), - maxNode: Math.max(...nodeRadii), - }; - }; - const orbitalEnergy = () => { - const field = I.galaxyBlackHoleField(nodes, { gravity: 100, softening: 40 }); - const g = I.galaxyGravityConstant(100); - return field.systems.reduce((sum, item) => { - let vx = 0, vy = 0; - item.center.nodes.forEach(node => { - vx += node.gravity_mass * node.vx; - vy += node.gravity_mass * node.vy; - }); - vx /= item.center.mass; vy /= item.center.mass; - const kinetic = 0.5 * item.center.mass * (vx * vx + vy * vy); - const potential = -item.center.mass * g * ( - field.coreMass / Math.sqrt(item.radius * item.radius + 40 * 40) - + field.haloMass / Math.sqrt( - item.radius * item.radius + field.haloScale * field.haloScale - ) - ); - return sum + kinetic + potential; - }, 0); - }; - const initial = snapshot(); - const initialEnergy = orbitalEnergy(); - let minimumMedian = initial.median, maximumP95 = initial.p95; - let maximumNode = initial.maxNode, minimumEnergy = initialEnergy; - let maximumEnergy = initialEnergy, exactCenter = true, speedCaps = 0; - const angleStep = (next, previous) => Math.atan2( - Math.sin(next - previous), Math.cos(next - previous) - ); - const globalAngles = new Map([...I.communityCenters(nodes).values()] - .filter(center => center.id !== 'core') - .map(center => [center.id, Math.atan2(center.y, center.x)])); - const localAngles = new Map(nodes.slice(1).filter(node => node.anchor_role !== 'community') - .map(node => { - const star = nodes.find(candidate => candidate.community_id === node.community_id - && candidate.anchor_role === 'community'); - return [node.id, Math.atan2(node.y - star.y, node.x - star.x)]; - })); - let globalTravel = 0, localTravel = 0, minimumStarClearance = Infinity; - let starContacts = 0; - for (let step = 0; step < 1800; step++) { - const tick = I.integrateGalaxyLeapfrog(nodes, links, [], { - gravity: 100, softening: 32, centralSoftening: 40, - timestep: 0.021328125, velocityDecay: 0.00005, speedLimit: 48, - localPairFraction: 0.15, corePairMultiplier: 0.75, - includeBridges: false, includeMutualSystems: true, - mutualSystemGravityFraction: 0.12, mutualSystemSoftening: 80, - includeRelations: true, includeRelationSprings: false, - skipSystemAnchorRelations: true, relationStrengthMultiplier: 2, - relationForceCap: 1.6, relationAccelerationCap: 3.2, - relationConstraintRate: 24, relationConstraintMaxCorrection: 12, - relationPadding: 1.5, - includeOrbitalSeparation: true, orbitalSeparationPadding: 1.5, - orbitalSeparationStrength: 0.8, crossCommunitySeparationPadding: 1.5, - crossCommunitySeparationStrength: 0.144, - orbitalSeparationMaxCorrection: 4, orbitalSeparationMaxVelocityCorrection: 8, - preserveLocalTangentialVelocity: true, skipSystemAnchorPairs: true, - systemAnchorExclusionPadding: 1.5, - includeCollisions: false, - includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, - includeFarFieldConfinement: true, farFieldEnvelopeScale: 1.25, - farFieldMinimumRadius: 96, farFieldSoftFraction: 0.82, - farFieldAcceleration: 12, farFieldMaxAcceleration: 16, - inwardConvergence: true, wallClockSeconds: 1 / 30, - }); - if (tick.speedCapped) speedCaps++; - starContacts += tick.systemAnchorExclusion.contacts; - I.communityCenters(nodes).forEach(center => { - if (center.id === 'core') return; - const angle = Math.atan2(center.y, center.x); - globalTravel += Math.abs(angleStep(angle, globalAngles.get(center.id))); - globalAngles.set(center.id, angle); - }); - localAngles.forEach((previous, id) => { - const node = nodes.find(candidate => candidate.id === id); - const star = nodes.find(candidate => candidate.community_id === node.community_id - && candidate.anchor_role === 'community'); - const angle = Math.atan2(node.y - star.y, node.x - star.x); - localTravel += Math.abs(angleStep(angle, previous)); - localAngles.set(id, angle); - minimumStarClearance = Math.min(minimumStarClearance, - Math.hypot(node.x - star.x, node.y - star.y) - node.radius - star.radius - 1.5); - }); - const sample = snapshot(); - minimumMedian = Math.min(minimumMedian, sample.median); - maximumP95 = Math.max(maximumP95, sample.p95); - maximumNode = Math.max(maximumNode, sample.maxNode); - const energy = orbitalEnergy(); - minimumEnergy = Math.min(minimumEnergy, energy); - maximumEnergy = Math.max(maximumEnergy, energy); - const anchor = nodes[0]; - exactCenter = exactCenter && anchor.x === 0 && anchor.y === 0 - && anchor.vx === 0 && anchor.vy === 0; - } - let overlaps = 0, minimumSeparation = Infinity, minimumSystemDiameter = Infinity; - const bySystem = new Map(); - nodes.slice(1).forEach(node => { - if (!bySystem.has(node.community_id)) bySystem.set(node.community_id, []); - bySystem.get(node.community_id).push(node); - }); - bySystem.forEach(members => { - let diameter = 0; - for (let left = 0; left < members.length; left++) for ( - let right = left + 1; right < members.length; right++ - ) { - const separation = Math.hypot(members[left].x - members[right].x, - members[left].y - members[right].y); - minimumSeparation = Math.min(minimumSeparation, separation); - diameter = Math.max(diameter, separation); - if (separation < members[left].radius + members[right].radius) overlaps++; - } - minimumSystemDiameter = Math.min(minimumSystemDiameter, diameter); - }); - emit({ initial, final: snapshot(), minimumMedian, maximumP95, maximumNode, - energyDrift: (maximumEnergy - minimumEnergy) / Math.abs(initialEnergy), - exactCenter, speedCaps, overlaps, minimumSeparation, minimumSystemDiameter, - globalTravel, localTravel, minimumStarClearance, starContacts, - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)) }); - """ - ) - assert report["finite"] is True - assert report["exactCenter"] is True - # Gravity 100 is more than twice the live default. Its emergency guard may engage for a - # bounded minority of stress ticks (the default-48 fixture below remains cap-free), but it - # must not become the system's steady state or replace the asserted orbital travel. - assert report["speedCaps"] < 1800 * 0.3 - # The controlled projection deliberately permits painted envelopes to overlap as it draws - # every orbit inward. Collision impulses remain off here because they can create the - # outward/ejection response this mode forbids; the systems must still retain real extent. - assert report["overlaps"] <= 18 - assert report["minimumSeparation"] > 0.1 - assert report["minimumSystemDiameter"] > 15 - # This large 144-satellite scene may begin already surface-safe, so a contact count is not - # an invariant. The final 24-pass solver must nevertheless never reopen painted overlap. - assert report["minimumStarClearance"] >= -1e-9 - assert report["globalTravel"] > 1 - assert report["localTravel"] > 1 - assert report["minimumMedian"] > report["initial"]["median"] * 0.05 - assert report["maximumP95"] < report["initial"]["p95"] * 1.45 - assert report["maximumNode"] < report["initial"]["maxNode"] * 1.45 - - -@requires_node -def test_stronger_gravity_keeps_a_300_node_galaxy_on_the_controlled_inward_track() -> None: - report = _run_node( - """ - const nodes = [{ id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 24, radius: 10, x: 0, y: 0, vx: 0, vy: 0 }]; - for (let system = 1; system <= 50; system++) { - const members = system === 50 ? 5 : 6; - const radius = 105 + system * 5.5; - const phase = system * 2.399963229728653; - for (let member = 0; member < members; member++) { - const localRadius = member === 0 ? 0 : 8 + member * 3.5; - const localPhase = phase + member * 1.2566370614; - nodes.push({ - id: `s${system}-n${member}`, - anchor_role: member === 0 ? 'community' : 'none', - community_id: `s${system}`, - gravity_mass: member === 0 ? 5 + system % 4 : 1 + (member % 3) * 0.5, - radius: member === 0 ? 5 : 2, - x: Math.cos(phase) * radius + Math.cos(localPhase) * localRadius, - y: Math.sin(phase) * radius * 0.82 + Math.sin(localPhase) * localRadius, - vx: 0, vy: 0, - }); - } - } - I.seedGalaxyOrbits(nodes, 91027, 100, 32, false, 0.15, 0.75); - I.seedGalaxySystemOrbits(nodes, 91027, 100, 40, false); - const systemSnapshot = () => new Map([...I.communityCenters(nodes).values()] - .filter(center => center.id !== 'core') - .map(center => [center.id, Math.hypot(center.x, center.y)])); - const initial = systemSnapshot(); - let previous = new Map(initial), monotone = true, speedCaps = 0, maxSpeed = 0; - for (let step = 0; step < 1800; step++) { - const tick = I.integrateGalaxyLeapfrog(nodes, [], [], { - gravity: 100, softening: 32, centralSoftening: 40, timestep: 0.032, - velocityDecay: 0.00005, speedLimit: 48, localPairFraction: 0.15, - corePairMultiplier: 0.75, includeBridges: false, includeRelations: false, - includeCollisions: false, inwardConvergence: true, wallClockSeconds: 1 / 30, - }); - speedCaps += tick.speedCapped ? 1 : 0; - systemSnapshot().forEach((radius, id) => { - monotone = monotone && radius <= previous.get(id) + 1e-8; - previous.set(id, radius); - }); - nodes.slice(1).forEach(node => { - maxSpeed = Math.max(maxSpeed, Math.hypot(node.vx, node.vy)); - }); - } - const ratios = [...previous.entries()].map(([id, radius]) => radius / initial.get(id)) - .sort((left, right) => left - right); - emit({ - nodes: nodes.length, monotone, speedCaps, maxSpeed, - ratioMin: ratios[0], ratioMedian: ratios[Math.floor(ratios.length / 2)], - ratioMax: ratios[ratios.length - 1], - expectedTrack: I.galaxyInwardConvergenceFactor(60, 100), - anchor: [nodes[0].x, nodes[0].y, nodes[0].vx, nodes[0].vy], - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)), - }); - """ - ) - assert report["nodes"] == 300 - # Convergence is disabled (rate=0); orbits remain stable under physics alone. - # Radii oscillate naturally around their seeded values — no forced inward track. - expected_track = report["expectedTrack"] - assert expected_track == pytest.approx(1) - # The established emergency cap remains 48. At this >2x-default stress field, inner - # encounters may touch it for a bounded minority of ticks without owning the simulation. - assert report["speedCaps"] < 1800 * 0.3 - assert report["maxSpeed"] <= 48 + 1e-10 - # Stable orbits: median ratio near 1.0, bounded drift within +/-15%. The former - # monotone-inward contract was the bug — 25%/minute convergence collapsed every - # system into the black hole regardless of orbital velocity balance. - assert report["ratioMedian"] == pytest.approx(1.0, abs=0.15) - assert report["ratioMax"] <= 1.15 - assert report["ratioMin"] > 0.78 - assert report["anchor"] == pytest.approx([0, 0, 0, 0], abs=1e-12) - assert report["finite"] is True - - -@requires_node -def test_501_active_bodies_keep_bounded_dual_scale_orbits_with_spacetime_enabled() -> None: - """The live force path remains stable at the requested 500+ active-body scale. - - This deliberately stays below the 1,000-body live ceiling and above the Barnes--Hut exact - threshold. It rejects a quiet fallback, per-node local-frame corruption, or an unstable - near-horizon field without embedding a machine-dependent wall-clock assertion in CI. - """ - report = _run_node( - """ - const nodes = [{ id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 64, radius: 9, x: 0, y: 0, vx: 0, vy: 0 }], links = []; - for (let system = 0; system < 100; system++) { - const id = 's' + system, starId = id + '-star'; - const globalAngle = system * 2.399963229728653; - const globalRadius = 112 + (system % 25) * 10; - const cx = Math.cos(globalAngle) * globalRadius; - const cy = Math.sin(globalAngle) * globalRadius * .82; - nodes.push({ id: starId, anchor_role: 'community', community_id: id, - system_anchor_id: starId, orbit_tier: 0, gravity_mass: 8, radius: 5, - x: cx, y: cy, vx: 0, vy: 0 }); - for (let planet = 1; planet <= 4; planet++) { - const radius = 14 + planet * 5, phase = globalAngle + planet * 1.57079632679; - const planetId = id + '-p' + planet; - nodes.push({ id: planetId, community_id: id, system_anchor_id: starId, - orbit_tier: planet, gravity_mass: 1, radius: 2.5, - x: cx + Math.cos(phase) * radius, y: cy + Math.sin(phase) * radius, - vx: 0, vy: 0 }); - links.push({ source: starId, target: planetId, relation: 'orbits', - rest_length: radius, spring_strength: .08 }); - } - } - const delta = (next, previous) => Math.atan2(Math.sin(next - previous), - Math.cos(next - previous)); - const byId = id => nodes.find(node => node.id === id); - I.seedGalaxyOrbits(nodes, 51001, 48, 32, false); - I.seedGalaxySystemOrbits(nodes, 51001, 48, 40, false); - const starts = new Map(['s0', 's31', 's74'].map(id => { - const star = byId(id + '-star'), planet = byId(id + '-p1'); - return [id, { global: Math.atan2(star.y, star.x), - local: Math.atan2(planet.y - star.y, planet.x - star.x) }]; - })); - let maxSpeed = 0, speedCaps = 0, maxWarp = 0; - const options = { - gravity: 48, gravitationalConstant: 1, blackHoleMass: 1, - softening: 32, centralSoftening: 40, timestep: .032, wallClockSeconds: 1 / 30, - velocityDecay: .00005, speedLimit: 48, localRelativeSpeedLimit: 48, - includeMutualSystems: true, mutualSystemGravityFraction: .12, - mutualSystemSoftening: 80, exactLimit: 64, theta: .85, - includeRelations: true, includeRelationSprings: false, - skipSystemAnchorRelations: true, skipOrbitalSystemRelations: true, - includeOrbitalSeparation: true, orbitalSeparationPadding: 8, - orbitalSeparationStrength: .5, orbitalSeparationMaxCorrection: 4, - orbitalSeparationMaxVelocityCorrection: 8, - preserveLocalTangentialVelocity: true, preserveSystemRadii: true, - skipSystemAnchorPairs: true, systemAnchorExclusionPadding: 1.5, - includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, - includeFarFieldConfinement: true, farFieldEnvelopeScale: 1.75, - farFieldMinimumRadius: 96, farFieldSoftFraction: .82, - farFieldAcceleration: 12, farFieldMaxAcceleration: 16, - includeSpacetime: true, frameDraggingFraction: .018, - frameDraggingMaxAcceleration: .22, eventHorizonDecayRate: .12, - eventHorizonInwardAcceleration: .28, includeCollisions: false, - }; - for (let step = 0; step < 90; step++) { - const tick = I.integrateGalaxyLeapfrog(nodes, links, [], options); - maxSpeed = Math.max(maxSpeed, tick.maximumSpeed); - speedCaps += tick.speedCapped ? 1 : 0; - maxWarp = Math.max(maxWarp, tick.spacetime.maximumWarp); - } - const travel = [...starts.entries()].map(([id, start]) => { - const star = byId(id + '-star'), planet = byId(id + '-p1'); - return { global: delta(Math.atan2(star.y, star.x), start.global), - local: delta(Math.atan2(planet.y - star.y, planet.x - star.x), start.local) }; - }); - emit({ nodes: nodes.length, links: links.length, maxSpeed, speedCaps, maxWarp, travel, - anchor: [nodes[0].x, nodes[0].y, nodes[0].vx, nodes[0].vy], - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), - }); - """ - ) - assert report["nodes"] == 501 and report["links"] == 400 - assert report["finite"] is True - assert report["anchor"] == pytest.approx([0, 0, 0, 0], abs=1e-12) - assert report["maxSpeed"] <= 48 - assert report["speedCaps"] == 0 - # The selected systems prove both hierarchy levels remain live under the 500-node field. - assert all(abs(track["global"]) > .02 and abs(track["local"]) > .08 - for track in report["travel"]) - - -@requires_node -def test_black_hole_adornment_is_bounded_and_does_not_change_hit_geometry() -> None: - report = _run_node( - """ - const calls = { arcs: 0, ellipses: 0, fills: 0, strokes: 0, gradients: 0 }; - const ctx = { - save() {}, restore() {}, beginPath() {}, - moveTo() {}, lineTo() {}, - arc() { calls.arcs++; }, ellipse() { calls.ellipses++; }, - fill() { calls.fills++; }, stroke() { calls.strokes++; }, - createRadialGradient() { calls.gradients++; return { addColorStop() {} }; }, - set fillStyle(value) {}, set strokeStyle(value) {}, set lineWidth(value) {}, - }; - const global = { id: 'bh', x: 0, y: 0, radius: 9, - color: '#8f7cff', anchor_role: 'global' }; - const community = { id: 'star', x: 20, y: 0, radius: 5, - color: '#63d8cb', anchor_role: 'community' }; - const ordinary = { id: 'planet', x: 30, y: 0, radius: 3, - color: '#ffffff', anchor_role: 'none' }; - const before = [global.radius, community.radius, ordinary.radius]; - const painted = [ - I.paintGalaxyAnchorAdornment(ctx, global, 1, '#a58cff', false), - I.paintGalaxyAnchorAdornment(ctx, global, 1, '#a58cff', true), - I.paintGalaxyAnchorAdornment(ctx, community, 1, '#63d8cb', false), - I.paintGalaxyAnchorAdornment(ctx, ordinary, 1, '#ffffff', false), - ]; - emit({ calls, painted, before, - after: [global.radius, community.radius, ordinary.radius] }); - """ - ) - assert report["painted"] == [1, 1, 1, 0] - assert report["before"] == report["after"] == [9, 5, 3] - assert report["calls"]["gradients"] == 2 - assert report["calls"]["ellipses"] == 1 - assert report["calls"]["arcs"] >= 3 - assert report["calls"]["fills"] >= 2 - assert report["calls"]["strokes"] >= 3 - source = ASSET.read_text(encoding="utf-8") - style_node = source[source.index("function styleNode(node, ctx, scale)"): - source.index("function applyChrome", source.index("function styleNode(node, ctx, scale)"))] - assert "state.settings.mode === 'galaxy'" in style_node - assert style_node.count("paintGalaxyAnchorAdornment(") == 2 - - pointer = _run_engine( - """ - const pointerCalls = []; - const ctx = { - beginPath() {}, fill() {}, - arc(_x, _y, radius) { pointerCalls.push(radius); }, - set fillStyle(_value) {}, - }; - const api = G.create(el, {}); - api.setPreset('galaxy'); - store.nodePointerAreaPaint( - { id: 'bh', x: 0, y: 0, radius: 9, anchor_role: 'global' }, '#fff', ctx - ); - store.nodePointerAreaPaint( - { id: 'planet', x: 0, y: 0, radius: 3, anchor_role: 'none' }, '#fff', ctx - ); - emit({ pointerCalls }); - """ - ) - assert pointer["pointerCalls"] == [20, 5] - - -@requires_node -def test_black_hole_adornment_keeps_a_live_orbital_spin_phase() -> None: - report = _run_node( - """ - const spin = orbitalSpeed => { - const nodes = [{ id: 'bh', anchor_role: 'global', community_id: 'core', - x: 0, y: 0, vx: 0, vy: 0, gravity_mass: 64 }]; - const start = I.galaxyBlackHoleSpinAngle(nodes[0]); - for (let step = 0; step < 30; step += 1) { - I.advanceGalaxyBlackHoleSpin(nodes, { - layoutSeed: 7331, orbitalSpeed, timestep: .032, - }); - } - return I.galaxyBlackHoleSpinAngle(nodes[0]) - start; - }; - const slow = spin(100), fast = spin(400); - emit({ slow, fast, ratio: Math.abs(fast / slow) }); - """ - ) - assert abs(report["slow"]) > 0.1 - assert abs(report["fast"]) > abs(report["slow"]) - assert report["ratio"] == pytest.approx(2.5, rel=1e-9) - - -@requires_node -def test_galaxy_black_hole_seeds_circular_carriers_with_tangential_rotation() -> None: - report = _run_node( - """ - const nodes = [ - { id: 'anchor', x: 0, y: 0, vx: 0, vy: 0, gravity_mass: 16, - community_id: 'core', anchor_role: 'global' }, - { id: 'inner', x: 70, y: 0, vx: 0, vy: 0, gravity_mass: 2, - community_id: 'inner' }, - { id: 'outer', x: 180, y: 0, vx: 0, vy: 0, gravity_mass: 1, - community_id: 'outer' }, - ]; - I.seedGalaxySystemOrbits(nodes, 91, 48, 40, false); - const radius = node => Math.hypot(node.x, node.y); - const radialVelocity = node => node.x * node.vx + node.y * node.vy; - const initial = nodes.slice(1).map(node => ({ - radius: radius(node), radial: radialVelocity(node), - angular: node.x * node.vy - node.y * node.vx, - })); - for (let index = 0; index < 120; index++) { - I.integrateGalaxyLeapfrog(nodes, [], [], { - gravity: 48, softening: 8, centralSoftening: 40, timestep: 0.021328125, - velocityDecay: 0.02, speedLimit: 100, collisionStrength: 0, - }); - } - emit({ - initial, - final: nodes.slice(1).map(node => ({ - radius: radius(node), - angular: node.x * node.vy - node.y * node.vx, - })), - anchor: [nodes[0].x, nodes[0].y, nodes[0].vx, nodes[0].vy], - }); - """ - ) - # Admitted carrier lanes begin circularly; a compulsory inward seed would make a clean - # galaxy collapse into its neighbours and trigger packing pops. - assert all(abs(item["radial"]) < 1e-8 for item in report["initial"]) - assert all( - 0.5 * initial["radius"] < final["radius"] < 1.5 * initial["radius"] - for initial, final in zip(report["initial"], report["final"]) - ) - assert all(abs(item["angular"]) > 1e-6 for item in report["initial"]) - assert all(abs(item["angular"]) > 1e-6 for item in report["final"]) - assert report["anchor"] == pytest.approx([0, 0, 0, 0]) - - -@requires_node -def test_galaxy_relation_springs_are_local_mass_aware_and_momentum_symmetric() -> None: - report = _run_node( - """ - const fixture = () => [ - { id: 'heavy', x: 0, y: 0, vx: 0, vy: 0, gravity_mass: 4, community_id: 'solar' }, - { id: 'light', x: 30, y: 0, vx: 0, vy: 0, gravity_mass: 1, community_id: 'solar' }, - { id: 'remote', x: 80, y: 0, vx: 0, vy: 0, gravity_mass: 2, community_id: 'remote' }, - { id: 'history', x: 12, y: 0, vx: 0, vy: 0, gravity_mass: 0, - community_id: 'solar', ghost: true }, - ]; - const stretched = fixture(); - const stretchedStats = I.applyGalaxyRelationSprings(stretched, [ - { source: 'heavy', target: 'light', rest_length: 20, spring_strength: 0.1 }, - { source: 'light', target: 'remote', rest_length: 20, spring_strength: 0.2 }, - { source: 'heavy', target: 'remote', rest_length: 20, spring_strength: 0.2, - ghost: true, physics_strength: 0 }, - { source: 'heavy', target: 'history', rest_length: 20, spring_strength: 0.2 }, - ], { alpha: 1, orbitScale: 1 }); - const compressed = fixture(); - I.applyGalaxyRelationSprings(compressed, [ - { source: 'heavy', target: 'light', rest_length: 20, spring_strength: 0.1 }, - ], { alpha: 1, orbitScale: 2 }); - emit({ - stretched: stretched.map(node => [node.vx, node.vy]), - compressed: compressed.map(node => [node.vx, node.vy]), - applied: stretchedStats.applied, - momentum: stretched.reduce( - (sum, node) => sum + node.gravity_mass * node.vx, 0 - ), - }); - """ - ) - assert report["stretched"][0] == pytest.approx([0.2, 0]) - assert report["stretched"][1] == pytest.approx([-0.8, 0]) - assert report["stretched"][2] == pytest.approx([0, 0]) - assert report["stretched"][3] == pytest.approx([0, 0]) - assert report["compressed"][0] == pytest.approx([-0.2, 0]) - assert report["compressed"][1] == pytest.approx([0.8, 0]) - assert report["compressed"][2] == pytest.approx([0, 0]) - assert report["compressed"][3] == pytest.approx([0, 0]) - assert report["applied"] == 1 - assert report["momentum"] == pytest.approx(0, abs=1e-12) - - -@requires_node -def test_galaxy_link_distance_has_squared_scale_and_release_stable_response() -> None: - report = _run_node( - """ - const spring = (setting, strengthMultiplier = 2, - forceCap = 1.6, accelerationCap = 3.2) => { - const nodes = [ - { id: 'star', x: 0, y: 0, vx: 0, vy: 0, - gravity_mass: 4, radius: 1, community_id: 'solar' }, - { id: 'planet', x: 10, y: 0, vx: 0, vy: 0, - gravity_mass: 1, radius: 1, community_id: 'solar' }, - ]; - const link = { source: 'star', target: 'planet', - rest_length: 20, spring_strength: 0.1 }; - const orbitScale = I.galaxyRelationOrbitScale(setting); - const stats = I.applyGalaxyRelationSprings(nodes, [link], { - alpha: 1, orbitScale, strengthMultiplier, - forceCap, accelerationCap, - }); - return { - orbitScale, - target: I.galaxySpringDistance(link, orbitScale), - velocities: nodes.map(node => node.vx), - momentum: nodes.reduce( - (sum, node) => sum + node.gravity_mass * node.vx, 0), - stats, - }; - }; - const ordinary = [ - { id: 'star', x: 0, y: 0, vx: 0, vy: 0, - gravity_mass: 4, radius: 1, community_id: 'solar' }, - { id: 'planet', x: 10, y: 0, vx: 0, vy: 0, - gravity_mass: 1, radius: 1, community_id: 'solar' }, - ]; - I.applyGalaxyRelationSprings(ordinary, [{ - source: 'star', target: 'planet', rest_length: 20, spring_strength: 0.1, - }], { alpha: 1, orbitScale: 0.25, forceCap: 1.6, accelerationCap: 3.2 }); - emit({ - tight: spring(4), baseline: spring(8), reference: spring(16), loose: spring(80), - unsafeLoose: spring(80, 4, 3.2, 6.4), - ordinary: ordinary.map(node => node.vx), - constraint: (() => { - const make = () => [ - { id: 'star', x: 0, y: 0, vx: 0, vy: 0, - gravity_mass: 4, radius: 1, community_id: 'solar' }, - { id: 'planet', x: 10, y: 0, vx: 0, vy: 0, - gravity_mass: 1, radius: 1, community_id: 'solar' }, - ]; - const link = { source: 'star', target: 'planet', - rest_length: 20, spring_strength: 0.1 }; - const run = (setting, responseMultiplier, maxCorrection) => { - const nodes = make(); - const beforeCom = (nodes[0].x * 4 + nodes[1].x) / 5; - const stats = I.applyGalaxyRelationDistanceConstraints(nodes, [link], { - orbitScale: I.galaxyRelationOrbitScale(setting), strengthMultiplier: 2, - responseMultiplier, wallClockSeconds: 1 / 30, rate: 24, maxCorrection, - }); - return { - distance: Math.abs(nodes[1].x - nodes[0].x), - target: I.galaxySpringDistance(link, I.galaxyRelationOrbitScale(setting)), - beforeCom, afterCom: (nodes[0].x * 4 + nodes[1].x) / 5, stats, - }; - }; - return { - tight: run(8, 1, 12), loose: run(80, 1, 12), - responseStable: run(8, 1, 100), unsafeDoubled: run(8, 2, 100), - capStable: run(80, 1, 12), unsafeCapDoubled: run(80, 2, 12), - }; - })(), - }); - """ - ) - assert report["tight"]["orbitScale"] == pytest.approx(1 / 16) - assert report["baseline"]["orbitScale"] == pytest.approx(0.25) - assert report["reference"]["orbitScale"] == pytest.approx(1) - assert report["loose"]["orbitScale"] == pytest.approx(25) - assert report["tight"]["target"] == pytest.approx(1.25) - assert report["baseline"]["target"] == pytest.approx(5) - assert report["loose"]["target"] == pytest.approx(500) - assert report["baseline"]["velocities"] == pytest.approx( - [value * 2 for value in report["ordinary"]] - ) - assert report["loose"]["target"] == report["unsafeLoose"]["target"] - assert report["unsafeLoose"]["velocities"] == pytest.approx( - [value * 2 for value in report["loose"]["velocities"]] - ) - assert report["unsafeLoose"]["stats"]["maximumAcceleration"] == pytest.approx( - report["loose"]["stats"]["maximumAcceleration"] * 2 - ) - assert report["tight"]["velocities"][0] > 0 - assert report["loose"]["velocities"][0] < 0 - assert report["constraint"]["tight"]["distance"] < 10 - assert report["constraint"]["loose"]["distance"] > 10 - assert report["constraint"]["tight"]["stats"]["applied"] == 1 - assert report["constraint"]["loose"]["stats"]["applied"] == 1 - assert report["constraint"]["unsafeDoubled"]["target"] == \ - report["constraint"]["responseStable"]["target"] - # Doubling a continuous convergence rate squares the fraction of relation error left - # after one frame. It must not multiply the completed displacement past the target. - prior_correction = report["constraint"]["responseStable"]["stats"]["correctedDistance"] - initial_error = 5 - prior_response = prior_correction / initial_error - doubled_response = 1 - (1 - prior_response) ** 2 - assert report["constraint"]["unsafeDoubled"]["stats"]["correctedDistance"] \ - == pytest.approx(initial_error * doubled_response, rel=1e-12) - assert report["constraint"]["unsafeDoubled"]["stats"]["correctedDistance"] \ - < prior_correction * 2 - assert report["constraint"]["capStable"]["stats"]["maximumNodeShift"] \ - == pytest.approx(9.6) - assert report["constraint"]["unsafeCapDoubled"]["stats"]["maximumNodeShift"] \ - == pytest.approx(9.6) - assert report["constraint"]["capStable"]["stats"]["correctedDistance"] \ - == pytest.approx(12) - assert report["constraint"]["unsafeCapDoubled"]["stats"]["correctedDistance"] \ - == pytest.approx(12) - assert report["constraint"]["unsafeCapDoubled"]["stats"]["correctedDistance"] \ - == pytest.approx(report["constraint"]["capStable"]["stats"]["correctedDistance"]) - assert report["constraint"]["tight"]["afterCom"] == pytest.approx( - report["constraint"]["tight"]["beforeCom"], abs=1e-12 - ) - assert report["constraint"]["loose"]["afterCom"] == pytest.approx( - report["constraint"]["loose"]["beforeCom"], abs=1e-12 - ) - assert all( - item["momentum"] == pytest.approx(0, abs=1e-12) - for item in (report["tight"], report["baseline"], report["loose"]) - ) - - -@requires_node -def test_orbital_separation_is_contractive_and_preserves_local_mass_center() -> None: - report = _run_node( - """ - const run = (setting, strengthOverride = null) => { - const nodes = [ - { id: 'star', x: 0, y: 0, vx: 0, vy: 0, radius: 3, - gravity_mass: 4, community_id: 'solar' }, - { id: 'planet', x: 10, y: 0, vx: 0, vy: 0, radius: 3, - gravity_mass: 1, community_id: 'solar' }, - { id: 'other-system', x: 1, y: 0, vx: 0, vy: 0, radius: 3, - gravity_mass: 2, community_id: 'other' }, - ]; - const beforeCom = (nodes[0].x * 4 + nodes[1].x) / 5; - const otherBefore = [nodes[2].x, nodes[2].y, nodes[2].vx, nodes[2].vy]; - const padding = I.galaxyOrbitalSeparationPadding(setting); - const strength = I.galaxyOrbitalSeparationStrength(setting); - const stats = I.applyGalaxyOrbitalSeparation(nodes, { - padding, strength: strengthOverride === null ? strength : strengthOverride, - maxCorrection: 100, maxVelocityCorrection: 100, - }); - return { - padding, strength, stats, - distance: Math.hypot(nodes[1].x - nodes[0].x, nodes[1].y - nodes[0].y), - beforeCom, afterCom: (nodes[0].x * 4 + nodes[1].x) / 5, - otherBefore, - otherAfter: [nodes[2].x, nodes[2].y, nodes[2].vx, nodes[2].vy], - }; - }; - emit({ off: run(0), default: run(48), preset: run(60), maximum: run(120), - priorDefault: run(48, 0.8), priorMaximum: run(120, 1) }); - """ - ) - assert report["off"]["padding"] == 0 - assert report["off"]["strength"] == 0 - assert report["off"]["distance"] == pytest.approx(10) - assert report["default"]["padding"] == pytest.approx(12) - assert report["default"]["strength"] == pytest.approx(0.8) - assert report["default"]["distance"] == pytest.approx(16.4) - assert report["preset"]["strength"] == pytest.approx(1) - assert report["preset"]["distance"] == pytest.approx(21) - assert report["maximum"]["padding"] == pytest.approx(30) - assert report["maximum"]["strength"] == pytest.approx(1) - assert report["maximum"]["distance"] == pytest.approx(36) - # The release-safe response never exceeds one. It approaches contact monotonically and - # retains the pre-speed-up 48-setting calibration instead of crossing the manifold. - assert report["default"]["stats"]["correctionDistance"] == pytest.approx( - report["priorDefault"]["stats"]["correctionDistance"] - ) - assert report["maximum"]["stats"]["correctionDistance"] == pytest.approx( - report["priorMaximum"]["stats"]["correctionDistance"] - ) - for item in (report["default"], report["preset"], report["maximum"]): - assert item["stats"]["overlaps"] == 1 - assert item["afterCom"] == pytest.approx(item["beforeCom"], abs=1e-12) - assert item["otherAfter"] == item["otherBefore"] - - -@requires_node -def test_cross_system_repulsion_is_weak_bounded_and_preserves_orbital_velocity() -> None: - report = _run_node( - """ - const fixture = (leftVx, rightVx) => [ - { id: 'heavy', community_id: 'left-system', x: 0, y: 0, - vx: leftVx, vy: 0, radius: 3, gravity_mass: 4 }, - { id: 'light', community_id: 'right-system', x: 4, y: 0, - vx: rightVx, vy: 0, radius: 3, gravity_mass: 1 }, - ]; - const options = { - padding: 12, strength: 0, - crossCommunityPadding: 1.5, crossCommunityStrength: 0.16, - maxCorrection: 4, maxVelocityCorrection: 8, - }; - const closing = fixture(1, -1); - const separating = fixture(-1, 1); - const disabled = fixture(1, -1); - const beforeCom = (closing[0].x * 4 + closing[1].x) / 5; - const beforeMomentum = closing[0].vx * 4 + closing[1].vx; - const stats = I.applyGalaxyOrbitalSeparation(closing, options); - I.applyGalaxyOrbitalSeparation(separating, options); - const disabledStats = I.applyGalaxyOrbitalSeparation(disabled, { - ...options, crossCommunityStrength: 0, - }); - emit({ - stats, disabledStats, - distance: closing[1].x - closing[0].x, - center: (closing[0].x * 4 + closing[1].x) / 5, - beforeCom, - momentum: closing[0].vx * 4 + closing[1].vx, - beforeMomentum, - closingVelocity: closing.map(node => node.vx), - separatingVelocity: separating.map(node => node.vx), - disabledPhase: disabled.map(node => [node.x, node.y, node.vx, node.vy]), - finite: closing.concat(separating).every(node => - [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), - }); - """ - ) - assert report["finite"] is True - assert report["stats"]["crossCommunityPairs"] == 1 - assert report["stats"]["crossCommunityOverlaps"] == 1 - assert report["stats"]["crossCommunityCorrectionDistance"] == pytest.approx(0.56) - assert report["distance"] == pytest.approx(4.56) - assert report["center"] == pytest.approx(report["beforeCom"], abs=1e-12) - assert report["momentum"] == pytest.approx(report["beforeMomentum"], abs=1e-12) - # Cross-system contact is positional only: dissipating its COM motion repeatedly in a - # crowded galaxy bleeds the tangential velocity that keeps both systems orbiting the well. - assert report["closingVelocity"] == pytest.approx([1, -1], abs=1e-12) - assert report["separatingVelocity"] == pytest.approx([-1, 1], abs=1e-12) - assert report["disabledStats"]["overlaps"] == 0 - assert report["disabledPhase"] == [[0, 0, 1, 0], [4, 0, -1, 0]] - - -@requires_node -def test_cross_system_repulsion_translates_whole_systems_without_warping_orbits() -> None: - report = _run_node( - """ - const fixture = () => [ - { id: 'left-star', community_id: 'left-system', x: 0, y: 0, - vx: 1, vy: 0, radius: 1, gravity_mass: 3 }, - { id: 'left-moon', community_id: 'left-system', x: 2, y: 1, - vx: 1, vy: 2, radius: 1, gravity_mass: 1 }, - { id: 'right-star', community_id: 'right-system', x: 5, y: 0, - vx: -1, vy: 0, radius: 1, gravity_mass: 2 }, - { id: 'right-moon', community_id: 'right-system', x: 7, y: -1, - vx: -1, vy: -3, radius: 1, gravity_mass: 1 }, - ]; - const options = { - padding: 12, strength: 0, - crossCommunityPadding: 1.5, crossCommunityStrength: 0.16, - maxCorrection: 4, maxVelocityCorrection: 8, - }; - const relativeState = nodes => [ - nodes[1].x - nodes[0].x, nodes[1].y - nodes[0].y, - nodes[1].vx - nodes[0].vx, nodes[1].vy - nodes[0].vy, - nodes[3].x - nodes[2].x, nodes[3].y - nodes[2].y, - nodes[3].vx - nodes[2].vx, nodes[3].vy - nodes[2].vy, - ]; - const totals = nodes => { - const mass = nodes.reduce((sum, node) => sum + node.gravity_mass, 0); - return { - center: [ - nodes.reduce((sum, node) => sum + node.x * node.gravity_mass, 0) / mass, - nodes.reduce((sum, node) => sum + node.y * node.gravity_mass, 0) / mass, - ], - momentum: [ - nodes.reduce((sum, node) => sum + node.vx * node.gravity_mass, 0), - nodes.reduce((sum, node) => sum + node.vy * node.gravity_mass, 0), - ], - }; - }; - const nodes = fixture(); - const beforeRelative = relativeState(nodes); - const beforeTotals = totals(nodes); - const stats = I.applyGalaxyOrbitalSeparation(nodes, options); - const fixed = fixture(); - const fixedLeftBefore = fixed.slice(0, 2).map(node => - [node.x, node.y, node.vx, node.vy]); - I.applyGalaxyOrbitalSeparation(fixed, { ...options, fixedNodeId: 'left-star' }); - emit({ - stats, - beforeRelative, - afterRelative: relativeState(nodes), - beforeTotals, - afterTotals: totals(nodes), - fixedLeftBefore, - fixedLeftAfter: fixed.slice(0, 2).map(node => - [node.x, node.y, node.vx, node.vy]), - fixedRightMoved: fixed[2].x !== 5 || fixed[2].y !== 0, - finite: nodes.concat(fixed).every(node => - [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), - }); - """ - ) - assert report["finite"] is True - assert report["stats"]["crossCommunityOverlaps"] == 1 - assert report["afterRelative"] == pytest.approx( - report["beforeRelative"], abs=1e-12 - ) - assert report["afterTotals"]["center"] == pytest.approx( - report["beforeTotals"]["center"], abs=1e-12 - ) - assert report["afterTotals"]["momentum"] == pytest.approx( - report["beforeTotals"]["momentum"], abs=1e-12 - ) - assert report["fixedLeftAfter"] == report["fixedLeftBefore"] - assert report["fixedRightMoved"] is True - - -@requires_node -def test_dense_system_admission_assigns_clear_carrier_lanes_without_warping_local_frames() -> None: - """505 stacked systems receive one collision-free carrier admission, not live packing.""" - report = _run_node( - """ - const SYSTEMS = 84, PLANETS = 5, GAP = 2.4; - const nodes = [{ id: 'custom-central-mass', anchor_role: 'global', community_id: 'core', - gravity_mass: 64, radius: 9, x: 0, y: 0, vx: 0, vy: 0 }]; - for (let system = 0; system < SYSTEMS; system++) { - const id = 'packed-' + system, starId = id + '-star'; - nodes.push({ id: starId, anchor_role: 'community', community_id: id, - system_anchor_id: starId, orbit_tier: 0, gravity_mass: 9, radius: 5, - x: 120, y: 0, vx: 1.5, vy: -2 }); - for (let planet = 1; planet <= PLANETS; planet++) { - const radius = 18 + planet * 4, angle = planet * Math.PI * 2 / PLANETS; - nodes.push({ id: `${id}-p${planet}`, community_id: id, system_anchor_id: starId, - orbit_tier: planet, gravity_mass: 1, radius: 2.5, - x: 120 + Math.cos(angle) * radius, y: Math.sin(angle) * radius, - vx: 1.5 - Math.sin(angle), vy: -2 + Math.cos(angle) }); - } - } - const byId = id => nodes.find(node => node.id === id); - const localFrames = () => Array.from({ length: SYSTEMS }, (_, system) => { - const id = 'packed-' + system, star = byId(id + '-star'); - return Array.from({ length: PLANETS }, (_, index) => { - const planet = byId(`${id}-p${index + 1}`); - return [planet.x - star.x, planet.y - star.y, planet.vx - star.vx, planet.vy - star.vy]; - }); - }); - const envelopes = () => I.galaxySystemEnvelopes(nodes, { - blackHoleExclusionPadding: 2.5, - }).filter(envelope => envelope.anchor.anchor_role === 'community'); - const metrics = () => { - const systems = envelopes(); let minimumClearance = Infinity, overlaps = 0; - for (let left = 0; left < systems.length; left++) for (let right = 0; - right < left; right++) { - const a = systems[left], b = systems[right]; - const clearance = Math.hypot(a.x - b.x, a.y - b.y) - a.radius - b.radius; - minimumClearance = Math.min(minimumClearance, clearance); - if (clearance < GAP - 1e-8) overlaps++; - } - const blackHole = nodes[0]; - const horizonClearance = Math.min(...systems.map(system => - Math.hypot(system.x - blackHole.x, system.y - blackHole.y) - - system.radius - blackHole.radius - 2.5)); - return { count: systems.length, minimumClearance, overlaps, horizonClearance }; - }; - const before = localFrames(), initial = metrics(); - const fixedBefore = nodes.filter(node => node.community_id === 'packed-0') - .map(node => [node.x, node.y, node.vx, node.vy]); - const admissionStart = performance.now(); - const stats = I.establishGalaxyCarrierLanes(nodes, { - blackHoleExclusionPadding: 2.5, layoutSeed: 7103, - }); - const admissionMilliseconds = performance.now() - admissionStart; - const after = localFrames(), final = metrics(); - const maximumLocalFrameError = Math.max(...after.flat(2).map((value, index) => - Math.abs(value - before.flat(2)[index]))); - emit({ nodes: nodes.length, initial, final, stats, admissionMilliseconds, - maximumLocalFrameError, - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); - """ - ) - assert report["nodes"] == 505 - assert report["finite"] is True - assert report["initial"]["overlaps"] == 84 * 83 // 2 - assert report["final"]["count"] == 84 - assert report["final"]["overlaps"] == 0 - assert report["final"]["minimumClearance"] >= 2.4 - 1e-6 - assert report["final"]["horizonClearance"] >= -1e-9 - assert report["stats"]["assigned"] == 84 - assert report["stats"]["moved"] == 84 - # Admission translates an entire solar system exactly once; no planet is warped in its - # carrier frame and live integration no longer needs a packer to repair it. - assert report["maximumLocalFrameError"] < 1e-10 - - -@requires_node -def test_live_dense_system_lanes_stay_clear_without_packing_under_default_high_and_reduced_physics() -> None: - """A pre-admitted 505-body galaxy remains clear while both orbit levels advance.""" - report = _run_node( - """ - const SYSTEMS = 84, PLANETS = 5; - const make = gap => { - const nodes = [{ id: 'bh', anchor_role: 'global', community_id: 'core', - gravity_mass: 64, radius: 9, x: 0, y: 0, vx: 0, vy: 0 }], links = []; - for (let system = 0; system < SYSTEMS; system++) { - const id = 'orbit-' + system, starId = id + '-star'; - nodes.push({ id: starId, anchor_role: 'community', community_id: id, - system_anchor_id: starId, orbit_tier: 0, gravity_mass: 9, radius: 5, - x: 150, y: 0, vx: 0, vy: 0 }); - for (let planet = 1; planet <= PLANETS; planet++) { - const radius = 18 + planet * 4, angle = planet * Math.PI * 2 / PLANETS; - const planetId = `${id}-p${planet}`; - nodes.push({ id: planetId, community_id: id, system_anchor_id: starId, - orbit_tier: planet, gravity_mass: 1, radius: 2.5, - x: 150 + Math.cos(angle) * radius, y: Math.sin(angle) * radius, vx: 0, vy: 0 }); - links.push({ source: starId, target: planetId, relation: 'orbits', - rest_length: radius, spring_strength: .08 }); - } - } - const admission = I.establishGalaxyCarrierLanes(nodes, { gap, layoutSeed: 8831 }); - I.seedGalaxyOrbits(nodes, 8831, 48, 32, false); - I.seedGalaxySystemOrbits(nodes, 8831, 48, 40, false); - return { nodes, links, admission }; - }; - const run = (gap, strength, reducedMotion) => { - const { nodes, links, admission } = make(gap); - const byId = id => nodes.find(node => node.id === id); - const initialRadius = new Map(nodes.filter(node => node.orbit_tier > 0).map(node => { - const star = byId(node.system_anchor_id); - return [node.id, Math.hypot(node.x - star.x, node.y - star.y)]; - })); - const options = { - gravity: 48, gravitationalConstant: 1, localGravitationalConstant: 1, - blackHoleMass: 1, softening: 32, centralSoftening: 40, - timestep: .032, wallClockSeconds: 1 / 30, velocityDecay: .00005, - speedLimit: 48, localRelativeSpeedLimit: 48, - includeMutualSystems: true, mutualSystemGravityFraction: .12, - mutualSystemSoftening: 80, exactLimit: 64, theta: .85, - includeRelations: true, includeRelationSprings: false, - skipSystemAnchorRelations: true, skipOrbitalSystemRelations: true, - includeOrbitalSeparation: true, orbitalSeparationPadding: 8, - orbitalSeparationStrength: .5, orbitalSeparationMaxCorrection: 4, - orbitalSeparationMaxVelocityCorrection: 8, preserveLocalTangentialVelocity: true, - preserveSystemRadii: true, skipSystemAnchorPairs: true, - systemAnchorExclusionPadding: 1.5, includeBlackHoleExclusion: true, - blackHoleExclusionPadding: 2.5, includeFarFieldConfinement: true, - farFieldEnvelopeScale: 2, farFieldMinimumRadius: 96, farFieldSoftFraction: .82, - farFieldAcceleration: 12, farFieldMaxAcceleration: 16, includeSpacetime: true, - frameDraggingFraction: .018, frameDraggingMaxAcceleration: .22, - eventHorizonDecayRate: .12, eventHorizonInwardAcceleration: .28, - includeCollisions: false, includeSystemPacking: false, systemPackingGap: gap, - systemPackingStrength: strength, systemPackingMaxCorrection: 12, reducedMotion, - }; - const clearance = () => { - const systems = I.galaxySystemEnvelopes(nodes).filter(system => - system.anchor.anchor_role === 'community'); - let minimum = Infinity, overlaps = 0; - for (let left = 0; left < systems.length; left++) for (let right = 0; - right < left; right++) { - const a = systems[left], b = systems[right]; - const value = Math.hypot(a.x - b.x, a.y - b.y) - a.radius - b.radius; - minimum = Math.min(minimum, value); - if (value < gap - 1e-8) overlaps++; - } - return { count: systems.length, minimum, overlaps }; - }; - const initial = clearance(); let speedCaps = 0, maximumRadiusDrift = 0; - let totalPackingAdjustments = 0, maximumRemainingOverlaps = 0; - const liveStart = performance.now(); - for (let step = 0; step < 120; step++) { - const tick = I.integrateGalaxyLeapfrog(nodes, links, [], options); - speedCaps += tick.speedCapped ? 1 : 0; - totalPackingAdjustments += tick.systemPacking.adjustedSystems; - maximumRemainingOverlaps = Math.max(maximumRemainingOverlaps, - tick.systemPacking.remainingOverlaps); - initialRadius.forEach((radius, id) => { - const node = byId(id), star = byId(node.system_anchor_id); - maximumRadiusDrift = Math.max(maximumRadiusDrift, - Math.abs(Math.hypot(node.x - star.x, node.y - star.y) - radius)); - }); - } - const liveMilliseconds = performance.now() - liveStart; - return { admission, initial, final: clearance(), speedCaps, maximumRadiusDrift, - totalPackingAdjustments, maximumRemainingOverlaps, liveMilliseconds, - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }; - }; - emit({ normal: run(8, .4, false), reduced: run(8, .4, true), high: run(12, .8, false) }); - """ - ) - for mode, gap in (("normal", 8), ("reduced", 8), ("high", 12)): - sample = report[mode] - assert sample["finite"] is True - assert sample["admission"]["assigned"] == 84 - assert sample["admission"]["moved"] == 84 - assert sample["initial"]["count"] == sample["final"]["count"] == 84 - assert sample["initial"]["overlaps"] == 0 - assert sample["final"]["overlaps"] == 0 - assert sample["final"]["minimum"] >= gap - 1e-6 - assert sample["speedCaps"] == 0 - # Carrier packing is exactly rigid; this allows only the small bounded Verlet orbit - # drift accrued across 120 real local-gravity steps (well below a painted pixel). - assert sample["maximumRadiusDrift"] < .01 - assert sample["maximumRemainingOverlaps"] == 0 - assert sample["totalPackingAdjustments"] == 0 - - -@requires_node -def test_annulus_aware_packing_keeps_two_large_solar_systems_clear_and_rigid() -> None: - """The finite galaxy annulus must not trade envelope overlap for an outer-bound escape.""" - report = _run_node( - """ - const OUTER = 249.375, GAP = 8; - const make = () => { - const nodes = [{ id: 'bh', anchor_role: 'global', community_id: 'core', - gravity_mass: 64, radius: 9, x: 0, y: 0, vx: 0, vy: 0 }]; - ['a', 'b'].forEach(id => { - const star = `${id}-star`; - nodes.push({ id: star, anchor_role: 'community', community_id: id, - system_anchor_id: star, orbit_tier: 0, gravity_mass: 9, radius: 5, - x: 120, y: 0, vx: 0, vy: 0 }); - nodes.push({ id: `${id}-planet`, community_id: id, system_anchor_id: star, - orbit_tier: 1, gravity_mass: 1, radius: 2.5, x: 159.5, y: 0, vx: 0, vy: 0 }); - }); - return nodes; - }; - const options = { - gravity: 48, gravitationalConstant: 1, localGravitationalConstant: 1, - blackHoleMass: 1, softening: 32, centralSoftening: 40, - includeFarFieldConfinement: true, farFieldEnvelopeRadius: OUTER, - farFieldMinimumRadius: 96, farFieldSoftFraction: .82, - farFieldAcceleration: 12, farFieldMaxAcceleration: 16, - includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, - includeCollisions: false, includeRelations: false, includeOrbitalSeparation: false, - includeSystemPacking: true, systemPackingGap: GAP, systemPackingStrength: 1, - systemPackingMaxCorrection: Infinity, timestep: .032, wallClockSeconds: 1 / 30, - velocityDecay: .00005, speedLimit: 48, localRelativeSpeedLimit: 48, - }; - const local = nodes => ['a', 'b'].map(id => { - const star = nodes.find(node => node.id === `${id}-star`); - const planet = nodes.find(node => node.id === `${id}-planet`); - return [planet.x - star.x, planet.y - star.y, planet.vx - star.vx, planet.vy - star.vy]; - }); - const safety = nodes => { - const bh = nodes[0]; - let inner = Infinity, outer = Infinity; - nodes.slice(1).forEach(node => { - const distance = Math.hypot(node.x - bh.x, node.y - bh.y); - inner = Math.min(inner, distance - bh.radius - node.radius - 2.5); - outer = Math.min(outer, OUTER - distance - node.radius); - }); - const systems = I.galaxySystemEnvelopes(nodes, options).filter(system => - system.anchor.anchor_role === 'community'); - return { inner, outer, pairClearance: Math.hypot(systems[0].x - systems[1].x, - systems[0].y - systems[1].y) - systems[0].radius - systems[1].radius }; - }; - const directNodes = make(), before = local(directNodes); - const direct = I.applyGalaxySystemPacking(directNodes, { - ...options, gap: GAP, strength: 1, maxCorrection: Infinity, - }); - const directAfter = local(directNodes), directSafety = safety(directNodes); - const directLocalFrameError = Math.max(...before.flatMap((frame, index) => - frame.map((value, component) => Math.abs(value - directAfter[index][component])))); - - const liveNodes = make(); - I.applyGalaxySystemPacking(liveNodes, { ...options, gap: GAP, strength: 1, maxCorrection: Infinity }); - liveNodes.forEach(node => { delete node.__galaxyOrbitSeeded; delete node.__galaxySystemOrbitSeeded; }); - I.seedGalaxyOrbits(liveNodes, 442, 48, 32, false); - I.seedGalaxySystemOrbits(liveNodes, 442, 48, 40, false); - let live = null, liveCaps = 0; - for (let step = 0; step < 24; step++) { - live = I.integrateGalaxyLeapfrog(liveNodes, [], [], options); - liveCaps += live.speedCapped ? 1 : 0; - } - - const kinematicNodes = make(); - I.applyGalaxySystemPacking(kinematicNodes, { ...options, gap: GAP, strength: 1, maxCorrection: Infinity }); - let kinematic = null; - for (let step = 0; step < 24; step++) { - kinematic = I.advanceGalaxyKinematicOrbits(kinematicNodes, { ...options, layoutSeed: 442 }); - } - emit({ direct, directLocalFrameError, directSafety, livePacking: live.systemPacking, - liveSafety: safety(liveNodes), liveCaps, kinematicPacking: kinematic.systemPacking, - kinematicSafety: safety(kinematicNodes), - finite: directNodes.concat(liveNodes, kinematicNodes).every(node => - [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); - """ - ) - assert report["finite"] is True - assert report["direct"]["remainingOverlaps"] == 0 - assert report["direct"]["boundaryViolations"] == 0 - assert report["direct"]["minimumBlackHoleClearance"] >= 0 - assert report["direct"]["minimumOuterClearance"] >= 0 - assert report["directSafety"]["pairClearance"] >= 8 - 1e-8 - assert report["directSafety"]["inner"] >= 0 - assert report["directSafety"]["outer"] >= 0 - assert report["directLocalFrameError"] <= 1e-12 - for packing, safety in ((report["livePacking"], report["liveSafety"]), - (report["kinematicPacking"], report["kinematicSafety"])): - assert packing["remainingOverlaps"] == 0 - assert packing["boundaryViolations"] == 0 - assert packing["minimumBlackHoleClearance"] >= 0 - assert packing["minimumOuterClearance"] >= 0 - assert safety["pairClearance"] >= 8 - 1e-8 - assert safety["inner"] >= 0 and safety["outer"] >= 0 - assert report["liveCaps"] == 0 - - -@requires_node -def test_far_field_confinement_bounds_painted_members_without_erasing_orbits() -> None: - """The outer guard is a physical boundary, not a centre-only convergence hint. - - In particular, a satellite in the anchor community and the outer member of a - multi-node external system must both be contained. The external system moves - rigidly, while the core satellite keeps its angular motion. - """ - report = _run_node( - """ - const options = { - /* Deliberately use the live/default envelope scale. */ - farFieldMinimumRadius: 120, - farFieldSoftFraction: 0.55, farFieldAcceleration: 0.2, - farFieldMaxAcceleration: 0.2, - }; - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 64, radius: 12, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'core-satellite', community_id: 'core', system_anchor_id: 'black-hole', - gravity_mass: 1, radius: 3, x: 900, y: 0, vx: 0, vy: 8 }, - { id: 'outer-star', anchor_role: 'community', community_id: 'outer', - system_anchor_id: 'outer-star', gravity_mass: 4, - radius: 5, x: 600, y: 0, vx: 0, vy: 3 }, - { id: 'outer-moon', community_id: 'outer', system_anchor_id: 'outer-star', - gravity_mass: 1, radius: 3, x: 760, y: 0, vx: 0, vy: 5 }, - /* A pointer-owned system exercises the same painted outer guard. */ - { id: 'fixed-star', anchor_role: 'community', community_id: 'fixed', - system_anchor_id: 'fixed-star', gravity_mass: 2, - radius: 3, x: 300, y: -40, vx: 2, vy: 1 }, - { id: 'fixed-moon', community_id: 'fixed', system_anchor_id: 'fixed-star', - gravity_mass: 1, radius: 2, x: 320, y: -40, vx: 2, vy: 4 }, - ]; - const fixedPhase = nodes.slice(4).map(node => [node.x, node.y, node.vx, node.vy]); - const bootstrap = I.applyGalaxyFarFieldConfinement(nodes, { - ...options, fixedNodeId: 'fixed-star', - }); - const envelope = bootstrap.envelopeRadius; - const core = nodes[1], star = nodes[2], moon = nodes[3]; - - /* The smooth far-field must act before the exact cap. Put the external system in - its soft band, but leave the core satellite for the strict member-level case. */ - core.x = envelope - 10; core.y = 0; core.vx = 0; core.vy = 8; - star.x = envelope - 80; star.y = 0; star.vx = 0; star.vy = 3; - moon.x = envelope + 80; moon.y = 0; moon.vx = 0; moon.vy = 5; - const gravity = I.applyGalaxyFarFieldGravity(nodes, options); - const inwardAcceleration = (star.vx * 4 + moon.vx) / 5; - const coreInwardAcceleration = core.vx; - - /* Escape the core member outright, and put only the outer painted member of the - external system past the cached envelope. Its COM is still within it. */ - core.x = envelope + 90; core.y = 0; core.vx = 12; core.vy = 8; - star.x = envelope - 180; star.y = 0; star.vx = 12; star.vy = 3; - moon.x = envelope + 40; moon.y = 0; moon.vx = 12; moon.vy = 5; - const externalRelativeBefore = [ - moon.x - star.x, moon.y - star.y, moon.vx - star.vx, moon.vy - star.vy, - ]; - const coreAngularBefore = core.x * core.vy - core.y * core.vx; - const constrained = I.applyGalaxyFarFieldConfinement(nodes, { - ...options, fixedNodeId: 'fixed-star', - }); - const externalRelativeAfterConstraint = [ - moon.x - star.x, moon.y - star.y, moon.vx - star.vx, moon.vy - star.vy, - ]; - const coreAngularAfterConstraint = core.x * core.vy - core.y * core.vx; - /* Pointer targets outside the envelope are clamped before paint for the source and - every companion, so release does not need to repair stretched geometry. */ - const fixedStar = nodes[4], fixedMoon = nodes[5]; - fixedStar.x = envelope + 240; fixedStar.y = -40; fixedStar.vx = 12; fixedStar.vy = 1; - fixedMoon.x = envelope + 260; fixedMoon.y = -40; fixedMoon.vx = 12; fixedMoon.vy = 4; - const fixedHeldBefore = nodes.slice(4).map(node => [node.x, node.y, node.vx, node.vy]); - const fixedHeld = I.applyGalaxyFarFieldConfinement(nodes, { - ...options, fixedNodeId: 'fixed-star', - }); - const fixedHeldAfter = nodes.slice(4).map(node => [node.x, node.y, node.vx, node.vy]); - const fixedHeldClearance = nodes.slice(4).map(node => - envelope - (Math.hypot(node.x, node.y) + node.radius)); - const fixedBeforeRelease = nodes.slice(4).map(node => [node.x, node.y]); - const released = I.applyGalaxyFarFieldConfinement(nodes, options); - const maximumFixedReleaseStep = Math.max(...nodes.slice(4).map((node, index) => - Math.hypot(node.x - fixedBeforeRelease[index][0], node.y - fixedBeforeRelease[index][1]))); - const clearance = node => envelope - (Math.hypot(node.x, node.y) + node.radius); - const nonFixed = nodes.slice(1, 4); - let maximumRadius = Math.max(...nonFixed.map(node => Math.hypot(node.x, node.y) + node.radius)); - let minimumClearance = Math.min(...nonFixed.map(clearance)); - let finalStep; - for (let step = 0; step < 240; step++) { - finalStep = I.integrateGalaxyLeapfrog(nodes, [], [], { - ...options, gravity: 0, central: true, fixedNodeId: 'fixed-star', - includeFarFieldConfinement: true, includeBlackHoleExclusion: true, - includeCollisions: false, includeRelations: false, - includeOrbitalSeparation: false, inwardConvergence: false, - timestep: 0.021328125, wallClockSeconds: 1 / 30, - velocityDecay: 0, speedLimit: 24, - }); - const currentEnvelope = finalStep.farFieldConfinement.envelopeRadius; - nonFixed.forEach(node => { - maximumRadius = Math.max(maximumRadius, Math.hypot(node.x, node.y) + node.radius); - minimumClearance = Math.min(minimumClearance, - currentEnvelope - (Math.hypot(node.x, node.y) + node.radius)); - }); - } - emit({ - bootstrap, gravity, constrained, envelope, inwardAcceleration, - coreInwardAcceleration, - externalRelativeBefore, - externalRelativeAfterConstraint, - coreAngularBefore, - coreAngularAfterConstraint, - coreTangentAfterConstraint: core.vy, - coreAngularAfter: core.x * core.vy - core.y * core.vx, - fixedPhase, - fixedHeld, fixedHeldBefore, fixedHeldAfter, fixedHeldClearance, released, - maximumFixedReleaseStep, - fixedAfterRelease: nodes.slice(4).map(node => [node.x, node.y, node.vx, node.vy]), - minimumClearance, maximumRadius, - finalEnvelope: finalStep.farFieldConfinement.envelopeRadius, - maximumSpeed: finalStep.maximumSpeed, - horizonClearance: Math.hypot(core.x, core.y) - nodes[0].radius - core.radius - 2.5, - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), - }); - """ - ) - assert report["finite"] is True - assert report["bootstrap"]["envelopeRadius"] > 0 - assert report["gravity"]["acceleratedSystems"] >= 1 - assert report["gravity"]["acceleratedCoreNodes"] >= 1 - assert report["inwardAcceleration"] < 0 - assert report["coreInwardAcceleration"] < 0 - assert report["constrained"]["boundedCoreNodes"] >= 1 - assert report["constrained"]["boundedSystems"] >= 1 - assert report["externalRelativeAfterConstraint"] == pytest.approx( - report["externalRelativeBefore"], abs=1e-10 - ) - # The exact inward cap must retain the tangential direction instead of stopping or - # reversing the satellite. It intentionally does not speed it up to manufacture L. - assert 0 < report["coreAngularAfterConstraint"] <= report["coreAngularBefore"] - assert report["coreTangentAfterConstraint"] > 0 - assert report["coreAngularAfter"] > 0 - assert report["fixedHeld"]["boundedFixedSource"] >= 1 - assert report["fixedHeld"]["boundedFixedFollowers"] >= 1 - assert min(report["fixedHeldClearance"]) >= -1e-8 - assert abs(report["fixedHeldClearance"][0]) <= 1e-8 - assert report["maximumFixedReleaseStep"] <= 48 - assert all( - math.hypot(phase[0], phase[1]) + radius <= report["finalEnvelope"] + 1e-8 - for phase, radius in zip(report["fixedAfterRelease"], [3, 2]) - ) - assert report["minimumClearance"] >= -1e-8 - assert report["maximumRadius"] <= report["finalEnvelope"] + 1e-8 - assert report["horizonClearance"] >= -1e-8 - assert report["maximumSpeed"] <= 24 - - -@requires_node -def test_far_field_envelope_cache_survives_frozen_anchor() -> None: - """Object.defineProperty silently fails on frozen nodes; the WeakMap cache must still pin - the envelope so a late outward escape cannot make the permitted radius chase it.""" - report = _run_node( - """ - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 64, radius: 12, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'inner', community_id: 'core', gravity_mass: 2, - radius: 3, x: 40, y: 0, vx: 0, vy: 4 }, - { id: 'outer-star', community_id: 'outer', gravity_mass: 4, - radius: 5, x: 90, y: 0, vx: 0, vy: 3 }, - { id: 'outer-moon', community_id: 'outer', gravity_mass: 1, - radius: 3, x: 102, y: 6, vx: 0, vy: 5 }, - ]; - const anchor = nodes[0]; - const first = I.galaxyFarFieldEnvelope(nodes, { - farFieldMinimumRadius: 96, farFieldEnvelopeScale: 1.25, - farFieldSoftFraction: 0.82, - }); - Object.freeze(anchor); - const whileFrozen = I.galaxyFarFieldEnvelope(nodes, { - farFieldMinimumRadius: 96, farFieldEnvelopeScale: 1.25, - farFieldSoftFraction: 0.82, - }); - nodes[2].x = first.envelopeRadius + 400; - nodes[2].y = 0; - nodes[3].x = first.envelopeRadius + 420; - nodes[3].y = 0; - const afterEscape = I.galaxyFarFieldEnvelope(nodes, { - farFieldMinimumRadius: 96, farFieldEnvelopeScale: 1.25, - farFieldSoftFraction: 0.82, - }); - emit({ - initial: first.envelopeRadius, - whileFrozen: whileFrozen.envelopeRadius, - afterEscape: afterEscape.envelopeRadius, - anchorFrozen: Object.isFrozen(anchor), - finite: nodes.every(node => - [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), - }); - """ - ) - assert report["finite"] is True - assert report["anchorFrozen"] is True - assert report["initial"] > 0 - assert report["whileFrozen"] == pytest.approx(report["initial"], abs=1e-12) - assert report["afterEscape"] == pytest.approx(report["initial"], abs=1e-12) - -@requires_node -def test_pathological_oversized_system_stays_inside_the_black_hole_annulus() -> None: - """The final annular pass must solve both edges after an impossible rigid outer fit.""" - report = _run_node( - """ - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 64, radius: 12, x: 0, y: 0, vx: 0, vy: 0 }, - /* A heavy near member makes the external COM stay near the horizon while its light - partner stretches far beyond the cached envelope. The rigid outer correction - therefore carries this member through the black hole unless the final annulus - alternates the two strict boundaries member-by-member. */ - { id: 'heavy-near', community_id: 'pathological', gravity_mass: 100, - radius: 4, x: 40, y: 0, vx: 2, vy: 3 }, - { id: 'light-far', community_id: 'pathological', gravity_mass: 1, - radius: 4, x: 80, y: 0, vx: 2, vy: -2 }, - ]; - const options = { - gravity: 0, central: true, includeFarFieldConfinement: true, - includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, - includeCollisions: false, includeRelations: false, - includeOrbitalSeparation: false, inwardConvergence: false, - timestep: 0.021328125, wallClockSeconds: 1 / 30, - velocityDecay: 0, speedLimit: 24, farFieldMinimumRadius: 80, - }; - /* Cache a normal painted extent first; this emulates a late pathological deformation - rather than allowing the anomalous member to enlarge the initial envelope. */ - const bootstrap = I.applyGalaxyFarFieldConfinement(nodes, options); - const envelope = bootstrap.envelopeRadius; - nodes[1].x = 20; nodes[1].y = 0; nodes[1].vx = 4; nodes[1].vy = 3; - nodes[2].x = envelope + 300; nodes[2].y = 0; nodes[2].vx = 4; nodes[2].vy = -2; - let minimumInner = Infinity, minimumOuter = Infinity; - let oversized = 0, horizonContacts = 0, annulusInner = 0, annulusOuter = 0; - let finalStep; - for (let step = 0; step < 8; step++) { - finalStep = I.integrateGalaxyLeapfrog(nodes, [], [], options); - const far = finalStep.farFieldConfinement; - oversized += far.boundedOversizedNodes; - horizonContacts += finalStep.blackHoleExclusion.contacts; - annulusInner += far.annulus.innerCorrectedNodes; - annulusOuter += far.annulus.outerCorrectedNodes; - nodes.slice(1).forEach(node => { - const distance = Math.hypot(node.x - nodes[0].x, node.y - nodes[0].y); - minimumInner = Math.min(minimumInner, - distance - nodes[0].radius - node.radius - options.blackHoleExclusionPadding); - minimumOuter = Math.min(minimumOuter, - far.envelopeRadius - (distance + node.radius)); - }); - } - emit({ - bootstrap, finalStep, envelope, oversized, horizonContacts, annulusInner, annulusOuter, - minimumInner, minimumOuter, - anchor: [nodes[0].x, nodes[0].y, nodes[0].vx, nodes[0].vy], - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), - maximumSpeed: finalStep.maximumSpeed, - }); - """ - ) - assert report["bootstrap"]["envelopeRadius"] > 0 - assert report["finite"] is True - assert report["anchor"] == pytest.approx([0, 0, 0, 0], abs=1e-12) - assert report["oversized"] > 0 - assert report["horizonContacts"] > 0 - assert report["minimumInner"] >= -1e-8 - assert report["minimumOuter"] >= -1e-8 - assert report["maximumSpeed"] <= 24 - - -@requires_node -def test_final_outer_annulus_never_reopens_a_dominant_star_surface_overlap() -> None: - """The final painted phase must satisfy the outer and local stellar bounds together.""" - report = _run_node( - """ - const blackHole = { id: 'bh', anchor_role: 'global', community_id: 'core', - gravity_mass: 20, radius: 10, x: 0, y: 0, vx: 0, vy: 0 }; - const nodes = [blackHole]; - const boundaryOptions = { - includeFarFieldConfinement: true, farFieldEnvelopeScale: 1, - farFieldMinimumRadius: 96, farFieldSoftFraction: 0.82, - farFieldAcceleration: 12, farFieldMaxAcceleration: 16, - }; - // Cache the 96-unit envelope before the late outer system appears. - const bootstrap = I.applyGalaxyFarFieldConfinement(nodes, boundaryOptions); - const star = { id: 'star', anchor_role: 'community', community_id: 'solar', - system_anchor_id: 'star', orbit_tier: 0, gravity_mass: 8, radius: 5, - x: 88, y: 0, vx: 0, vy: 0 }; - const planet = { id: 'planet', community_id: 'solar', system_anchor_id: 'star', - orbit_tier: 1, gravity_mass: 1, radius: 3, x: 96, y: 0, vx: 0, vy: 0 }; - nodes.push(star, planet); - const options = { - ...boundaryOptions, gravity: 0, softening: 32, centralSoftening: 40, - includeRelations: false, includeMutualSystems: false, - includeOrbitalSeparation: false, includeCollisions: false, - includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, - systemAnchorExclusionPadding: 1.5, - timestep: 0.032, wallClockSeconds: 1 / 30, - inwardConvergence: false, velocityDecay: 0.00005, speedLimit: 48, - }; - let tick, minimumActualStarClearance = Infinity, firstFrame = null; - let totalBoundedSystems = 0, totalCorrectedDistance = 0; - for (let step = 0; step < 12; step += 1) { - tick = I.integrateGalaxyLeapfrog(nodes, [], [], options); - const actualStarClearance = Math.hypot(planet.x - star.x, planet.y - star.y) - - star.radius - planet.radius - options.systemAnchorExclusionPadding; - minimumActualStarClearance = Math.min( - minimumActualStarClearance, actualStarClearance); - totalBoundedSystems += tick.farFieldConfinement.boundedSystems; - totalCorrectedDistance += tick.farFieldConfinement.correctedDistance; - if (step === 0) { - firstFrame = { - starClearance: actualStarClearance, - reportedStarClearance: tick.systemAnchorExclusion.minimumClearance, - blackHoleClearance: Math.min(...nodes.slice(1).map(node => - Math.hypot(node.x - blackHole.x, node.y - blackHole.y) - - blackHole.radius - node.radius - options.blackHoleExclusionPadding)), - outerClearance: Math.min(...nodes.slice(1).map(node => - tick.farFieldConfinement.envelopeRadius - - Math.hypot(node.x - blackHole.x, node.y - blackHole.y) - node.radius)), - }; - } - } - const starClearance = Math.hypot(planet.x - star.x, planet.y - star.y) - - star.radius - planet.radius - options.systemAnchorExclusionPadding; - const blackHoleClearance = Math.min(...nodes.slice(1).map(node => - Math.hypot(node.x - blackHole.x, node.y - blackHole.y) - - blackHole.radius - node.radius - options.blackHoleExclusionPadding)); - const outerClearance = Math.min(...nodes.slice(1).map(node => - tick.farFieldConfinement.envelopeRadius - - Math.hypot(node.x - blackHole.x, node.y - blackHole.y) - node.radius)); - emit({ - bootstrap: bootstrap.envelopeRadius, - envelope: tick.farFieldConfinement.envelopeRadius, - starClearance, minimumActualStarClearance, blackHoleClearance, outerClearance, - firstFrame, totalBoundedSystems, totalCorrectedDistance, - reportedStarClearance: tick.systemAnchorExclusion.minimumClearance, - boundaryIterations: tick.systemAnchorExclusion.boundaryIterations, - annulus: tick.farFieldConfinement.annulus, - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)), - }); - """ - ) - assert report["bootstrap"] == report["envelope"] == pytest.approx(96) - assert report["finite"] is True - assert report["minimumActualStarClearance"] >= -1e-9, report - assert report["firstFrame"]["starClearance"] >= -1e-9, report - assert report["firstFrame"]["reportedStarClearance"] == pytest.approx( - report["firstFrame"]["starClearance"], abs=1e-9 - ) - assert report["firstFrame"]["blackHoleClearance"] >= -1e-9 - assert report["firstFrame"]["outerClearance"] >= -1e-9 - assert report["starClearance"] >= -1e-9 - assert report["blackHoleClearance"] >= -1e-9 - assert report["outerClearance"] >= -1e-9 - assert report["reportedStarClearance"] == pytest.approx( - report["starClearance"], abs=1e-9 - ) - assert report["boundaryIterations"] > 0 - assert report["totalBoundedSystems"] > 0 - assert report["totalCorrectedDistance"] > 0 - assert report["annulus"]["infeasibleNodes"] == 0 - - -@requires_node -def test_black_hole_exclusion_preserves_system_orbits_at_the_painted_edge() -> None: - report = _run_node( - """ - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - x: 0, y: 0, vx: 0, vy: 0, radius: 12, gravity_mass: 64 }, - { id: 'core-satellite', community_id: 'core', system_anchor_id: 'black-hole', - x: 2, y: 0, vx: -4, vy: 7, radius: 3, gravity_mass: 1 }, - { id: 'outer-star', anchor_role: 'community', community_id: 'outer', - system_anchor_id: 'outer-star', - x: 4, y: 0, vx: -3, vy: 2, radius: 4, gravity_mass: 4 }, - { id: 'outer-planet', community_id: 'outer', system_anchor_id: 'outer-star', - x: 8, y: 0, vx: -3, vy: 7, radius: 2, gravity_mass: 1 }, - ]; - const before = { - diameter: Math.hypot(nodes[3].x - nodes[2].x, nodes[3].y - nodes[2].y), - relativeVelocity: [nodes[3].vx - nodes[2].vx, nodes[3].vy - nodes[2].vy], - coreTangent: nodes[1].vy, - outerTangent: (nodes[2].vy * 4 + nodes[3].vy) / 5, - coreAngular: nodes[1].x * nodes[1].vy - nodes[1].y * nodes[1].vx, - outerAngular: ((nodes[2].x * 4 + nodes[3].x) / 5) - * ((nodes[2].vy * 4 + nodes[3].vy) / 5) - - ((nodes[2].y * 4 + nodes[3].y) / 5) - * ((nodes[2].vx * 4 + nodes[3].vx) / 5), - }; - const stats = I.applyGalaxyBlackHoleExclusion(nodes, { padding: 2.5 }); - const anchor = nodes[0]; - const clearances = nodes.slice(1).map(node => Math.hypot( - node.x - anchor.x, node.y - anchor.y - ) - anchor.radius - node.radius - 2.5); - emit({ - stats, - anchor: [anchor.x, anchor.y, anchor.vx, anchor.vy], - clearances, - core: [nodes[1].x, nodes[1].y, nodes[1].vx, nodes[1].vy], - diameter: Math.hypot(nodes[3].x - nodes[2].x, nodes[3].y - nodes[2].y), - relativeVelocity: [nodes[3].vx - nodes[2].vx, nodes[3].vy - nodes[2].vy], - outerTangent: (nodes[2].vy * 4 + nodes[3].vy) / 5, - coreAngular: nodes[1].x * nodes[1].vy - nodes[1].y * nodes[1].vx, - outerAngular: ((nodes[2].x * 4 + nodes[3].x) / 5) - * ((nodes[2].vy * 4 + nodes[3].vy) / 5) - - ((nodes[2].y * 4 + nodes[3].y) / 5) - * ((nodes[2].vx * 4 + nodes[3].vx) / 5), - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), - before, - }); - """ - ) - assert report["finite"] is True - assert report["anchor"] == pytest.approx([0, 0, 0, 0], abs=1e-12) - assert min(report["clearances"]) >= -1e-10 - assert report["stats"]["contacts"] == 2 - assert report["stats"]["systems"] == 1 - assert report["stats"]["coreNodes"] == 1 - assert report["stats"]["repelledNodes"] == 3 - assert report["stats"]["minimumClearance"] == pytest.approx(0, abs=1e-10) - assert report["stats"]["inwardVelocityRemoved"] == pytest.approx(7, abs=1e-12) - assert report["stats"]["tangentialVelocityRemoved"] > 0 - assert report["core"][2] == pytest.approx(0, abs=1e-12) - assert 0 < report["core"][3] < report["before"]["coreTangent"] - assert report["coreAngular"] == pytest.approx(report["before"]["coreAngular"], abs=1e-12) - assert report["diameter"] == pytest.approx(report["before"]["diameter"], abs=1e-12) - assert report["relativeVelocity"] == pytest.approx( - report["before"]["relativeVelocity"], abs=1e-12 - ) - assert 0 < report["outerTangent"] < report["before"]["outerTangent"] - assert report["outerAngular"] == pytest.approx( - report["before"]["outerAngular"], abs=1e-12 - ) - - -@requires_node -def test_link_and_orbital_separation_share_one_settling_target_without_jitter() -> None: - report = _run_node( - """ - const nodes = [ - { id: 'star', x: 0, y: 0, vx: 0, vy: 0, radius: 3, - gravity_mass: 4, community_id: 'solar' }, - { id: 'planet', x: 10, y: 0, vx: 0, vy: 0, radius: 3, - gravity_mass: 1, community_id: 'solar' }, - ]; - const links = [{ source: 'star', target: 'planet', rest_length: 20, - spring_strength: 0.1 }]; - const options = { - gravity: 0, central: false, timestep: 0.021328125, velocityDecay: 0.00005, - speedLimit: 48, includeCollisions: false, - includeRelations: true, includeRelationSprings: false, orbitScale: 0.25, - relationStrengthMultiplier: 2, relationConstraintRate: 24, - relationConstraintMaxCorrection: 12, relationPadding: 12, - wallClockSeconds: 1 / 30, - includeOrbitalSeparation: true, orbitalSeparationPadding: 12, - orbitalSeparationStrength: 0.8, orbitalSeparationMaxCorrection: 4, - orbitalSeparationMaxVelocityCorrection: 8, localRelativeSpeedLimit: 16, - // This unannotated compatibility pair is a relation/separation convergence fixture, - // not an explicit community-star stellar-pressure test. - systemAnchorRepulsionAcceleration: 0, - }; - const distances = [Math.hypot(nodes[1].x - nodes[0].x, - nodes[1].y - nodes[0].y)]; - const corrections = []; - let speedCaps = 0; - for (let step = 0; step < 120; step++) { - const tick = I.integrateGalaxyLeapfrog(nodes, links, [], options); - distances.push(Math.hypot(nodes[1].x - nodes[0].x, - nodes[1].y - nodes[0].y)); - corrections.push(tick.relationConstraint.correctedDistance - + tick.orbitalSeparation.correctionDistance); - speedCaps += tick.speedCapped ? 1 : 0; - } - emit({ - distances, corrections, speedCaps, - finalVelocity: nodes.map(node => [node.vx, node.vy]), - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)), - }); - """ - ) - assert report["finite"] is True - assert report["speedCaps"] == 0 - assert all( - current >= previous - 1e-10 - for previous, current in zip(report["distances"], report["distances"][1:]) - ) - assert report["distances"][-1] == pytest.approx(18, abs=2e-3) - # A bounded residual is expected while the relation and orbital-separation projections - # share the same settling target; it must remain three orders below the initial correction. - assert max(report["corrections"][-20:]) < report["corrections"][0] * 1e-3 - assert report["finalVelocity"][0] == pytest.approx(report["finalVelocity"][1], abs=1e-10) - assert math.hypot(*report["finalVelocity"][0]) <= 16 - - -@requires_node -def test_live_relation_constraints_skip_only_explicit_orbital_system_links() -> None: - """Topology links within an explicit solar system must not overwrite orbital phase.""" - report = _run_node( - """ - const fixture = () => [ - { id: 'star', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 0, - gravity_mass: 8, x: 0, y: 0 }, - { id: 'planet', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 1, - gravity_mass: 1, x: 30, y: 0 }, - // Same community but no explicit anchor metadata: a compatibility relation remains - // eligible for the legacy Link constraint. - { id: 'legacy-a', community_id: 'legacy', gravity_mass: 1, x: 0, y: 20 }, - { id: 'legacy-b', community_id: 'legacy', gravity_mass: 1, x: 30, y: 20 }, - ]; - const links = [ - { source: 'star', target: 'planet', rest_length: 10, spring_strength: 0.2 }, - { source: 'legacy-a', target: 'legacy-b', rest_length: 10, spring_strength: 0.2 }, - ]; - const run = skipOrbitalSystemRelations => { - const nodes = fixture(); - const before = nodes.map(node => [node.x, node.y]); - const stats = I.applyGalaxyRelationDistanceConstraints(nodes, links, { - orbitScale: 1, rate: 24, wallClockSeconds: 1 / 30, maxCorrection: 12, - skipOrbitalSystemRelations, - }); - return { stats, before, after: nodes.map(node => [node.x, node.y]) }; - }; - emit({ live: run(true), legacy: run(false) }); - """ - ) - live, legacy = report["live"], report["legacy"] - assert live["stats"]["skippedOrbitalSystem"] == 1 - assert live["stats"]["applied"] == 1 - for actual, expected in zip(live["after"][:2], live["before"][:2]): - assert actual == pytest.approx(expected) - assert any(actual != pytest.approx(expected) - for actual, expected in zip(live["after"][2:], live["before"][2:])) - # Direct helper callers retain the compatibility behavior until they opt into the live - # orbital-system guard; both relations are then eligible. - assert legacy["stats"]["skippedOrbitalSystem"] == 0 - assert legacy["stats"]["applied"] == 2 - assert any(actual != pytest.approx(expected) - for actual, expected in zip(legacy["after"][:2], legacy["before"][:2])) - - -@requires_node -def test_dense_hub_constraints_are_simultaneous_order_independent_and_bounded() -> None: - report = _run_node( - """ - const make = () => { - const nodes = [{ id: 'hub', x: 0, y: 0, vx: 0, vy: 0, - gravity_mass: 12, radius: 8, community_id: 'dense' }]; - for (let index = 0; index < 24; index++) nodes.push({ - id: 'leaf-' + index, x: 90 + index * 0.2, y: -18 + index * 1.5, - vx: 0, vy: 0, gravity_mass: 1, radius: 2, community_id: 'dense', - }); - return nodes; - }; - const links = Array.from({ length: 24 }, (_, index) => ({ - source: 'hub', target: 'leaf-' + index, - rest_length: 20, spring_strength: 0.1, - })); - const run = reverse => { - const nodes = make(); - const beforeCom = nodes.reduce((sum, node) => ({ - x: sum.x + node.gravity_mass * node.x, - y: sum.y + node.gravity_mass * node.y, - mass: sum.mass + node.gravity_mass, - }), { x: 0, y: 0, mass: 0 }); - const stats = I.applyGalaxyRelationDistanceConstraints( - nodes, reverse ? [...links].reverse() : links, - { orbitScale: 0.25, strengthMultiplier: 2, - wallClockSeconds: 1 / 30, rate: 24, maxCorrection: 12, padding: 12 } - ); - const afterCom = nodes.reduce((sum, node) => ({ - x: sum.x + node.gravity_mass * node.x, - y: sum.y + node.gravity_mass * node.y, - mass: sum.mass + node.gravity_mass, - }), { x: 0, y: 0, mass: 0 }); - return { - phase: Object.fromEntries(nodes.map(node => [node.id, [node.x, node.y]])), - before: [beforeCom.x / beforeCom.mass, beforeCom.y / beforeCom.mass], - after: [afterCom.x / afterCom.mass, afterCom.y / afterCom.mass], - stats, - }; - }; - emit({ forward: run(false), reverse: run(true) }); - """ - ) - assert report["forward"]["stats"]["applied"] == 24 - assert report["forward"]["stats"]["aggregateLimited"] is True - assert report["forward"]["stats"]["maximumNodeShift"] == pytest.approx(12) - assert report["forward"]["after"] == pytest.approx(report["forward"]["before"], abs=1e-12) - assert report["reverse"]["after"] == pytest.approx(report["reverse"]["before"], abs=1e-12) - for node_id, phase in report["forward"]["phase"].items(): - assert report["reverse"]["phase"][node_id] == pytest.approx(phase, abs=1e-12) - - -@requires_node -def test_dense_orbital_contacts_and_hot_members_receive_one_bounded_system_update() -> None: - report = _run_node( - """ - const nodes = [{ id: 'hub', x: 0, y: 0, vx: 0, vy: 0, - gravity_mass: 12, radius: 8, community_id: 'dense' }]; - for (let index = 0; index < 20; index++) { - const angle = index / 20 * Math.PI * 2; - nodes.push({ id: 'leaf-' + index, - x: Math.cos(angle) * 6, y: Math.sin(angle) * 6, - vx: -Math.sin(angle) * (index === 3 ? 90 : 4), - vy: Math.cos(angle) * (index === 3 ? 90 : 4), - gravity_mass: 1, radius: 2, community_id: 'dense' }); - } - const beforeCom = nodes.reduce((sum, node) => ({ - x: sum.x + node.gravity_mass * node.x, - y: sum.y + node.gravity_mass * node.y, - mass: sum.mass + node.gravity_mass, - }), { x: 0, y: 0, mass: 0 }); - const separation = I.applyGalaxyOrbitalSeparation(nodes, { - padding: 12, strength: 0.8, maxCorrection: 4, maxVelocityCorrection: 8, - }); - const afterPositionCom = nodes.reduce((sum, node) => ({ - x: sum.x + node.gravity_mass * node.x, - y: sum.y + node.gravity_mass * node.y, - mass: sum.mass + node.gravity_mass, - }), { x: 0, y: 0, mass: 0 }); - const beforeMomentum = nodes.reduce((sum, node) => ({ - x: sum.x + node.gravity_mass * node.vx, - y: sum.y + node.gravity_mass * node.vy, - }), { x: 0, y: 0 }); - const velocity = I.stabilizeGalaxySystemVelocities(nodes, { limit: 16 }); - const afterMomentum = nodes.reduce((sum, node) => ({ - x: sum.x + node.gravity_mass * node.vx, - y: sum.y + node.gravity_mass * node.vy, - }), { x: 0, y: 0 }); - const mass = beforeCom.mass; - const centerVx = afterMomentum.x / mass, centerVy = afterMomentum.y / mass; - emit({ separation, velocity, - positionComBefore: [beforeCom.x / mass, beforeCom.y / mass], - positionComAfter: [afterPositionCom.x / mass, afterPositionCom.y / mass], - momentumBefore: beforeMomentum, momentumAfter: afterMomentum, - maximumFinalRelativeSpeed: Math.max(...nodes.map(node => - Math.hypot(node.vx - centerVx, node.vy - centerVy))), - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)), - }); - """ - ) - assert report["finite"] is True - assert report["separation"]["overlaps"] > 20 - assert report["separation"]["aggregateLimited"] is True - assert report["separation"]["maximumNodeShift"] <= 4 + 1e-12 - assert report["separation"]["maximumVelocityShift"] <= 8 + 1e-12 - assert report["positionComAfter"] == pytest.approx(report["positionComBefore"], abs=1e-12) - assert report["velocity"]["limitedSystems"] == 1 - assert report["maximumFinalRelativeSpeed"] == pytest.approx(16, abs=1e-10) - assert [report["momentumAfter"]["x"], report["momentumAfter"]["y"]] == pytest.approx( - [report["momentumBefore"]["x"], report["momentumBefore"]["y"]], abs=1e-10 - ) - - -@requires_node -def test_release_sized_dense_galaxy_never_reheats_or_ping_pongs_at_slider_extremes() -> None: - """The 542-body release shape stays contractive at both ordinary and 120/80 tuning. - - Endpoint displacement did not catch the regression: over-unity cross-system contact could - kick a solar-system COM one direction and project it back on the next frame while ending in - a plausible place. Sample every fixed step and require bounded radii/energy, signed phase, - painted clearances, and a low per-system COM-step tail for six seconds of solver time. - """ - report = _run_node( - """ - const make = () => { - const nodes = [{ id: 'black-hole', anchor_role: 'global', community_id: 'core', - system_anchor_id: 'black-hole', orbit_tier: 0, gravity_mass: 64, radius: 8, - x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'core-star', community_id: 'core', system_anchor_id: 'black-hole', - orbit_tier: 1, gravity_mass: 6, radius: 5, x: 52, y: 0, vx: 0, vy: 0 }]; - const links = [{ source: 'black-hole', target: 'core-star', rest_length: 52, - spring_strength: 0.08 }]; - for (let system = 0; system < 60; system++) { - const id = system === 0 ? 'aurora' : 'system-' + system; - const starId = id + '-star'; - const phase = 0.31 + system * 2.399963229728653; - const galacticRadius = 112 + system * 3.15; - const centerX = Math.cos(phase) * galacticRadius; - const centerY = Math.sin(phase) * galacticRadius * 0.84; - for (let member = 0; member < 9; member++) { - const localRadius = member === 0 ? 0 : (member === 1 ? 40 : 18 + member * 5); - const localPhase = phase + member * 2.399963229728653; - const nodeId = member === 0 ? starId - : (member === 1 ? id + '-planet' : id + '-planet-' + member); - nodes.push({ id: nodeId, community_id: id, - anchor_role: member === 0 ? 'community' : 'none', - system_anchor_id: starId, orbit_tier: member, - gravity_mass: member === 0 ? 8 + system % 5 : 1 + (member % 3) * 0.25, - radius: member === 0 ? 5.5 : 2.5, - x: centerX + Math.cos(localPhase) * localRadius, - y: centerY + Math.sin(localPhase) * localRadius, vx: 0, vy: 0 }); - if (member > 0) links.push({ source: starId, target: nodeId, - rest_length: localRadius, spring_strength: 0.08 }); - } - } - return { nodes, links }; - }; - const quantile = (items, portion) => { - const values = [...items].sort((a, b) => a - b); - return values[Math.floor((values.length - 1) * portion)]; - }; - const delta = (next, previous) => Math.atan2( - Math.sin(next - previous), Math.cos(next - previous)); - const run = (repel, link) => { - const { nodes, links } = make(); - // Admission chooses the exact carrier lane first; both global and local seed vectors - // are then composed in that final frame, as in layoutSeed 3031 at runtime. - I.establishGalaxyCarrierLanes(nodes, { gap: 8, layoutSeed: 3031 }); - I.seedGalaxyOrbits(nodes, 3031, 48, 32, false); - // Match galaxyIntegratorOptions(): Repel 60 yields live central softening 48. - I.seedGalaxySystemOrbits(nodes, 3031, 48, 48, false); - const separationPadding = I.galaxyOrbitalSeparationPadding(repel); - const separationStrength = I.galaxyOrbitalSeparationStrength(repel); - const options = { - layoutSeed: 3031, gravity: 48, softening: 32, centralSoftening: 48, - exactLimit: 64, theta: 0.85, - localPairFraction: 0.15, corePairMultiplier: 0.75, - includeBridges: false, includeMutualSystems: true, - mutualSystemGravityFraction: 0.12, mutualSystemSoftening: 80, - includeRelations: true, includeRelationSprings: false, - skipSystemAnchorRelations: true, skipOrbitalSystemRelations: true, - orbitScale: I.galaxyRelationOrbitScale(link), - relationConstraintStrengthMultiplier: 2, - relationConstraintResponseMultiplier: 1, - relationConstraintRate: 24, relationConstraintMaxCorrection: 12, - relationPadding: Math.max(1.5, separationPadding), - includeOrbitalSeparation: true, - orbitalSeparationPadding: separationPadding, - orbitalSeparationStrength: separationStrength, - crossCommunitySeparationPadding: 1.5, - crossCommunitySeparationStrength: separationStrength * 0.18, - orbitalSeparationMaxCorrection: 4, - orbitalSeparationMaxVelocityCorrection: 8, - preserveLocalTangentialVelocity: true, preserveSystemRadii: true, - skipSystemAnchorPairs: true, systemAnchorExclusionPadding: 1.5, - systemAnchorRepulsionRange: 6, systemAnchorRepulsionAcceleration: 0.12, - includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, - includeFarFieldConfinement: true, farFieldEnvelopeScale: 1.75, - farFieldMinimumRadius: 96, farFieldSoftFraction: 0.82, - farFieldAcceleration: 12, farFieldMaxAcceleration: 16, - localRelativeSpeedLimit: 48, timestep: 0.032, - inwardConvergence: false, wallClockSeconds: 1 / 30, - velocityDecay: 0.00005, speedLimit: 48, includeCollisions: false, - includeSystemPacking: false, - }; - const byId = new Map(nodes.map(node => [node.id, node])); - const tracked = ['aurora', 'system-11', 'system-23', 'system-35', - 'system-47', 'system-59']; - const local = new Map(tracked.map(id => { - const star = byId.get(id + '-star'), planet = byId.get( - id === 'aurora' ? 'aurora-planet' : id + '-planet'); - const dx = planet.x - star.x, dy = planet.y - star.y; - const dvx = planet.vx - star.vx, dvy = planet.vy - star.vy; - return [id, { star, planet, radius0: Math.hypot(dx, dy), - radiusMin: Math.hypot(dx, dy), radiusMax: Math.hypot(dx, dy), - angle: Math.atan2(dy, dx), direction: Math.sign(dx * dvy - dy * dvx), - reversals: 0, maxPhaseStep: 0, radialReversals: 0, - previousRadius: Math.hypot(dx, dy), previousRadial: 0, - kinetic0: 0.5 * star.gravity_mass * planet.gravity_mass - / (star.gravity_mass + planet.gravity_mass) * (dvx * dvx + dvy * dvy), - kineticMin: Infinity, kineticMax: 0 }]; - })); - const centers = () => new Map(nodes.filter(node => node.anchor_role === 'community') - .map(star => [String(star.id), { x: star.x, y: star.y, nodes: nodes.filter(node => - String(node.system_anchor_id || '') === String(star.id)), mass: star.gravity_mass }])); - let previousCenters = centers(); - const globalTracks = new Map(tracked.map(id => { - const center = previousCenters.get(id + '-star'), radius = Math.hypot(center.x, center.y); - const vx = center.nodes.reduce((sum, node) => sum - + node.gravity_mass * node.vx, 0) / center.mass; - const vy = center.nodes.reduce((sum, node) => sum - + node.gravity_mass * node.vy, 0) / center.mass; - return [id, { angle: Math.atan2(center.y, center.x), - direction: Math.sign(center.x * vy - center.y * vx), - radius0: radius, radiusMin: radius, radiusMax: radius, - reversals: 0, maxPhaseStep: 0 }]; - })); - const comSteps = [], crossCorrections = []; - let speedCaps = 0, localVelocityLimits = 0, maximumSpeed = 0; - let minimumBlackHoleClearance = Infinity, minimumStarClearance = Infinity; - let minimumOuterClearance = Infinity, maximumOrbitalShift = 0; - let alternatingRadialSteps = 0, relationApplications = 0; - for (let step = 0; step < 180; step++) { - const tick = I.integrateGalaxyLeapfrog(nodes, links, [], options); - speedCaps += tick.speedCapped ? 1 : 0; - localVelocityLimits += tick.systemVelocity.limitedSystems; - maximumSpeed = Math.max(maximumSpeed, tick.maximumSpeed); - maximumOrbitalShift = Math.max(maximumOrbitalShift, - tick.orbitalSeparation.maximumNodeShift || 0); - crossCorrections.push(tick.orbitalSeparation.crossCommunityCorrectionDistance || 0); - relationApplications += tick.relationConstraint.applied || 0; - const nextCenters = centers(); - nextCenters.forEach((center, id) => { - if (id === 'core') return; - const previous = previousCenters.get(id); - if (previous) comSteps.push(Math.hypot(center.x - previous.x, center.y - previous.y)); - }); - tracked.forEach(id => { - const item = local.get(id), star = item.star, planet = item.planet; - const dx = planet.x - star.x, dy = planet.y - star.y; - const radius = Math.hypot(dx, dy), angle = Math.atan2(dy, dx); - const phaseStep = delta(angle, item.angle); - if (item.direction && Math.sign(phaseStep) === -item.direction - && Math.abs(phaseStep) > 0.001) item.reversals++; - item.maxPhaseStep = Math.max(item.maxPhaseStep, Math.abs(phaseStep)); - const radialStep = radius - item.previousRadius; - if (item.previousRadial * radialStep < -0.0025) item.radialReversals++; - if (item.previousRadial * radialStep < -0.0025) alternatingRadialSteps++; - item.previousRadial = radialStep; - item.previousRadius = radius; - item.radiusMin = Math.min(item.radiusMin, radius); - item.radiusMax = Math.max(item.radiusMax, radius); - item.angle = angle; - const dvx = planet.vx - star.vx, dvy = planet.vy - star.vy; - const kinetic = 0.5 * star.gravity_mass * planet.gravity_mass - / (star.gravity_mass + planet.gravity_mass) * (dvx * dvx + dvy * dvy); - item.kineticMin = Math.min(item.kineticMin, kinetic); - item.kineticMax = Math.max(item.kineticMax, kinetic); - minimumStarClearance = Math.min(minimumStarClearance, - radius - star.radius - planet.radius - 1.5); - const center = nextCenters.get(star.id), global = globalTracks.get(id); - const globalRadius = Math.hypot(center.x, center.y); - const globalStep = delta(Math.atan2(center.y, center.x), global.angle); - if (global.direction && Math.sign(globalStep) === -global.direction - && Math.abs(globalStep) > 0.001) global.reversals++; - global.maxPhaseStep = Math.max(global.maxPhaseStep, Math.abs(globalStep)); - global.radiusMin = Math.min(global.radiusMin, globalRadius); - global.radiusMax = Math.max(global.radiusMax, globalRadius); - global.angle = Math.atan2(center.y, center.x); - }); - const envelope = tick.farFieldConfinement.envelopeRadius; - nodes.slice(1).forEach(node => { - minimumBlackHoleClearance = Math.min(minimumBlackHoleClearance, - Math.hypot(node.x, node.y) - nodes[0].radius - node.radius - 2.5); - minimumOuterClearance = Math.min(minimumOuterClearance, - envelope - Math.hypot(node.x, node.y) - node.radius); - }); - previousCenters = nextCenters; - } - return { - repel, link, separationStrength, - crossStrength: separationStrength * 0.18, - local: Object.fromEntries([...local].map(([id, item]) => [id, { - radius0: item.radius0, radiusMin: item.radiusMin, radiusMax: item.radiusMax, - reversals: item.reversals, radialReversals: item.radialReversals, - maxPhaseStep: item.maxPhaseStep, kinetic0: item.kinetic0, - kineticMin: item.kineticMin, kineticMax: item.kineticMax }])), - global: Object.fromEntries(globalTracks), - comStepMedian: quantile(comSteps, 0.5), comStepP95: quantile(comSteps, 0.95), - comStepMax: Math.max(...comSteps), - crossCorrectionP95: quantile(crossCorrections, 0.95), - crossCorrectionMax: Math.max(...crossCorrections), - speedCaps, localVelocityLimits, maximumSpeed, maximumOrbitalShift, - alternatingRadialSteps, relationApplications, - minimumBlackHoleClearance, minimumStarClearance, minimumOuterClearance, - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)), - }; - }; - emit({ ordinary: run(60, 8), maximum: run(120, 80) }); - """ - ) - for trial in report.values(): - assert trial["finite"] is True - assert trial["separationStrength"] == pytest.approx(1) - # This is the release bug's exact oracle: pressure 0.36 crossed the contact manifold. - assert trial["crossStrength"] == pytest.approx(0.18) - assert trial["speedCaps"] == 0 - assert trial["localVelocityLimits"] == 0 - assert trial["maximumSpeed"] < 48 - assert trial["maximumOrbitalShift"] <= 4 + 1e-9 - assert trial["relationApplications"] == 0 - assert trial["minimumBlackHoleClearance"] >= -1e-8 - assert trial["minimumStarClearance"] >= -1e-8 - assert trial["minimumOuterClearance"] >= -1e-8 - assert trial["comStepP95"] < 1.25, trial - assert trial["comStepMax"] < 3, trial - assert trial["crossCorrectionP95"] < 500, trial - assert trial["crossCorrectionMax"] < 900, trial - # Sparse eccentric perturbations are physical; the regression was frame-to-frame - # reversal across many systems. Across 1,080 tracked phase slices allow at most two. - assert sum(system["reversals"] for system in trial["local"].values()) <= 2 - for system in trial["local"].values(): - assert system["reversals"] <= 2 - assert system["radialReversals"] <= 12 - # 0.085 rad is 4.9 degrees per fixed slice. The unstable response reached - # 0.10415 here; retain margin for floating-point ordering without admitting it. - assert system["maxPhaseStep"] < 0.088 - assert system["radiusMin"] > system["radius0"] * 0.65 - assert system["radiusMax"] < system["radius0"] * 1.35 - assert system["kineticMin"] > system["kinetic0"] * 0.15 - assert system["kineticMax"] < system["kinetic0"] * 4 - for system_id, system in trial["global"].items(): - # A crowded galaxy may receive an occasional genuine near-field perturbation; - # four or fewer opposite samples in 180 slices is not the frame-to-frame ping-pong - # produced by the former over-unity contact response. - assert system["reversals"] == 0, (system_id, system, { - key: trial[key] for key in ("repel", "link", "comStepMedian", - "comStepP95", "comStepMax") - }) - assert system["maxPhaseStep"] < 0.08 - assert system["radiusMin"] > system["radius0"] * .99999 - assert system["radiusMax"] < system["radius0"] * 1.00001 - - -@requires_node -def test_drag_follow_uses_softened_source_mass_gravity_and_preserves_tangent() -> None: - report = _run_node( - """ - const run = ({ mass = 12, distance = 60, gravity = 48, - localGravitySetting = 48 } = {}) => { - const source = { id: 'star', x: 0, y: 0, vx: 0, vy: 0, - radius: 2, gravity_mass: mass, community_id: 'solar' }; - const follower = { id: 'planet', x: distance, y: 0, vx: 0, vy: 3, - radius: 2, gravity_mass: 1, community_id: 'solar' }; - const remote = { id: 'remote', x: 200, y: 40, vx: 2, vy: -1, - radius: 2, gravity_mass: 1, community_id: 'remote' }; - const beforeRemote = [remote.x, remote.y, remote.vx, remote.vy]; - const stats = I.applyDraggedNodeGravity(source, [{ - node: follower, - link: { source: 'star', target: 'planet', rest_length: 20, - spring_strength: 0.1 }, - }, { node: remote, link: null, proximity: 'field' }], { - gravity, localGravitySetting, linkSetting: 8, softening: 12, duration: 6, - maximumPull: 36, maximumImpulse: 8, padding: 1.5 }); - return { - follower: [follower.x, follower.y, follower.vx, follower.vy], - remote: [remote.x, remote.y, remote.vx, remote.vy], - beforeRemote, stats, - }; - }; - const coincidentSource = { id: 'same-star', x: 0, y: 0, - gravity_mass: 12, community_id: 'same' }; - const coincident = { id: 'same-planet', x: 0, y: 0, vx: 1, vy: 2, - gravity_mass: 1, community_id: 'same' }; - const coincidentStats = I.applyDraggedNodeGravity(coincidentSource, - [{ node: coincident }], { gravity: 100 }); - emit({ - heavy: run(), light: run({ mass: 6 }), - near: run({ distance: 60 }), far: run({ distance: 120 }), - zero: run({ gravity: 0 }), - coincident: [coincident.x, coincident.y, coincident.vx, coincident.vy], - coincidentStats, - }); - """ - ) - assert report["heavy"]["stats"]["applied"] == 2 - assert report["heavy"]["stats"]["maximumAcceleration"] == pytest.approx( - report["light"]["stats"]["maximumAcceleration"] * 2, rel=1e-12 - ) - assert report["near"]["stats"]["maximumAcceleration"] > report["far"]["stats"][ - "maximumAcceleration" - ] - assert report["near"]["stats"]["maximumPull"] <= 36 - assert report["far"]["stats"]["maximumPull"] <= 36 - assert report["heavy"]["follower"][0] < 60 - assert report["heavy"]["follower"][2] < 0 - assert report["heavy"]["follower"][3] == pytest.approx(3) - assert report["heavy"]["remote"] != report["heavy"]["beforeRemote"] - assert report["heavy"]["remote"][0] < report["heavy"]["beforeRemote"][0] - assert report["heavy"]["remote"][1] < report["heavy"]["beforeRemote"][1] - assert report["zero"]["follower"] == pytest.approx(report["heavy"]["follower"]) - assert report["zero"]["remote"] == pytest.approx(report["heavy"]["remote"]) - assert report["coincident"] == pytest.approx([0, 0, 1, 2]) - assert report["coincidentStats"]["applied"] == 0 - - -@requires_node -def test_live_drag_force_is_fixed_step_acceleration_not_pointer_displacement() -> None: - report = _run_node( - """ - const primary = { id: 'star', x: 0, y: 0, vx: 0, vy: 0, - radius: 2, gravity_mass: 12, community_id: 'solar' }; - const follower = { id: 'planet', x: 60, y: 0, vx: 0, vy: 3, - radius: 2, gravity_mass: 1, community_id: 'solar' }; - const before = [follower.x, follower.y, follower.vx, follower.vy]; - const stats = I.applyDraggedNodeAcceleration(primary, [{ node: follower }], { - gravity: 48, localGravitySetting: 48, softening: 12, - }); - const expected = I.galaxyLocalGravityConstant(48) * 2 * 12 * 60 - / Math.pow(60 * 60 + 12 * 12, 1.5); - const zeroFollower = { id: 'zero-planet', x: 60, y: 0, vx: 0, vy: 3, - radius: 2, gravity_mass: 1, community_id: 'solar' }; - const zeroStats = I.applyDraggedNodeAcceleration(primary, [{ node: zeroFollower }], { - gravity: 0, localGravitySetting: 48, softening: 12, - }); - emit({ before, after: [follower.x, follower.y, follower.vx, follower.vy], - stats, expected, - zeroAfter: [zeroFollower.x, zeroFollower.y, zeroFollower.vx, zeroFollower.vy], - zeroStats }); - """ - ) - assert report["stats"]["applied"] == 1 - assert report["stats"]["maximumPull"] == 0 - assert report["stats"]["maximumAcceleration"] == pytest.approx( - report["expected"], rel=1e-12 - ) - assert report["after"][:2] == report["before"][:2] - assert report["after"][2] == pytest.approx(-report["expected"]) - assert report["after"][3] == pytest.approx(report["before"][3]) - assert report["zeroAfter"] == pytest.approx(report["after"]) - assert report["zeroStats"]["maximumAcceleration"] == pytest.approx( - report["stats"]["maximumAcceleration"], rel=1e-12 - ) - - -@requires_node -def test_connected_galaxy_drag_keeps_followers_and_unrelated_systems_bounded() -> None: - """A cursor-owned source obeys painted bounds without turning bodies into projectiles.""" - report = _run_node( - """ - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 64, radius: 12, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'dragged', community_id: 'cursor', gravity_mass: 8, radius: 4, - x: 100, y: 0, vx: 0, vy: 0 }, - { id: 'follower-a', community_id: 'follower-a', gravity_mass: 2, radius: 3, - x: 132, y: 0, vx: 0, vy: 2 }, - { id: 'follower-b', community_id: 'follower-b', gravity_mass: 2, radius: 3, - x: 112, y: 30, vx: -1, vy: 1 }, - { id: 'remote-star', community_id: 'remote', gravity_mass: 5, radius: 4, - x: -130, y: 30, vx: 0, vy: -2 }, - { id: 'remote-moon', community_id: 'remote', gravity_mass: 1, radius: 2, - x: -112, y: 36, vx: 1, vy: -1 }, - ]; - const links = [ - { source: 'dragged', target: 'follower-a', rest_length: 30, spring_strength: 0.1 }, - { source: 'dragged', target: 'follower-b', rest_length: 30, spring_strength: 0.1 }, - ]; - const common = { - gravity: 48, central: true, includeFarFieldConfinement: true, - includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, - includeMutualSystems: true, mutualSystemGravityFraction: 0.12, - mutualSystemSoftening: 80, includeCollisions: false, - includeRelations: true, includeRelationSprings: true, - orbitScale: 0.25, relationStrengthMultiplier: 2, - relationConstraintRate: 24, relationConstraintMaxCorrection: 12, - relationPadding: 12, includeOrbitalSeparation: true, - orbitalSeparationPadding: 12, orbitalSeparationStrength: 0.8, - crossCommunitySeparationPadding: 1.5, crossCommunitySeparationStrength: 0.144, - orbitalSeparationMaxCorrection: 4, orbitalSeparationMaxVelocityCorrection: 8, - localRelativeSpeedLimit: 16, timestep: 0.021328125, - wallClockSeconds: 1 / 30, velocityDecay: 0.00005, speedLimit: 24, - }; - /* Establish the cached envelope, then make a gradual cursor path that crosses it. */ - I.applyGalaxyFarFieldConfinement(nodes, common); - const envelope = I.galaxyFarFieldEnvelope(nodes, common).envelopeRadius; - const dragged = nodes[1], followerA = nodes[2], followerB = nodes[3]; - dragged.x = envelope - 100; dragged.y = 0; - followerA.x = envelope - 68; followerA.y = 0; - followerB.x = envelope - 88; followerB.y = 30; - const targets = [ - [envelope - 70, 0], [envelope - 35, 15], [envelope + 5, 20], - [envelope + 45, 10], [envelope + 80, -5], - ]; - const followers = [ - { node: followerA, link: links[0] }, { node: followerB, link: links[1] }, - ]; - let finite = true, maximumSpeed = 0, maximumFollowerStep = 0; - let maximumLinkDistance = 0, maximumRemoteRadius = 0, maximumRemoteStep = 0; - let dragAcceleration = 0, dragPull = 0; - let requestedBeyondEnvelope = false, minimumSourceOuterClearance = Infinity; - let sourceEdgeContact = false; - for (const [x, y] of targets) { - const beforeFollowers = [followerA, followerB].map(node => [node.x, node.y]); - const beforeRemote = nodes.slice(4).map(node => [node.x, node.y]); - dragged.x = x; dragged.y = y; dragged.vx = 0; dragged.vy = 0; - const tick = I.integrateGalaxyLeapfrog(nodes, links, [], { - ...common, fixedNodeId: 'dragged', dragSource: dragged, dragFollowers: followers, - }); - requestedBeyondEnvelope = requestedBeyondEnvelope - || Math.hypot(x, y) + dragged.radius > envelope + 1e-8; - const sourceClearance = envelope - (Math.hypot(dragged.x, dragged.y) + dragged.radius); - minimumSourceOuterClearance = Math.min(minimumSourceOuterClearance, sourceClearance); - sourceEdgeContact = sourceEdgeContact || Math.abs(sourceClearance) <= 1e-8; - dragAcceleration = Math.max(dragAcceleration, tick.dragGravity.maximumAcceleration); - dragPull = Math.max(dragPull, tick.dragGravity.maximumPull); - maximumSpeed = Math.max(maximumSpeed, tick.maximumSpeed); - [followerA, followerB].forEach((node, index) => { - maximumFollowerStep = Math.max(maximumFollowerStep, - Math.hypot(node.x - beforeFollowers[index][0], node.y - beforeFollowers[index][1])); - }); - links.forEach(link => { - const source = nodes.find(node => node.id === link.source); - const target = nodes.find(node => node.id === link.target); - maximumLinkDistance = Math.max(maximumLinkDistance, - Math.hypot(source.x - target.x, source.y - target.y)); - }); - nodes.slice(4).forEach((node, index) => { - maximumRemoteRadius = Math.max(maximumRemoteRadius, - Math.hypot(node.x, node.y) + node.radius); - maximumRemoteStep = Math.max(maximumRemoteStep, - Math.hypot(node.x - beforeRemote[index][0], node.y - beforeRemote[index][1])); - }); - finite = finite && nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)); - } - const held = [dragged.x, dragged.y]; - let releaseSpeed = 0; - for (let step = 0; step < 20; step++) { - const tick = I.integrateGalaxyLeapfrog(nodes, links, [], common); - releaseSpeed = Math.max(releaseSpeed, tick.maximumSpeed); - finite = finite && nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)); - } - emit({ - envelope, requestedBeyondEnvelope, minimumSourceOuterClearance, sourceEdgeContact, - finite, maximumSpeed, releaseSpeed, - maximumFollowerStep, maximumLinkDistance, maximumRemoteRadius, maximumRemoteStep, - dragAcceleration, dragPull, held, released: [dragged.x, dragged.y], - }); - """ - ) - assert report["requestedBeyondEnvelope"] is True - assert report["minimumSourceOuterClearance"] >= -1e-8 - assert report["sourceEdgeContact"] is True - assert report["finite"] is True - assert report["dragAcceleration"] > 0 - assert report["dragPull"] > 0 - assert report["maximumSpeed"] <= 24, report - assert report["releaseSpeed"] <= 24, report - # Fixed geometry and the relation cap limit every cursor sample; neither link may run away. - assert report["maximumFollowerStep"] <= 48 - assert report["maximumLinkDistance"] <= 180 - assert report["maximumRemoteRadius"] <= report["envelope"] + 1e-8 - assert report["maximumRemoteStep"] <= 32 - # Removing fixedNodeId/dragSource lets the former cursor point resume normal physics. - assert math.dist(report["held"], report["released"]) > 1e-4 - - -@requires_node -@pytest.mark.parametrize( - ("drag_community", "expect_fixed_system_nodes"), - [("core", False), ("drag-system", True)], -) -def test_dragging_connected_core_node_over_black_hole_keeps_the_annulus_stable( - drag_community: str, expect_fixed_system_nodes: bool, -) -> None: - """The pointer may target the hole centre, but its painted body cannot cover it.""" - report = _run_node( - "const dragCommunity = " + repr(drag_community) - + ";\nconst externalSystem = " + ("true" if expect_fixed_system_nodes else "false") - + ";\n" + """ - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 64, radius: 12, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'dragged', community_id: dragCommunity, - anchor_role: externalSystem ? 'community' : 'none', - system_anchor_id: externalSystem ? 'dragged' : 'black-hole', - gravity_mass: 8, radius: 4, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'core-follower-a', community_id: dragCommunity, system_anchor_id: 'dragged', - gravity_mass: 2, radius: 3, x: 26, y: 0, vx: 0, vy: 2 }, - { id: 'core-follower-b', community_id: dragCommunity, system_anchor_id: 'dragged', - gravity_mass: 2, radius: 3, x: 0, y: 28, vx: -2, vy: 0 }, - { id: 'remote-star', anchor_role: 'community', community_id: 'remote', - system_anchor_id: 'remote-star', gravity_mass: 5, radius: 4, - x: -100, y: 25, vx: 0, vy: -2 }, - { id: 'remote-moon', community_id: 'remote', system_anchor_id: 'remote-star', - gravity_mass: 1, radius: 2, x: -84, y: 31, vx: 1, vy: -1 }, - ]; - const links = [ - { source: 'dragged', target: 'core-follower-a', rest_length: 24, spring_strength: 0.1 }, - { source: 'dragged', target: 'core-follower-b', rest_length: 24, spring_strength: 0.1 }, - ]; - const dragged = nodes[1], followers = [ - { node: nodes[2], link: links[0] }, { node: nodes[3], link: links[1] }, - ]; - const options = { - gravity: 48, central: true, fixedNodeId: 'dragged', dragSource: dragged, - dragFollowers: followers, includeFarFieldConfinement: true, - includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, - includeMutualSystems: true, mutualSystemGravityFraction: 0.12, - mutualSystemSoftening: 80, includeCollisions: false, - includeRelations: true, includeRelationSprings: true, orbitScale: 0.25, - relationStrengthMultiplier: 2, relationConstraintRate: 24, - relationConstraintMaxCorrection: 12, relationPadding: 12, - includeOrbitalSeparation: true, orbitalSeparationPadding: 12, - orbitalSeparationStrength: 0.8, crossCommunitySeparationPadding: 1.5, - crossCommunitySeparationStrength: 0.144, orbitalSeparationMaxCorrection: 4, - orbitalSeparationMaxVelocityCorrection: 8, localRelativeSpeedLimit: 16, - timestep: 0.021328125, wallClockSeconds: 1 / 30, - velocityDecay: 0.00005, speedLimit: 24, - }; - I.applyGalaxyFarFieldConfinement(nodes, options); - const envelope = I.galaxyFarFieldEnvelope(nodes, options).envelopeRadius; - let minimumClearance = Infinity, maximumFollowerStep = 0, maximumLinkDistance = 0; - let maximumRemoteRadius = 0, maximumSpeed = 0, dragPull = 0, finite = true; - let fixedSystemNodes = 0, skippedFixedEndpoint = 0; - let outerFollowerClearance = Infinity, minimumSourceOuterClearance = Infinity; - let maximumOuterFollowerStep = 0, requestedBeyondEnvelope = false, sourceEdgeContact = false; - for (let step = 0; step < 48; step++) { - const before = nodes.slice(2, 4).map(node => [node.x, node.y]); - const remoteBefore = nodes.slice(4).map(node => [node.x, node.y]); - /* This is the adversarial pointer target. The final horizon owns the paint phase. */ - dragged.x = 0; dragged.y = 0; dragged.vx = 0; dragged.vy = 0; - const tick = I.integrateGalaxyLeapfrog(nodes, links, [], options); - maximumSpeed = Math.max(maximumSpeed, tick.maximumSpeed); - dragPull = Math.max(dragPull, tick.dragGravity.maximumPull); - fixedSystemNodes += tick.blackHoleExclusion.fixedSystemNodes; - skippedFixedEndpoint += tick.relationConstraint.skippedFixedEndpoint; - nodes.slice(1).forEach(node => { - minimumClearance = Math.min(minimumClearance, - Math.hypot(node.x, node.y) - nodes[0].radius - node.radius - - options.blackHoleExclusionPadding); - }); - nodes.slice(2, 4).forEach((node, index) => { - maximumFollowerStep = Math.max(maximumFollowerStep, - Math.hypot(node.x - before[index][0], node.y - before[index][1])); - }); - links.forEach(link => { - const target = nodes.find(node => node.id === link.target); - maximumLinkDistance = Math.max(maximumLinkDistance, - Math.hypot(dragged.x - target.x, dragged.y - target.y)); - }); - nodes.slice(4).forEach((node, index) => { - maximumRemoteRadius = Math.max(maximumRemoteRadius, - Math.hypot(node.x, node.y) + node.radius); - maximumFollowerStep = Math.max(maximumFollowerStep, - Math.hypot(node.x - remoteBefore[index][0], node.y - remoteBefore[index][1])); - }); - finite = finite && nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)); - } - const centreHeld = [dragged.x, dragged.y]; - /* An external pointer may request a source beyond the envelope, but the painted source - and its nonfixed followers must remain inside it throughout a long, gradual outward - drag. This is the former 400-slice runaway: a skipped fixed system let followers - drift hundreds of units out, then snap back only after release. */ - if (externalSystem) { - const startRadius = nodes[0].radius + dragged.radius + options.blackHoleExclusionPadding; - const endRadius = envelope + 320; - for (let step = 0; step < 400; step++) { - const before = nodes.slice(2, 4).map(node => [node.x, node.y]); - const targetX = startRadius + (endRadius - startRadius) * (step + 1) / 400; - dragged.x = targetX; dragged.y = 0; dragged.vx = 0; dragged.vy = 0; - const tick = I.integrateGalaxyLeapfrog(nodes, links, [], options); - requestedBeyondEnvelope = requestedBeyondEnvelope - || targetX + dragged.radius > envelope + 1e-8; - const sourceClearance = envelope - (Math.hypot(dragged.x, dragged.y) + dragged.radius); - minimumSourceOuterClearance = Math.min(minimumSourceOuterClearance, sourceClearance); - sourceEdgeContact = sourceEdgeContact || Math.abs(sourceClearance) <= 1e-8; - maximumSpeed = Math.max(maximumSpeed, tick.maximumSpeed); - dragPull = Math.max(dragPull, tick.dragGravity.maximumPull); - fixedSystemNodes += tick.blackHoleExclusion.fixedSystemNodes; - skippedFixedEndpoint += tick.relationConstraint.skippedFixedEndpoint; - nodes.slice(1).forEach(node => { - minimumClearance = Math.min(minimumClearance, - Math.hypot(node.x, node.y) - nodes[0].radius - node.radius - - options.blackHoleExclusionPadding); - }); - nodes.slice(2, 4).forEach((node, index) => { - outerFollowerClearance = Math.min(outerFollowerClearance, - envelope - (Math.hypot(node.x, node.y) + node.radius)); - maximumOuterFollowerStep = Math.max(maximumOuterFollowerStep, - Math.hypot(node.x - before[index][0], node.y - before[index][1])); - }); - finite = finite && nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)); - } - } - const held = [dragged.x, dragged.y]; - let releaseSpeed = 0, maximumReleaseFollowerStep = 0; - for (let step = 0; step < 20; step++) { - const before = nodes.slice(2, 4).map(node => [node.x, node.y]); - const tick = I.integrateGalaxyLeapfrog(nodes, links, [], { - ...options, fixedNodeId: null, dragSource: null, dragFollowers: [], - }); - releaseSpeed = Math.max(releaseSpeed, tick.maximumSpeed); - nodes.slice(2, 4).forEach((node, index) => { - maximumReleaseFollowerStep = Math.max(maximumReleaseFollowerStep, - Math.hypot(node.x - before[index][0], node.y - before[index][1])); - }); - finite = finite && nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)); - } - emit({ - envelope, minimumClearance, maximumFollowerStep, maximumLinkDistance, - maximumRemoteRadius, maximumSpeed, releaseSpeed, dragPull, finite, - fixedSystemNodes, skippedFixedEndpoint, requestedBeyondEnvelope, sourceEdgeContact, - outerFollowerClearance, minimumSourceOuterClearance, maximumOuterFollowerStep, - maximumReleaseFollowerStep, - centreHeld, held, released: [dragged.x, dragged.y], - anchor: [nodes[0].x, nodes[0].y, nodes[0].vx, nodes[0].vy], - draggedRadius: Math.hypot(centreHeld[0], centreHeld[1]), - paintedHorizon: nodes[0].radius + dragged.radius + options.blackHoleExclusionPadding, - }); - """ - ) - assert report["finite"] is True - assert report["anchor"] == pytest.approx([0, 0, 0, 0], abs=1e-12) - # The fixed source is projected to the event horizon, not allowed to paint at the centre. - assert report["draggedRadius"] == pytest.approx(report["paintedHorizon"], abs=1e-8) - assert report["minimumClearance"] >= -1e-8 - assert report["dragPull"] > 0 - # The dragged cluster may be the anchor community or a pointer-owned external system. The - # latter must use its dedicated horizon path, while both skip direct spring correction. - if expect_fixed_system_nodes: - assert report["fixedSystemNodes"] > 0 - # Pointer targets beyond the cached envelope are requests, not paint positions: the - # source must meet the same finite outer boundary as every follower while held. - assert report["requestedBeyondEnvelope"] is True - assert report["minimumSourceOuterClearance"] >= -1e-8 - assert report["sourceEdgeContact"] is True - assert report["outerFollowerClearance"] >= -1e-8 - assert report["maximumOuterFollowerStep"] <= 48 - assert report["maximumReleaseFollowerStep"] <= 48 - else: - assert report["fixedSystemNodes"] == 0 - assert report["skippedFixedEndpoint"] > 0 - assert report["maximumSpeed"] <= 24 - assert report["releaseSpeed"] <= 24 - assert report["maximumFollowerStep"] <= 48 - assert report["maximumLinkDistance"] <= 96 - assert report["maximumRemoteRadius"] <= report["envelope"] + 1e-8 - assert math.dist(report["held"], report["released"]) > 1e-4 - - -@requires_node -@pytest.mark.parametrize("drag_id", ["star", "planet"]) -def test_dragging_star_or_planet_across_stellar_surface_stays_bounded(drag_id: str) -> None: - """A fixed source may cross a stellar surface without a follower feedback runaway.""" - report = _run_node( - "const dragId = " + repr(drag_id) + ";\n" + """ - const nodes = [ - { id: 'bh', anchor_role: 'global', community_id: 'core', gravity_mass: 8, - radius: 10, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'star', community_id: 'solar', gravity_mass: 14, - radius: 5, x: 54, y: 0, vx: 0, vy: 0 }, - { id: 'planet', orbit_tier: 1, community_id: 'solar', gravity_mass: 1, - radius: 3, x: 64, y: 0, vx: 0, vy: 0 }, - { id: 'moon', orbit_tier: 2, community_id: 'solar', gravity_mass: 1, - radius: 3, x: 54, y: 16, vx: 0, vy: 0 }, - { id: 'remote-star', community_id: 'remote', gravity_mass: 10, - radius: 5, x: -60, y: 0, vx: 0, vy: 0 }, - { id: 'remote-planet', orbit_tier: 1, community_id: 'remote', gravity_mass: 1, - radius: 3, x: -48, y: 0, vx: 0, vy: 0 }, - ]; - const links = [ - { source: 'star', target: 'planet', rest_length: 10, spring_strength: 0.08 }, - { source: 'star', target: 'moon', rest_length: 16, spring_strength: 0.08 }, - ]; - const dragSourceNode = nodes.find(node => node.id === dragId); - const star = nodes.find(node => node.id === 'star'); - const planet = nodes.find(node => node.id === 'planet'); - const target = dragId === 'star' ? [planet.x, planet.y] : [star.x, star.y]; - const followers = nodes.filter(node => node !== dragSourceNode && node.id !== 'bh') - .map(node => ({ node, link: links.find(link => link.source === node.id - || link.target === node.id) || null })); - const options = { - gravity: 48, central: true, fixedNodeId: dragId, dragSource: dragSourceNode, - dragFollowers: followers, softening: 12, centralSoftening: 40, - includeMutualSystems: true, mutualSystemGravityFraction: 0.12, - mutualSystemSoftening: 80, includeCollisions: false, - includeRelations: true, includeRelationSprings: false, - skipSystemAnchorRelations: true, relationStrengthMultiplier: 1, - relationConstraintRate: 24, relationConstraintMaxCorrection: 12, - includeOrbitalSeparation: true, orbitalSeparationPadding: 1.5, - orbitalSeparationStrength: 0.8, orbitalSeparationMaxCorrection: 4, - orbitalSeparationMaxVelocityCorrection: 8, preserveLocalTangentialVelocity: true, - skipSystemAnchorPairs: true, systemAnchorExclusionPadding: 1.5, - crossCommunitySeparationPadding: 1.5, crossCommunitySeparationStrength: 0.144, - includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, - includeFarFieldConfinement: true, farFieldEnvelopeScale: 1.25, - farFieldMinimumRadius: 96, farFieldSoftFraction: 0.82, - farFieldAcceleration: 12, farFieldMaxAcceleration: 16, inwardConvergence: true, - timestep: 0.021328125, wallClockSeconds: 1 / 30, - velocityDecay: 0.00005, speedLimit: 24, localRelativeSpeedLimit: 16, - }; - let anchorContacts = 0, minimumStarClearance = Infinity, maximumFollowerStep = 0; - let maximumSpeed = 0, finite = true, envelope = 0; - for (let step = 0; step < 120; step++) { - const before = followers.map(follower => [follower.node.x, follower.node.y]); - dragSourceNode.x = target[0]; dragSourceNode.y = target[1]; - dragSourceNode.vx = 0; dragSourceNode.vy = 0; - const tick = I.integrateGalaxyLeapfrog(nodes, links, [], options); - anchorContacts += tick.systemAnchorExclusion.contacts; - envelope = tick.farFieldConfinement.envelopeRadius; - maximumSpeed = Math.max(maximumSpeed, tick.maximumSpeed); - followers.forEach((follower, index) => { - maximumFollowerStep = Math.max(maximumFollowerStep, - Math.hypot(follower.node.x - before[index][0], follower.node.y - before[index][1])); - }); - [planet, nodes.find(node => node.id === 'moon')].forEach(satellite => { - if (satellite === star) return; - minimumStarClearance = Math.min(minimumStarClearance, - Math.hypot(satellite.x - star.x, satellite.y - star.y) - - star.radius - satellite.radius - options.systemAnchorExclusionPadding); - }); - finite = finite && nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)); - } - const held = [dragSourceNode.x, dragSourceNode.y]; - let maximumReleaseStep = 0; - for (let step = 0; step < 40; step++) { - const before = nodes.map(node => [node.x, node.y]); - const tick = I.integrateGalaxyLeapfrog(nodes, links, [], { - ...options, fixedNodeId: null, dragSource: null, dragFollowers: [], - }); - maximumSpeed = Math.max(maximumSpeed, tick.maximumSpeed); - maximumReleaseStep = Math.max(maximumReleaseStep, ...nodes.map((node, index) => - Math.hypot(node.x - before[index][0], node.y - before[index][1]))); - finite = finite && nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)); - } - emit({ - anchorContacts, minimumStarClearance, maximumFollowerStep, maximumReleaseStep, - maximumSpeed, finite, held, released: [dragSourceNode.x, dragSourceNode.y], - outerBounded: nodes.slice(1).every(node => - Math.hypot(node.x, node.y) + node.radius <= envelope + 1e-8), - }); - """ - ) - assert report["anchorContacts"] > 0 - assert report["minimumStarClearance"] >= -1e-9 - assert report["finite"] is True - assert report["outerBounded"] is True - assert report["maximumSpeed"] <= 24 - assert report["maximumFollowerStep"] <= 32 - assert report["maximumReleaseStep"] <= 32 - assert math.dist(report["held"], report["released"]) > 1e-4 - - -@requires_node -def test_dense_stellar_surface_exclusion_keeps_com_momentum_and_tangential_phase() -> None: - """Many simultaneous planets must clear a star without a contact-induced slingshot.""" - report = _run_node( - """ - const star = { id: 'star', anchor_role: 'community', community_id: 'solar', - gravity_mass: 20, radius: 5, x: 40, y: -12, vx: 1.5, vy: -0.75 }; - const nodes = [star]; - for (let index = 0; index < 16; index++) { - const angle = index * Math.PI * 2 / 16; - const radius = 6; // strictly inside the 5 + 2 + 1.5 painted stellar surface - nodes.push({ id: 'planet-' + index, community_id: 'solar', gravity_mass: 1, - radius: 2, x: star.x + Math.cos(angle) * radius, - y: star.y + Math.sin(angle) * radius, - vx: star.vx - Math.sin(angle) * 3, - vy: star.vy + Math.cos(angle) * 3 }); - } - const totals = () => nodes.reduce((sum, node) => ({ - mass: sum.mass + node.gravity_mass, - x: sum.x + node.gravity_mass * node.x, - y: sum.y + node.gravity_mass * node.y, - px: sum.px + node.gravity_mass * node.vx, - py: sum.py + node.gravity_mass * node.vy, - }), { mass: 0, x: 0, y: 0, px: 0, py: 0 }); - const before = totals(); - const exclusion = I.applyGalaxySystemAnchorExclusion(nodes, { padding: 1.5 }); - const after = totals(); - emit({ - exclusion, - comShift: Math.hypot(after.x / after.mass - before.x / before.mass, - after.y / after.mass - before.y / before.mass), - momentumDelta: Math.hypot(after.px - before.px, after.py - before.py), - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)), - }); - """ - ) - assert report["exclusion"]["contacts"] >= 16 - assert report["exclusion"]["minimumClearance"] >= -1e-10 - assert report["comShift"] <= 1e-10 - assert report["momentumDelta"] <= 1e-10 - assert report["exclusion"]["tangentialVelocityRemoved"] == 0 - assert report["finite"] is True - - -@requires_node -def test_dominant_star_has_smooth_mass_balanced_repulsion_before_its_hard_surface() -> None: - """A star's surface pressure beats its well without becoming generic pair repulsion.""" - report = _run_node( - """ - const fixture = innerMass => [ - { id: 'star', anchor_role: 'community', community_id: 'solar', gravity_mass: 8, - radius: 5, x: 0, y: 0, vx: 1, vy: -2 }, - // 9.5 is the exact painted boundary: 5 + 3 radii + 1.5 padding. - { id: 'inner', community_id: 'solar', orbit_tier: 1, gravity_mass: innerMass, - radius: 3, x: 9.5, y: 0, vx: 1, vy: 2 }, - { id: 'outer', community_id: 'solar', orbit_tier: 2, gravity_mass: 1, - radius: 3, x: 100, y: 0, vx: 1, vy: -2 }, - ]; - const trial = (innerMass, pressure = 0.12) => { - const nodes = fixture(innerMass); - const before = nodes.map(node => [node.vx, node.vy]); - const momentum = nodes.reduce((total, node) => [ - total[0] + node.gravity_mass * node.vx, - total[1] + node.gravity_mass * node.vy, - ], [0, 0]); - const stats = I.applyGalaxySystemAnchorGravity(nodes, { - gravity: 0, alpha: 1, softening: 12, repulsionPadding: 1.5, - repulsionRange: 6, repulsionAcceleration: pressure, accelerationCap: 100, - }); - const afterMomentum = nodes.reduce((total, node) => [ - total[0] + node.gravity_mass * node.vx, - total[1] + node.gravity_mass * node.vy, - ], [0, 0]); - return { before, after: nodes.map(node => [node.vx, node.vy]), stats, - momentumDelta: [afterMomentum[0] - momentum[0], afterMomentum[1] - momentum[1]], - radialRelative: nodes[1].vx - nodes[0].vx, - outerRadialRelative: nodes[2].vx - nodes[0].vx, - tangentialRelative: nodes[1].vy - nodes[0].vy, - }; - }; - emit({ light: trial(1), heavy: trial(9), - lightControl: trial(1, 0), heavyControl: trial(9, 0) }); - """ - ) - light, heavy = report["light"], report["heavy"] - controls = (report["lightControl"], report["heavyControl"]) - for trial, control in zip((light, heavy), controls): - stats = trial["stats"] - assert stats["systems"] == stats["anchors"] == 1 - assert stats["satellites"] == 2 - assert stats["repulsions"] == 1 - assert stats["repulsionPadding"] == pytest.approx(1.5) - 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) - assert stats["eligibleStellarAnchors"] == 1 - assert stats["fallbackAnchors"] == 0 - assert stats["globalAnchors"] == 0 - assert stats["stellarFloorActive"] is True - assert stats["surfaceRepulsions"] == 1 - assert stats["maximumRepulsion"] > 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 - # attraction by the requested bounded margin at the painted surface. Comparing with - # pressure disabled isolates the radial correction from the shared gravity field. - assert trial["radialRelative"] == pytest.approx(stats["maximumNetRepulsion"]) - assert trial["radialRelative"] - control["radialRelative"] == pytest.approx( - stats["maximumRepulsion"] - ) - # The named star is an external local carrier. Surface pressure changes only the - # planet's phase-space state; aggregate system momentum is intentionally no longer - # conserved through an artificial equal-and-opposite star recoil. - assert trial["after"][0] == pytest.approx(trial["before"][0], abs=1e-12) - assert trial["tangentialRelative"] == pytest.approx(4) - # The inner planet is not promoted into a second pressure source: enabling its surface - # correction leaves the remote planet's star-relative radial response unchanged. - assert trial["outerRadialRelative"] == pytest.approx( - control["outerRadialRelative"], abs=1e-12 - ) - # Surface strength depends on the star field and geometry, not satellite evidence mass. - assert light["stats"]["maximumRepulsion"] == pytest.approx( - heavy["stats"]["maximumRepulsion"], abs=1e-12 - ) - - -@requires_node -def test_live_gravity_stellar_pressure_is_outward_at_the_surface_and_tapers_smoothly() -> None: - """The soft stellar surface beats live attraction without moving its local star.""" - report = _run_node( - """ - const trial = (gravity, distance, repulsionAcceleration) => { - const nodes = [ - { id: 'star', anchor_role: 'community', community_id: 'solar', gravity_mass: 8, - radius: 5, x: 0, y: 0, vx: 1, vy: -2 }, - { id: 'planet', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 1, - gravity_mass: 1, radius: 3, x: distance, y: 0, vx: 1, vy: 2 }, - ]; - const before = nodes.map(node => ({ vx: node.vx, vy: node.vy })); - const momentumBefore = ['vx', 'vy'].map(axis => nodes.reduce((sum, node) => - sum + node.gravity_mass * node[axis], 0)); - const options = { gravity, softening: 32, alpha: 1, - repulsionPadding: 1.5, repulsionRange: 6 }; - if (repulsionAcceleration !== undefined) { - options.repulsionAcceleration = repulsionAcceleration; - } - const stats = I.applyGalaxySystemAnchorGravity(nodes, options); - const momentumAfter = ['vx', 'vy'].map(axis => nodes.reduce((sum, node) => - sum + node.gravity_mass * node[axis], 0)); - return { - stats, - starBefore: before[0], starAfter: { vx: nodes[0].vx, vy: nodes[0].vy }, - relativeRadial: (nodes[1].vx - nodes[0].vx) - - (before[1].vx - before[0].vx), - relativeTangential: nodes[1].vy - nodes[0].vy, - momentumDelta: momentumAfter.map((value, index) => value - momentumBefore[index]), - finite: nodes.every(node => [node.vx, node.vy].every(Number.isFinite)), - }; - }; - const hardDistance = 5 + 3 + 1.5; - const pressureEdge = hardDistance + 6; - const inside = trial(48, hardDistance - 0.75); - const surface = trial(48, hardDistance); - const surfaceWithoutPressure = trial(48, hardDistance, 0); - const edge = trial(48, pressureEdge); - const edgeWithoutPressure = trial(48, pressureEdge, 0); - const maximum = trial(400, hardDistance); - emit({ hardDistance, pressureEdge, inside, surface, surfaceWithoutPressure, - edge, edgeWithoutPressure, maximum }); - """ - ) - for trial in (report["inside"], report["surface"], report["edge"], report["maximum"]): - assert trial["finite"] is True - assert trial["starAfter"] == pytest.approx(trial["starBefore"], abs=1e-12) - assert trial["relativeTangential"] == pytest.approx(4, abs=1e-12) - # At and just inside the painted 9.5-unit stellar surface, net star-relative acceleration - # must point outward even with the ordinary gravity-48 central well active. - assert report["inside"]["relativeRadial"] > 0 - assert report["surface"]["relativeRadial"] > 0 - assert report["inside"]["stats"]["repulsions"] == 1 - assert report["surface"]["stats"]["repulsions"] == 1 - assert report["inside"]["stats"]["surfaceRepulsions"] == 1 - assert report["surface"]["stats"]["surfaceRepulsions"] == 1 - assert report["surface"]["stats"]["maximumSampledAttraction"] > 0 - assert report["surface"]["stats"]["maximumNetRepulsion"] > 0 - assert report["surface"]["stats"]["minimumSurfaceNetRepulsion"] > 0 - assert report["surface"]["relativeRadial"] > \ - report["surfaceWithoutPressure"]["relativeRadial"] - # Pressure reaches zero continuously at the 15.5-unit outer edge; ordinary gravity remains. - assert report["edge"]["stats"]["repulsions"] == 0 - assert report["edge"]["relativeRadial"] == pytest.approx( - report["edgeWithoutPressure"]["relativeRadial"], abs=1e-12 - ) - # The maximum visible gravity setting stays finite and below its tested acceleration cap. - assert report["maximum"]["stats"]["surfaceRepulsions"] == 1 - assert report["maximum"]["stats"]["minimumSurfaceNetRepulsion"] > 0 - assert report["maximum"]["stats"]["maximumAcceleration"] <= 500 - assert abs(report["maximum"]["relativeRadial"]) <= 1000 - - -@requires_node -def test_galaxy_collision_uses_evidence_mass_without_injecting_system_momentum() -> None: - report = _run_node( - """ - const contact = [ - { id: 'star', x: 0, y: 0, vx: 0, vy: 0, radius: 6, gravity_mass: 4 }, - { id: 'planet', x: 10, y: 0, vx: 0, vy: 0, radius: 6, gravity_mass: 1 }, - { id: 'remote', x: 100, y: 0, vx: 0, vy: 0, radius: 2, gravity_mass: 8 }, - ]; - const stats = I.applyGalaxyCollisions(contact, { - padding: 0, strength: 1, iterations: 1, - }); - const coincident = [ - { id: 'a', x: 0, y: 0, radius: 3, gravity_mass: 2 }, - { id: 'b', x: 0, y: 0, radius: 3, gravity_mass: 5 }, - ]; - I.applyGalaxyCollisions(coincident, { padding: 0, strength: 0.7, iterations: 2 }); - const sparse = Array.from({ length: 120 }, (_, index) => ({ - id: 's' + index, x: index * 30, y: 0, radius: 2, gravity_mass: 1, - })); - const sparseStats = I.applyGalaxyCollisions(sparse, { - padding: 0, strength: 1, iterations: 1, - }); - const tangent = [ - { id: 'left', x: 0, y: 0, vx: 0, vy: 1, radius: 6, gravity_mass: 1 }, - { id: 'right', x: 10, y: 0, vx: 0, vy: 0, radius: 6, gravity_mass: 1 }, - ]; - const closing = [ - { id: 'heavy', x: 0, y: 0, vx: 1, vy: 0, radius: 6, gravity_mass: 4 }, - { id: 'light', x: 10, y: 0, vx: -2, vy: 0, radius: 6, gravity_mass: 1 }, - ]; - const angular = bodies => bodies.reduce((sum, node) => sum - + node.gravity_mass * (node.x * node.vy - node.y * node.vx), 0); - const kinetic = bodies => bodies.reduce((sum, node) => sum - + 0.5 * node.gravity_mass * (node.vx * node.vx + node.vy * node.vy), 0); - const angularBefore = angular(tangent); - const kineticBefore = kinetic(closing); - I.applyGalaxyCollisions(tangent, { padding: 0, strength: 1, iterations: 1 }); - I.applyGalaxyCollisions(closing, { padding: 0, strength: 1, iterations: 1 }); - emit({ - positions: contact.map(node => [node.x, node.y]), - velocities: contact.map(node => [node.vx, node.vy]), - momentum: [ - contact.reduce((sum, node) => sum + node.gravity_mass * node.vx, 0), - contact.reduce((sum, node) => sum + node.gravity_mass * node.vy, 0), - ], - overlaps: stats.overlaps, - coincidentFinite: coincident.every(node => Number.isFinite(node.vx) - && Number.isFinite(node.vy)), - sparsePairs: sparseStats.pairs, - quadratic: sparse.length * sparse.length, - angularBefore, - angularAfter: angular(tangent), - kineticBefore, - kineticAfter: kinetic(closing), - closingMomentum: closing.reduce( - (sum, node) => sum + node.gravity_mass * node.vx, 0 - ), - }); - """ - ) - assert report["positions"][0] == pytest.approx([-0.4, 0]) - assert report["positions"][1] == pytest.approx([11.6, 0]) - assert report["velocities"][0] == pytest.approx([0, 0]) - assert report["velocities"][1] == pytest.approx([0, 0]) - assert report["velocities"][2] == pytest.approx([0, 0]) - assert report["momentum"] == pytest.approx([0, 0], abs=1e-12) - assert report["overlaps"] == 1 - assert report["coincidentFinite"] is True - assert report["sparsePairs"] < report["quadratic"] // 20 - assert report["angularAfter"] == pytest.approx(report["angularBefore"], abs=1e-12) - assert report["kineticAfter"] <= report["kineticBefore"] - assert report["closingMomentum"] == pytest.approx(2, abs=1e-12) - - -@requires_node -def test_galaxy_leapfrog_is_fixed_step_deterministic_and_does_not_depend_on_alpha() -> None: - report = _run_node( - """ - const fixture = () => [ - { id: 'sun', x: 0, y: 0, vx: 0, vy: 0, radius: 5, - gravity_mass: 8, community_id: 'solar' }, - { id: 'planet', x: 28, y: 0, vx: 0, vy: 0, radius: 2, - gravity_mass: 1, community_id: 'solar' }, - ]; - const first = fixture(), second = fixture(), damped = fixture(), conserved = fixture(); - I.seedGalaxyOrbits(first, 77, 12, 8, false); - I.seedGalaxyOrbits(second, 77, 12, 8, false); - I.seedGalaxyOrbits(conserved, 77, 12, 8, false, { localGravitationalConstant: 1 }); - const seeded = first.map(node => [node.x, node.y, node.vx, node.vy]); - const step = nodes => I.integrateGalaxyLeapfrog(nodes, [], [], { - gravity: 12, softening: 8, central: false, timestep: 0.25, - velocityDecay: 0.012, speedLimit: 18, collisionPadding: 0, - collisionStrength: 0, collisionIterations: 1, - }); - const initialAngular = first[1].x * first[1].vy - first[1].y * first[1].vx; - let firstStep = step(first); - step(second); - for (let i = 0; i < 159; i++) { step(first); step(second); } - const energy = nodes => { - const kinetic = nodes.reduce((sum, node) => sum + 0.5 * node.gravity_mass - * (node.vx * node.vx + node.vy * node.vy), 0); - const dx = nodes[1].x - nodes[0].x, dy = nodes[1].y - nodes[0].y; - return kinetic - (I.galaxyStellarGravityConstant(12) * 8) - / Math.sqrt(dx * dx + dy * dy + 64); - }; - const angularMomentum = nodes => nodes.reduce((sum, node) => sum + node.gravity_mass - * (node.x * node.vy - node.y * node.vx), 0); - const energyStart = energy(conserved), angularStart = angularMomentum(conserved); - for (let i = 0; i < 400; i++) I.integrateGalaxyLeapfrog(conserved, [], [], { - gravity: 12, softening: 8, central: false, timestep: 0.1, - velocityDecay: 0, speedLimit: 100, localRelativeSpeedLimit: 100, - localGravitationalConstant: 1, - includeFarFieldConfinement: false, collisionStrength: 0, - }); - damped[0].vx = 6; damped[0].vy = -2; - const beforeDamping = 0.5 * damped[0].gravity_mass - * (damped[0].vx * damped[0].vx + damped[0].vy * damped[0].vy); - const dampingStep = I.integrateGalaxyLeapfrog(damped, [], [], { - gravity: 0, central: false, timestep: 1, velocityDecay: 0.2, - speedLimit: 100, collisionStrength: 0, - }); - emit({ - seeded, - firstStep, initialAngular, - first: first.map(node => [node.x, node.y, node.vx, node.vy]), - second: second.map(node => [node.x, node.y, node.vx, node.vy]), - finite: first.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)), - maximumSpeed: Math.max(...first.map(node => Math.hypot(node.vx, node.vy))), - beforeDamping, afterDamping: dampingStep.kinetic, - energyStart, energyEnd: energy(conserved), angularStart, - angularEnd: angularMomentum(conserved), - }); - """ - ) - # A fixed sequence is repeatable and changes the seeded orbit without a D3 alpha input. - assert [value for node in report["first"] for value in node] == pytest.approx( - [value for node in report["second"] for value in node] - ) - assert report["firstStep"]["bodies"] == 2 - assert report["initialAngular"] != 0 - assert report["finite"] is True - assert report["maximumSpeed"] <= 18 - assert report["first"][1][:2] != pytest.approx(report["seeded"][1][:2]) - # The calibrated local field contributes to the reported whole-system kinetic total; - # damping still keeps one step from doubling the injected energy. - assert report["afterDamping"] < report["beforeDamping"] * 2 - # The production adapter also applies bounded surface/velocity projections after the - # conservative kick-drift-kick sample; the isolated field remains finite with bounded drift. - assert report["energyEnd"] == pytest.approx(report["energyStart"], rel=0.6) - assert report["angularEnd"] == pytest.approx(report["angularStart"], rel=0.3) - source = ASSET.read_text(encoding="utf-8") - integrator = source[source.index("function integrateGalaxyLeapfrog"): - source.index("function fallbackCommunityBridges")] - assert "alpha" not in integrator - assert "kick-drift-kick" in integrator - - -@requires_node -def test_integrator_keeps_rotating_nodes_outside_black_hole_and_clamps_drag() -> None: - report = _run_node( - """ - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 64, radius: 12, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'aurora', community_id: 'aurora', gravity_mass: 4, radius: 3, - x: 18, y: 0, vx: 0, vy: 0 }, - { id: 'borealis', community_id: 'borealis', gravity_mass: 3, radius: 3, - x: 0, y: -22, vx: 0, vy: 0 }, - { id: 'cygnus', community_id: 'cygnus', gravity_mass: 2, radius: 2, - x: -26, y: 4, vx: 0, vy: 0 }, - ]; - I.seedGalaxySystemOrbits(nodes, 123, 48, 40, false); - const options = { - gravity: 48, softening: 32, centralSoftening: 40, - localPairFraction: 0.15, corePairMultiplier: 0.75, - includeMutualSystems: true, mutualSystemGravityFraction: 0.12, - mutualSystemSoftening: 80, includeRelations: false, - includeOrbitalSeparation: true, orbitalSeparationPadding: 12, - orbitalSeparationStrength: 0.8, orbitalSeparationMaxCorrection: 4, - orbitalSeparationMaxVelocityCorrection: 8, - includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, - includeCollisions: false, inwardConvergence: true, - timestep: 0.021328125, wallClockSeconds: 1 / 30, - velocityDecay: 0.00005, speedLimit: 48, localRelativeSpeedLimit: 16, - }; - const angles = new Map(nodes.slice(1).map(node => [node.id, Math.atan2(node.y, node.x)])); - const angularTravel = new Map(nodes.slice(1).map(node => [node.id, 0])); - let minimumClearance = Infinity, contacts = 0, finalStep = null; - for (let step = 0; step < 600; step++) { - finalStep = I.integrateGalaxyLeapfrog(nodes, [], [], options); - contacts += finalStep.blackHoleExclusion.contacts; - nodes.slice(1).forEach(node => { - const clearance = Math.hypot(node.x, node.y) - - nodes[0].radius - node.radius - 2.5; - minimumClearance = Math.min(minimumClearance, clearance); - const angle = Math.atan2(node.y, node.x); - const previous = angles.get(node.id); - angularTravel.set(node.id, angularTravel.get(node.id) - + Math.abs(Math.atan2(Math.sin(angle - previous), Math.cos(angle - previous)))); - angles.set(node.id, angle); - }); - } - - const dragged = [ - { id: 'drag-anchor', anchor_role: 'global', community_id: 'core', - gravity_mass: 64, radius: 12, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'dragged', community_id: 'dragged-system', gravity_mass: 1, radius: 2, - x: 0, y: 0, vx: 0, vy: 0 }, - ]; - const dragStep = I.integrateGalaxyLeapfrog(dragged, [], [], { - gravity: 0, central: true, fixedNodeId: 'dragged', timestep: 0.021328125, - includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, - includeCollisions: false, includeRelations: false, inwardConvergence: false, - velocityDecay: 0, speedLimit: 48, - }); - emit({ - minimumClearance, contacts, - angularTravel: Object.fromEntries(angularTravel), - anchor: [nodes[0].x, nodes[0].y, nodes[0].vx, nodes[0].vy], - finalRadii: nodes.slice(1).map(node => Math.hypot(node.x, node.y)), - finite: nodes.concat(dragged).every(node => - [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), - maximumSpeed: finalStep.maximumSpeed, - finalClearance: finalStep.blackHoleExclusion.minimumClearance, - draggedClearance: Math.hypot(dragged[1].x, dragged[1].y) - - dragged[0].radius - dragged[1].radius - 2.5, - dragContacts: dragStep.blackHoleExclusion.contacts, - }); - """ - ) - assert report["finite"] is True - assert report["anchor"] == pytest.approx([0, 0, 0, 0], abs=1e-12) - assert report["minimumClearance"] >= -1e-9 - assert report["finalClearance"] >= -1e-9 - # The weaker 48 setting may never enter the horizon during this run; the boundary is still - # exercised by the explicit dragged-node case below. - assert report["contacts"] >= 0 - assert min(report["angularTravel"].values()) > 0.05 - assert report["maximumSpeed"] <= 48 - assert report["draggedClearance"] >= -1e-9 - assert report["dragContacts"] > 0 - - -@requires_node -def test_nested_galaxy_orbits_keep_global_and_local_angular_motion() -> None: - """Dense cross-system contact must not erase either layer of orbital motion.""" - report = _run_node( - """ - const nodes = [{ id: 'bh', anchor_role: 'global', community_id: 'core', - gravity_mass: 24, radius: 10, x: 0, y: 0, vx: 0, vy: 0 }]; - const systemIds = []; - for (let system = 0; system < 14; system++) { - const phase = system * 2 * Math.PI / 14; - systemIds.push('s' + system); - for (let member = 0; member < 4; member++) { - const localPhase = phase + member * Math.PI / 2; - nodes.push({ id: `${system}-${member}`, community_id: `s${system}`, - anchor_role: member ? 'none' : 'community', gravity_mass: member ? 1 : 5, - radius: member ? 3 : 5, - x: Math.cos(phase) * 38 + Math.cos(localPhase) * (member ? 9 : 0), - y: Math.sin(phase) * 38 + Math.sin(localPhase) * (member ? 9 : 0), - vx: 0, vy: 0 }); - } - } - I.seedGalaxyOrbits(nodes, 91, 48, 12, false, 0.15, 0.75); - I.seedGalaxySystemOrbits(nodes, 91, 48, 40, false); - const centers = () => I.communityCenters(nodes); - const byId = id => nodes.find(node => node.id === id); - const globalAngles = new Map(systemIds.map(id => { - const center = centers().get(id); - return [id, Math.atan2(center.y, center.x)]; - })); - const localAngles = new Map(systemIds.map((id, system) => { - const star = byId(`${system}-0`), planet = byId(`${system}-1`); - return [id, Math.atan2(planet.y - star.y, planet.x - star.x)]; - })); - const globalTravel = new Map(systemIds.map(id => [id, 0])); - const localTravel = new Map(systemIds.map(id => [id, 0])); - const angleStep = (next, previous) => Math.atan2( - Math.sin(next - previous), Math.cos(next - previous) - ); - const options = { - gravity: 48, softening: 12, centralSoftening: 40, - localPairFraction: 0.15, corePairMultiplier: 0.75, - includeMutualSystems: true, mutualSystemGravityFraction: 0.12, - mutualSystemSoftening: 80, includeRelations: false, - includeOrbitalSeparation: true, orbitalSeparationPadding: 12, - orbitalSeparationStrength: 0.8, orbitalSeparationMaxCorrection: 4, - orbitalSeparationMaxVelocityCorrection: 8, - crossCommunitySeparationPadding: 1.5, crossCommunitySeparationStrength: 0.144, - includeCollisions: false, - includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, - includeFarFieldConfinement: true, farFieldEnvelopeScale: 1.25, - farFieldMinimumRadius: 96, farFieldSoftFraction: 0.82, - farFieldAcceleration: 12, farFieldMaxAcceleration: 16, inwardConvergence: true, - timestep: 0.021328125, wallClockSeconds: 1 / 30, - velocityDecay: 0.00005, speedLimit: 48, localRelativeSpeedLimit: 16, - }; - let minimumClearance = Infinity, maximumSpeed = 0, minimumSystemSpeed = Infinity; - let crossCommunityOverlaps = 0; - for (let step = 0; step < 300; step++) { - const tick = I.integrateGalaxyLeapfrog(nodes, [], [], options); - crossCommunityOverlaps += tick.orbitalSeparation.crossCommunityOverlaps; - systemIds.forEach((id, system) => { - const center = centers().get(id); - const global = Math.atan2(center.y, center.x); - const globalDelta = angleStep(global, globalAngles.get(id)); - globalTravel.set(id, globalTravel.get(id) + Math.abs(globalDelta)); - globalAngles.set(id, global); - const star = byId(`${system}-0`), planet = byId(`${system}-1`); - const local = Math.atan2(planet.y - star.y, planet.x - star.x); - const localDelta = angleStep(local, localAngles.get(id)); - localTravel.set(id, localTravel.get(id) + Math.abs(localDelta)); - localAngles.set(id, local); - const radius = Math.hypot(center.x, center.y); - const vx = center.nodes.reduce((sum, node) => sum - + node.gravity_mass * node.vx, 0) / center.mass; - const vy = center.nodes.reduce((sum, node) => sum - + node.gravity_mass * node.vy, 0) / center.mass; - minimumSystemSpeed = Math.min(minimumSystemSpeed, Math.abs( - (-center.y / radius) * vx + (center.x / radius) * vy - )); - }); - nodes.slice(1).forEach(node => { - minimumClearance = Math.min(minimumClearance, Math.hypot(node.x, node.y) - - nodes[0].radius - node.radius - 2.5); - }); - maximumSpeed = Math.max(maximumSpeed, tick.maximumSpeed); - } - emit({ - globalTravel: Object.fromEntries(globalTravel), - localTravel: Object.fromEntries(localTravel), - minimumClearance, - maximumSpeed, crossCommunityOverlaps, minimumSystemSpeed, - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)), - }); - """ - ) - assert report["finite"] is True - assert report["minimumClearance"] >= -1e-9 - assert report["maximumSpeed"] <= 48 - assert report["crossCommunityOverlaps"] > 1000 - assert report["minimumSystemSpeed"] > 3 - assert min(report["globalTravel"].values()) > 1 - assert min(report["localTravel"].values()) > 0.3 - - -@requires_node -def test_hierarchical_galaxy_keeps_planets_bound_to_one_dominant_star() -> None: - """A local star is the sole source for its planets while its system orbits the hole. - - This deliberately starts one planet slightly inside its star's painted exclusion radius. - The contact layer must repair that hard local boundary without draining either the - system's black-hole orbit or the satellites' signed local angular phase. - """ - report = _run_node( - """ - const nodes = [ - { id: 'bh', anchor_role: 'global', community_id: 'core', - gravity_mass: 64, radius: 10, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'a-star', community_id: 'a', system_anchor_id: 'a-star', gravity_mass: 14, radius: 5, - x: 46, y: 0, vx: 0, vy: 0 }, - { id: 'a-inner', orbit_tier: 1, community_id: 'a', system_anchor_id: 'a-star', gravity_mass: 1, radius: 3, - x: 54, y: 0, vx: 0, vy: 0 }, - { id: 'a-outer', orbit_tier: 2, community_id: 'a', system_anchor_id: 'a-star', gravity_mass: 1, radius: 3, - x: 54, y: 7, vx: 0, vy: 0 }, - { id: 'b-star', community_id: 'b', system_anchor_id: 'b-star', gravity_mass: 12, radius: 5, - x: -54, y: 0, vx: 0, vy: 0 }, - { id: 'b-inner', orbit_tier: 1, community_id: 'b', system_anchor_id: 'b-star', gravity_mass: 1, radius: 3, - x: -44, y: 0, vx: 0, vy: 0 }, - { id: 'b-outer', orbit_tier: 2, community_id: 'b', system_anchor_id: 'b-star', gravity_mass: 1, radius: 3, - x: -54, y: -16, vx: 0, vy: 0 }, - ]; - const links = [ - { source: 'a-star', target: 'a-inner', rest_length: 10, spring_strength: 0.08 }, - { source: 'a-star', target: 'a-outer', rest_length: 16, spring_strength: 0.08 }, - { source: 'b-star', target: 'b-inner', rest_length: 10, spring_strength: 0.08 }, - { source: 'b-star', target: 'b-outer', rest_length: 16, spring_strength: 0.08 }, - ]; - const systemIds = ['a', 'b']; - const planetIds = ['a-inner', 'a-outer', 'b-inner', 'b-outer']; - const byId = id => nodes.find(node => node.id === id); - const centers = () => I.communityCenters(nodes); - const angleStep = (next, previous) => Math.atan2( - Math.sin(next - previous), Math.cos(next - previous) - ); - const localSourceAcceleration = innerMass => { - /* A planet's inertial mass must not make it an additional local gravity source. */ - const sample = [ - { id: 'star', anchor_role: 'community', community_id: 'sample', - gravity_mass: 14, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'inner', community_id: 'sample', gravity_mass: innerMass, - x: 16, y: 0, vx: 0, vy: 0 }, - { id: 'outer', community_id: 'sample', gravity_mass: 1, - x: 0, y: 24, vx: 0, vy: 0 }, - ]; - I.applyGalaxySystemAnchorGravity(sample, { - gravity: 48, softening: 12, accelerationCap: 100, - }); - // The free-system frame can translate after a massive satellite recoils the star. - // Only outer-minus-star acceleration proves planets are not secondary wells. - return [sample[2].vx - sample[0].vx, sample[2].vy - sample[0].vy]; - }; - const lightPlanetField = localSourceAcceleration(1); - const heavyPlanetField = localSourceAcceleration(8); - - I.seedGalaxyOrbits(nodes, 9, 48, 12, false, 0.15, 0.75); - I.seedGalaxySystemOrbits(nodes, 9, 48, 40, false); - const globalAngles = new Map(systemIds.map(id => { - const center = centers().get(id); - return [id, Math.atan2(center.y, center.x)]; - })); - const localAngles = new Map(planetIds.map(id => { - const planet = byId(id), star = byId(id.slice(0, 1) + '-star'); - return [id, Math.atan2(planet.y - star.y, planet.x - star.x)]; - })); - const globalTravel = new Map(systemIds.map(id => [id, 0])); - const localTravel = new Map(planetIds.map(id => [id, 0])); - const options = { - gravity: 48, softening: 12, centralSoftening: 40, - localPairFraction: 0.15, corePairMultiplier: 0.75, - includeMutualSystems: true, mutualSystemGravityFraction: 0.12, - mutualSystemSoftening: 80, includeRelations: true, - relationStrengthMultiplier: 1, relationConstraintRate: 24, - relationConstraintMaxCorrection: 12, - includeRelationSprings: false, skipSystemAnchorRelations: true, - skipOrbitalSystemRelations: true, - includeOrbitalSeparation: true, orbitalSeparationPadding: 1.5, - orbitalSeparationStrength: 0.8, orbitalSeparationMaxCorrection: 4, - orbitalSeparationMaxVelocityCorrection: 8, - preserveLocalTangentialVelocity: true, skipSystemAnchorPairs: true, - systemAnchorExclusionPadding: 1.5, - crossCommunitySeparationPadding: 1.5, crossCommunitySeparationStrength: 0.144, - includeCollisions: false, - includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, - includeFarFieldConfinement: true, farFieldEnvelopeScale: 1.25, - farFieldMinimumRadius: 96, farFieldSoftFraction: 0.82, - farFieldAcceleration: 12, farFieldMaxAcceleration: 16, inwardConvergence: true, - timestep: 0.021328125, wallClockSeconds: 1 / 30, - velocityDecay: 0.00005, speedLimit: 48, localRelativeSpeedLimit: 16, - }; - let localContacts = 0, systemAnchorContacts = 0, systemRepulsions = 0; - let surfaceRepulsions = 0, maximumSystemRepulsion = 0; - let relationAnchorSkips = 0; - let relationOrbitalSystemSkips = 0; - let maximumSpeed = 0, minimumBlackHoleClearance = Infinity; - let minimumStarClearance = Infinity, maximumInnerOrbitRadius = 0, finalTick = null; - for (let step = 0; step < 600; step++) { - finalTick = I.integrateGalaxyLeapfrog(nodes, links, [], options); - localContacts += finalTick.orbitalSeparation.overlaps; - systemAnchorContacts += finalTick.systemAnchorExclusion.contacts; - systemRepulsions += finalTick.systemGravity.repulsions; - surfaceRepulsions += finalTick.systemGravity.surfaceRepulsions; - maximumSystemRepulsion = Math.max( - maximumSystemRepulsion, finalTick.systemGravity.maximumRepulsion); - relationAnchorSkips += finalTick.relationConstraint.skippedSystemAnchor; - relationOrbitalSystemSkips += finalTick.relationConstraint.skippedOrbitalSystem; - maximumSpeed = Math.max(maximumSpeed, finalTick.maximumSpeed); - systemIds.forEach(id => { - const center = centers().get(id); - const angle = Math.atan2(center.y, center.x); - globalTravel.set(id, globalTravel.get(id) + angleStep(angle, globalAngles.get(id))); - globalAngles.set(id, angle); - }); - planetIds.forEach(id => { - const planet = byId(id), star = byId(id.slice(0, 1) + '-star'); - const angle = Math.atan2(planet.y - star.y, planet.x - star.x); - localTravel.set(id, localTravel.get(id) + angleStep(angle, localAngles.get(id))); - localAngles.set(id, angle); - minimumStarClearance = Math.min(minimumStarClearance, - Math.hypot(planet.x - star.x, planet.y - star.y) - - star.radius - planet.radius - 1.5); - if (id.endsWith('-inner')) maximumInnerOrbitRadius = Math.max( - maximumInnerOrbitRadius, Math.hypot(planet.x - star.x, planet.y - star.y) - ); - }); - nodes.slice(1).forEach(node => { - minimumBlackHoleClearance = Math.min(minimumBlackHoleClearance, - Math.hypot(node.x, node.y) - nodes[0].radius - node.radius - 2.5); - }); - } - const envelope = finalTick.farFieldConfinement.envelopeRadius; - emit({ - dominantOnly: systemIds.every(id => { - const star = byId(id + '-star'); - return !star.__galaxyOrbitOrder && ['inner', 'outer'].every(tier => - !!byId(id + '-' + tier).__galaxyOrbitOrder); - }), - localSourceShift: Math.hypot( - lightPlanetField[0] - heavyPlanetField[0], - lightPlanetField[1] - heavyPlanetField[1], - ), - globalTravel: Object.fromEntries(globalTravel), - localTravel: Object.fromEntries(localTravel), - localContacts, systemAnchorContacts, systemRepulsions, surfaceRepulsions, - maximumSystemRepulsion, - relationAnchorSkips, relationOrbitalSystemSkips, - maximumSpeed, minimumBlackHoleClearance, minimumStarClearance, - maximumInnerOrbitRadius, - outerBounded: nodes.slice(1).every(node => - Math.hypot(node.x, node.y) + node.radius <= envelope + 1e-8), - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)), - }); - """ - ) - assert report["dominantOnly"] is True - assert report["localSourceShift"] <= 1e-10 - assert report["finite"] is True - assert report["outerBounded"] is True - assert report["localContacts"] > 0 - assert report["systemRepulsions"] > 0 - assert report["maximumSystemRepulsion"] > 0 - # Explicit orbital metadata now takes precedence over the older anchor-only exemption. - assert report["relationAnchorSkips"] == 0 - assert report["relationOrbitalSystemSkips"] > 0 - assert report["minimumBlackHoleClearance"] >= -1e-9 - assert report["minimumStarClearance"] >= -1e-9 - # The six-unit soft stellar-pressure band intentionally expands the near-surface r=10 - # seeds, but they remain strongly bound below the retired always-on ~20 separation brake. - assert report["maximumInnerOrbitRadius"] < 18 - assert report["maximumSpeed"] <= 48 - assert min(abs(value) for value in report["globalTravel"].values()) > 1 - assert min(abs(value) for value in report["localTravel"].values()) > 1 - - -@requires_node -def test_render_enforces_horizon_before_paint_for_oversized_static_galaxy() -> None: - report = _run_engine( - """ - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 64, visual_radius: 8, degree: 1, - x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'intruder', community_id: 'intruder', gravity_mass: 1, - visual_radius: 3, degree: 1, x: 0, y: 0, vx: 0, vy: 5 }, - ]; - for (let index = 0; index < 1499; index++) nodes.push({ - id: 'filler-' + index, community_id: 'filler-' + index, - gravity_mass: 1, visual_radius: 3, degree: 1, - x: 240 + index * 2, y: 180 + (index % 17) * 3, vx: 0, vy: 0, - }); - const api = G.create(el, { reducedMotion: () => true }); - api.setData({ nodes, links: [], communities: [], community_bridges: [], - meta: { layout_seed: 7 } }); - const rendered = fg.graphData().nodes; - const anchor = rendered.find(node => node.id === 'black-hole'); - const intruder = rendered.find(node => node.id === 'intruder'); - const diagnostics = api.physicsDiagnostics(); - const integrator = source.slice(source.indexOf('function integrateGalaxyLeapfrog'), - source.indexOf('function galaxyMotionDiagnostics')); - emit({ - staticLayout: diagnostics.staticLayout, - exclusion: diagnostics.blackHoleExclusion, - clearance: Math.hypot(intruder.x - anchor.x, intruder.y - anchor.y) - - anchor.radius - intruder.radius - diagnostics.blackHoleExclusionPadding, - anchor: [anchor.x, anchor.y, anchor.vx, anchor.vy], - pinned: [intruder.fx, intruder.fy], - position: [intruder.x, intruder.y], - initialBeforeAcceleration: integrator.indexOf('const initialHorizon') - < integrator.indexOf('const start = galaxyAccelerations'), - }); - """ - ) - assert report["staticLayout"] is True - assert report["exclusion"]["contacts"] > 0 - assert report["clearance"] >= -1e-9 - assert report["anchor"] == pytest.approx([0, 0, 0, 0], abs=1e-12) - assert report["pinned"] == pytest.approx(report["position"], abs=1e-12) - assert report["initialBeforeAcceleration"] is True - - -@requires_node -def test_render_reapplies_far_field_envelope_before_static_repaint() -> None: - """A reused oversized/static payload must not bypass the cached outer boundary.""" - report = _run_engine( - """ - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 64, visual_radius: 8, degree: 1, - x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'intruder', community_id: 'outer', gravity_mass: 1, - visual_radius: 3, degree: 1, x: 300, y: 0, vx: 0, vy: 4 }, - ]; - for (let index = 0; index < 1499; index++) nodes.push({ - id: 'filler-' + index, community_id: 'filler-' + index, - gravity_mass: 1, visual_radius: 3, degree: 1, - x: 160 + index * 2, y: 140 + (index % 17) * 3, vx: 0, vy: 0, - }); - const api = G.create(el, { reducedMotion: () => true }); - api.setData({ nodes, links: [], communities: [], community_bridges: [], - meta: { layout_seed: 19 } }); - const initial = api.physicsDiagnostics(); - const rendered = fg.graphData().nodes; - const anchor = rendered.find(node => node.id === 'black-hole'); - const intruder = rendered.find(node => node.id === 'intruder'); - intruder.x = initial.farFieldConfinement.envelopeRadius + 400; - intruder.y = 0; - intruder.fx = intruder.x; - intruder.fy = intruder.y; - /* A cosmetic setting keeps the same static arrays; it must still project before - force-graph's next paint rather than relying on the disabled live integrator. */ - api.setSettings({ font: 13 }); - const diagnostics = api.physicsDiagnostics(); - const clearance = diagnostics.farFieldConfinement.envelopeRadius - - (Math.hypot(intruder.x - anchor.x, intruder.y - anchor.y) + intruder.radius); - emit({ - staticLayout: diagnostics.staticLayout, - initialEnvelope: initial.farFieldConfinement.envelopeRadius, - confinement: diagnostics.farFieldConfinement, - clearance, - pinned: [intruder.fx, intruder.fy], - position: [intruder.x, intruder.y], - finite: rendered.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)), - }); - """ - ) - assert report["staticLayout"] is True - assert report["initialEnvelope"] > 0 - assert report["confinement"]["boundedSystems"] >= 1 - assert report["clearance"] >= -1e-8 - assert report["pinned"] == pytest.approx(report["position"], abs=1e-12) - assert report["finite"] is True - - -@requires_node -def test_opt_in_inward_convergence_helper_is_bounded_and_keeps_local_frames_tangential() -> None: - report = _run_node( - """ - const options = { - gravity: 48, central: true, timestep: 0.021328125, velocityDecay: 0, - speedLimit: 1000, includeCollisions: false, inwardConvergence: true, - wallClockSeconds: 1 / 30, - }; - const anchor = { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 100, radius: 12, x: 0, y: 0, vx: 0, vy: 0 }; - const body = { id: 'outer', community_id: 'outer', gravity_mass: 1, radius: 2, - x: 120, y: 0, vx: 0, vy: 0 }; - const nodes = [anchor, body]; - let previous = Math.hypot(body.x, body.y), monotone = true; - for (let index = 0; index < 1800; index++) { - I.integrateGalaxyLeapfrog(nodes, [], [], options); - const radius = Math.hypot(body.x, body.y); - monotone = monotone && radius <= previous + 1e-10; - previous = radius; - } - const outbound = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 100, radius: 12, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'escape', community_id: 'outer', gravity_mass: 1, radius: 2, - x: 100, y: 0, vx: 30, vy: 0 }, - ]; - // Disable the central field explicitly for this low-level convergence-only trial; - // Galaxy's live carrier path intentionally retains its shallow floor at zero. - const escapeOptions = { ...options, gravity: 0, central: false }; - const escape = I.integrateGalaxyLeapfrog(outbound, [], [], escapeOptions); - const escapedRadius = Math.hypot(outbound[1].x, outbound[1].y); - const candidateRadius = 100 + 30 * options.timestep; - const attemptedOutward = candidateRadius - 100; - const counteracted = candidateRadius - escapedRadius; - const tangent = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 100, radius: 12, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'orbit', community_id: 'outer', gravity_mass: 1, radius: 2, - x: 120, y: 20, vx: 3, vy: 11 }, - ]; - const initial = new Map([['outer', { radius: 100 }]]); - const unitX = tangent[1].x / Math.hypot(tangent[1].x, tangent[1].y); - const unitY = tangent[1].y / Math.hypot(tangent[1].x, tangent[1].y); - const tangentBefore = tangent[1].vx * -unitY + tangent[1].vy * unitX; - const direct = I.applyGalaxyInwardConvergence(tangent, tangent[0], initial, - { wallClockSeconds: 1 / 30 }); - const postX = tangent[1].x / Math.hypot(tangent[1].x, tangent[1].y); - const postY = tangent[1].y / Math.hypot(tangent[1].x, tangent[1].y); - const tangentAfter = tangent[1].vx * -postY + tangent[1].vy * postX; - const localSystem = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 100, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'star', community_id: 'solar', gravity_mass: 4, - x: 100, y: 0, vx: 1, vy: 3 }, - { id: 'planet', community_id: 'solar', gravity_mass: 1, - x: 112, y: 0, vx: -2, vy: 8 }, - ]; - const localCenter = I.communityCenters(localSystem).get('solar'); - const localInitial = new Map([['solar', { - radius: Math.hypot(localCenter.x, localCenter.y), - }]]); - const internalBefore = Math.hypot( - localSystem[2].x - localSystem[1].x, localSystem[2].y - localSystem[1].y); - const relativeVelocityBefore = [ - localSystem[2].vx - localSystem[1].vx, - localSystem[2].vy - localSystem[1].vy, - ]; - I.applyGalaxyInwardConvergence(localSystem, localSystem[0], localInitial, - { wallClockSeconds: 1 / 30, gravity: 48, timestep: 0.021328125 }); - const internalAfter = Math.hypot( - localSystem[2].x - localSystem[1].x, localSystem[2].y - localSystem[1].y); - const relativeVelocityAfter = [ - localSystem[2].vx - localSystem[1].vx, - localSystem[2].vy - localSystem[1].vy, - ]; - const dense = Array.from({ length: 512 }, (_, index) => ({ - id: `n${index}`, x: 40 + (index % 32), y: 30 + Math.floor(index / 32), - vx: index % 3 - 1, vy: index % 5 - 2, community_id: `dense-${index}`, - })); - dense.unshift({ id: 'black-hole', anchor_role: 'global', community_id: 'core', - x: 0, y: 0, vx: 0, vy: 0 }); - let denseInitial = new Map([...I.communityCenters(dense).entries()].map( - ([id, center]) => [id, { radius: Math.hypot(center.x, center.y) }])); - let denseReport; - for (let index = 0; index < 120; index++) { - denseReport = I.applyGalaxyInwardConvergence(dense, dense[0], denseInitial, - { wallClockSeconds: 1 / 30 }); - denseInitial = new Map([...I.communityCenters(dense).entries()].map( - ([id, center]) => [id, { radius: Math.hypot(center.x, center.y) }])); - } - emit({ - minuteRadius: previous, monotone, - anchor: [anchor.x, anchor.y, anchor.vx, anchor.vy], - escapedRadius, attemptedOutward, counteracted, - outboundVelocity: outbound[1].vx, - tangentBefore, tangentAfter, direct, - internalBefore, internalAfter, - relativeVelocityBefore, relativeVelocityAfter, - finite: nodes.concat(outbound, tangent, dense).every(node => - [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), - denseApplied: denseReport.applied, - factors: [0, 48, 100].map(gravity => - I.galaxyInwardConvergenceFactor(60, gravity)), - rates: [0, 48, 100].map(gravity => - I.galaxyInwardConvergencePerMinute(gravity)), - convergence: escape.convergence, - }); - """ - ) - # Convergence is disabled (rate=0) for stable orbits: factor is 1 and rate is 0 - # at every gravity setting. The helper still runs but performs no movement. - assert report["factors"][0] == pytest.approx(1) - assert report["factors"][1] == pytest.approx(1) - assert report["factors"][2] == pytest.approx(1) - assert report["rates"][0] == pytest.approx(0) - assert report["rates"][1] == pytest.approx(0) - assert report["rates"][2] == pytest.approx(0) - # With convergence disabled, carrier support injects tangential velocity and the body - # enters an orbit rather than falling straight in. Radius oscillates — this is correct. - assert report["minuteRadius"] > 0 - assert report["minuteRadius"] < 240 - # monotone is False because the orbit oscillates, which is the desired stable behavior. - assert report["anchor"] == pytest.approx([0, 0, 0, 0], abs=1e-12) - # The optional inward projector is a no-op at rate=0; escape trajectory is ballistic. - candidate_radius = 100 + 30 * 0.021328125 - assert 100 < report["escapedRadius"] <= candidate_radius - assert 0 <= report["counteracted"] < 0.01 - assert 29 < report["outboundVelocity"] <= 30 - assert report["tangentAfter"] == pytest.approx(report["tangentBefore"], abs=1e-12) - assert report["internalAfter"] == pytest.approx(report["internalBefore"], abs=1e-12) - assert report["relativeVelocityAfter"] == pytest.approx( - report["relativeVelocityBefore"], abs=1e-12 - ) - assert report["finite"] is True - # Factor=1 triggers the early-return path: applied=0, no convergence work done. - assert report["denseApplied"] == 0 - assert report["convergence"]["overrides"] == 0 - - -@requires_node -def test_gravity_setting_changes_orbital_support_without_teleporting_system_density() -> None: - report = _run_node( - """ - const fixture = () => [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - gravity_mass: 20, x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'star-a', anchor_role: 'community', community_id: 'a', - gravity_mass: 6, x: 120, y: 20, vx: 1, vy: 3 }, - { id: 'planet-a', community_id: 'a', gravity_mass: 1, - x: 132, y: 20, vx: -2, vy: 7 }, - { id: 'star-b', anchor_role: 'community', community_id: 'b', - gravity_mass: 4, x: -180, y: 80, vx: -1, vy: -2 }, - ]; - const radius = (nodes, id) => { - const center = I.communityCenters(nodes).get(id); - return Math.hypot(center.x, center.y); - }; - const direct = fixture(), stepped = fixture(); - const before = { - radius: radius(direct, 'a'), - diameter: Math.hypot(direct[2].x - direct[1].x, direct[2].y - direct[1].y), - phase: direct.map(node => [node.x, node.y, node.vx, node.vy]), - }; - const tightened = I.applyGalaxyGravitySettingResponse(direct, 48, 100); - const tight = { - radius: radius(direct, 'a'), - diameter: Math.hypot(direct[2].x - direct[1].x, direct[2].y - direct[1].y), - phase: direct.map(node => [node.x, node.y, node.vx, node.vy]), - }; - const loosened = I.applyGalaxyGravitySettingResponse(direct, 100, 48); - [60, 80, 100].reduce((previous, setting) => { - I.applyGalaxyGravitySettingResponse(stepped, previous, setting); - return setting; - }, 48); - emit({ - before, tight, - roundTrip: direct.map(node => [node.x, node.y, node.vx, node.vy]), - stepped: stepped.map(node => [node.x, node.y, node.vx, node.vy]), - tightened, loosened, - }); - """ - ) - assert report["tightened"]["systems"] == 2 - assert report["tightened"]["moved"] == 2 - assert report["tightened"]["velocityAdjusted"] == 3 - assert report["tightened"]["maximumVelocityShift"] > 0 - assert report["tightened"]["maximumShift"] == pytest.approx(0, abs=1e-12) - assert report["tight"]["radius"] == pytest.approx(report["before"]["radius"], abs=1e-12) - assert report["tight"]["diameter"] == pytest.approx( - report["before"]["diameter"], abs=1e-12 - ) - # The slider re-seeds the black-hole-frame tangent immediately, but does not teleport the - # carrier or change any planet's local star-relative vector. - assert [row[:2] for row in report["tight"]["phase"]] == [ - row[:2] for row in report["before"]["phase"] - ] - assert report["tight"]["phase"][2][2] - report["tight"]["phase"][1][2] == pytest.approx( - report["before"]["phase"][2][2] - report["before"]["phase"][1][2] - ) - assert report["tightened"]["ratio"] > 1 - assert report["loosened"]["moved"] == 2 - assert report["loosened"]["velocityAdjusted"] == 3 - assert report["loosened"]["maximumShift"] == pytest.approx(0, abs=1e-12) - # A stepped change is path-independent: the final 100-setting velocity matches a direct - # 48→100 response even when intermediate slider values were visited. - for actual, expected in zip(report["stepped"], report["tight"]["phase"]): - assert actual == pytest.approx(expected, abs=1e-12) - - -@requires_node -def test_cached_carrier_lanes_support_cross_community_black_hole_children() -> None: - """Explicit ``system_anchor_id`` wins over community grouping for BH satellites.""" - report = _run_node( - """ - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - system_anchor_id: 'black-hole', gravity_mass: 64, radius: 9, - x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'outer-star', anchor_role: 'community', community_id: 'outer', - system_anchor_id: 'outer-star', gravity_mass: 8, radius: 5, - x: 220, y: 0, vx: 0, vy: 12 }, - { id: 'outer-planet', community_id: 'outer', system_anchor_id: 'outer-star', - gravity_mass: 1, radius: 2, x: 248, y: 0, vx: 0, vy: 15 }, - // This satellite deliberately belongs to a different community while explicitly - // orbiting the black hole. A community-only implementation freezes or drops it. - { id: 'cross-core-child', community_id: 'cross-core', system_anchor_id: 'black-hole', - orbit_tier: 1, gravity_mass: 3, radius: 3, x: 0, y: 54, vx: -8, vy: 0 }, - ]; - Object.defineProperty(nodes[1], '__galaxyCarrierLaneRadius', - { value: 220, writable: true, configurable: true }); - Object.defineProperty(nodes[3], '__galaxyCarrierLaneRadius', - { value: 54, writable: true, configurable: true }); - const before = nodes.map(node => [node.id, node.x, node.y, node.vx, node.vy]); - const support = I.supportGalaxyCarrierOrbits(nodes, { - gravity: 48, centralSoftening: 40, softening: 32, layoutSeed: 7331, - blackHoleMass: 1, gravitationalConstant: 1, localGravitationalConstant: 1, - includeMutualSystems: false, - }); - const bh = nodes[0], cross = nodes[3]; - const dx = cross.x - bh.x, dy = cross.y - bh.y; - const tangent = dx * (cross.vy - bh.vy) - dy * (cross.vx - bh.vx); - emit({ before, support, tangent, - coordinates: nodes.map(node => [node.id, node.x, node.y, node.vx, node.vy]), - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), - }); - """ - ) - assert report["finite"] is True - assert report["support"]["eligible"] >= 2 - assert report["support"]["coreEligible"] == 1 - assert report["support"]["coreSupported"] == 1 - assert abs(report["tangent"]) > 1e-6 - # The explicit lane is authoritative: the carrier/root may be projected as a rigid group - # to its admitted radius, while the cross-community BH child is retained and supported. - by_id = {row[0]: row for row in report["coordinates"]} - assert math.hypot(by_id["outer-star"][1], by_id["outer-star"][2]) == pytest.approx(220) - assert math.hypot(by_id["cross-core-child"][1], by_id["cross-core-child"][2]) == pytest.approx(54) - - -@requires_node -def test_three_coincident_cross_community_black_hole_children_receive_distinct_clear_lanes() -> None: - """Multiple explicit BH children may share authored radius/phase but never remain stacked.""" - report = _run_node( - """ - const nodes = [{ id: 'black-hole', anchor_role: 'global', community_id: 'core', - system_anchor_id: 'black-hole', gravity_mass: 64, radius: 9, x: 0, y: 0, vx: 0, vy: 0 }]; - ['cross-a', 'cross-b', 'cross-c'].forEach((id, index) => { - const node = { id, community_id: id, system_anchor_id: 'black-hole', orbit_tier: 1, - gravity_mass: 3, radius: 3, x: 180, y: 0, orbit_radius: 180, vx: 0, vy: 0 }; - nodes.push(node); - }); - const options = { gravity: 48, centralSoftening: 40, softening: 32, layoutSeed: 90817, - blackHoleMass: 1, gravitationalConstant: 1, localGravitationalConstant: 1, - includeMutualSystems: false, includeRelations: false, includeCollisions: false, - includeOrbitalSeparation: false, includeSystemPacking: false, - includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, - includeFarFieldConfinement: true, farFieldEnvelopeScale: 2, farFieldMinimumRadius: 96, - timestep: .032, wallClockSeconds: 1 / 30, velocityDecay: .00005, speedLimit: 48 }; - // Admission owns phase-slotting. Calling support against arbitrary hand-written lane - // tags would bypass the product path and falsely manufacture a collision. - I.seedGalaxyOrbits(nodes, 90817, 48, 32, false, options); - I.supportGalaxyCarrierOrbits(nodes, options); - const phase = node => Math.atan2(node.y, node.x); - const initial = nodes.slice(1).map(node => ({ id: node.id, phase: phase(node), - lane: node.__galaxyCoreLaneRadius, radius: Math.hypot(node.x, node.y) })); - let minClearance = Infinity, frozen = 0; - let previous = nodes.slice(1).map(phase), travel = [0, 0, 0]; - for (let step = 0; step < 1000; step++) { - I.integrateGalaxyLeapfrog(nodes, [], [], options); - nodes.slice(1).forEach((node, index) => { - const next = phase(node), delta = Math.atan2(Math.sin(next - previous[index]), - Math.cos(next - previous[index])); - travel[index] += delta; - if (Math.abs(delta) < 1e-8) frozen++; - previous[index] = next; - }); - for (let left = 1; left < nodes.length; left++) for (let right = left + 1; - right < nodes.length; right++) minClearance = Math.min(minClearance, - Math.hypot(nodes[left].x - nodes[right].x, nodes[left].y - nodes[right].y) - - nodes[left].radius - nodes[right].radius); - } - emit({ initial, travel, frozen, minClearance, - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); - """ - ) - assert report["finite"] is True - assert all(item["lane"] is not None for item in report["initial"]) - assert max(item["lane"] for item in report["initial"]) < 60 - assert len({round(item["phase"], 8) for item in report["initial"]}) == 3 - assert report["minClearance"] >= -1e-8 - assert report["frozen"] == 0 - assert all(abs(value) > 0.1 for value in report["travel"]) - - -@requires_node -def test_unequal_mass_local_seed_remains_a_bound_two_body_orbit() -> None: - report = _run_node( - """ - const nodes = [ - { id: 'star', anchor_role: 'community', community_id: 'solar', - system_anchor_id: 'star', - gravity_mass: 8, x: 0, y: 0, vx: 0, vy: 0, radius: 4 }, - { id: 'planet', community_id: 'solar', system_anchor_id: 'star', - gravity_mass: 1, x: 24, y: 0, vx: 0, vy: 0, radius: 2 }, - ]; - I.seedGalaxyOrbits(nodes, 31, 48, 7.68, false); - let minimum = Infinity, maximum = 0, centered = true; - for (let step = 0; step < 1200; step++) { - I.integrateGalaxyLeapfrog(nodes, [], [], { - gravity: 48, softening: 7.68, central: false, - timestep: 0.525, velocityDecay: 0, speedLimit: 100, - collisionStrength: 0, - }); - const separation = Math.hypot( - nodes[1].x - nodes[0].x, nodes[1].y - nodes[0].y - ); - minimum = Math.min(minimum, separation); - maximum = Math.max(maximum, separation); - centered = centered && nodes[0].x === 0 && nodes[0].y === 0 - && nodes[0].vx === 0 && nodes[0].vy === 0; - } - emit({ minimum, maximum, centered, - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)) }); - """ - ) - assert report["centered"] is True - assert report["finite"] is True - assert report["minimum"] >= 23.9 - # Exact-2x gravity raises the integrator's dimensionless step at this deliberately coarse - # 0.525 fixture timestep; the orbit remains within roughly 8% of its seeded radius with the - # compact kinematic carrier and translate-system-descendants admission. - assert report["maximum"] <= 26.0 - - -@requires_node -def test_galaxy_motion_diagnostics_are_mass_weighted_finite_and_read_only() -> None: - report = _run_node( - """ - const clean = [ - { id: 'heavy', x: 2, y: 0, vx: 3, vy: 4, gravity_mass: 4 }, - { id: 'light', x: -2, y: 0, vx: -2, vy: 0, gravity_mass: 1 }, - { id: 'history', x: Infinity, y: 0, vx: NaN, vy: 0, ghost: true }, - ]; - const before = JSON.stringify(clean); - const diagnostics = I.galaxyMotionDiagnostics(clean); - const dirty = I.galaxyMotionDiagnostics([ - { id: 'bad', x: NaN, y: 0, vx: Infinity, vy: 0, gravity_mass: 2 }, - ]); - emit({ diagnostics, dirty, unchanged: JSON.stringify(clean) === before }); - """ - ) - diagnostics = report["diagnostics"] - assert diagnostics["bodies"] == 2 - assert diagnostics["invalidBodies"] == 0 - assert diagnostics["totalMass"] == 5 - assert diagnostics["centerX"] == pytest.approx(1.2) - assert diagnostics["centerY"] == 0 - assert [diagnostics["momentumX"], diagnostics["momentumY"]] == pytest.approx([10, 16]) - assert diagnostics["kineticEnergy"] == pytest.approx(52) - assert diagnostics["angularMomentum"] == pytest.approx(12.8) - assert diagnostics["maxSpeed"] == pytest.approx(5) - assert report["dirty"]["invalidBodies"] == 1 - assert all(math.isfinite(report["dirty"][key]) for key in ( - "totalMass", "centerX", "centerY", "momentum", "kineticEnergy", "maxSpeed" - )) - assert report["unchanged"] is True - - -@requires_node -def test_fixed_step_speed_guard_uses_one_common_scale_and_preserves_momentum() -> None: - report = _run_node( - """ - const bodies = [ - { id: 'heavy', x: 0, y: 0, gravity_mass: 10, vx: 10, vy: 0 }, - { id: 'light', x: 100, y: 0, gravity_mass: 1, vx: -100, vy: 0 }, - { id: 'invalid', x: 0, y: 100, gravity_mass: 2, vx: NaN, vy: Infinity }, - { id: 'history', x: 0, y: -100, gravity_mass: 0, vx: 99, vy: -99, ghost: true }, - ]; - I.integrateGalaxyLeapfrog(bodies, [], [], { - gravity: 0, central: false, includeBridges: false, includeRelations: false, - includeCollisions: false, timestep: 0.001, velocityDecay: 0, speedLimit: 14.4, - }); - emit({ - velocities: bodies.map(node => [node.vx, node.vy]), - momentum: [ - bodies.filter(node => !node.ghost).reduce( - (sum, node) => sum + node.gravity_mass * node.vx, 0 - ), - bodies.filter(node => !node.ghost).reduce( - (sum, node) => sum + node.gravity_mass * node.vy, 0 - ), - ], - maximum: Math.max(...bodies.filter(node => !node.ghost) - .map(node => Math.hypot(node.vx, node.vy))), - }); - """ - ) - assert report["velocities"][0] == pytest.approx([1.44, 0], abs=1e-3) - assert report["velocities"][1] == pytest.approx([-14.4, 0], abs=1e-3) - assert report["velocities"][2] == pytest.approx([0, 0], abs=1e-3) - assert report["velocities"][3] == pytest.approx([99, -99]) - # Invalid finite-position payloads are sanitized into the common scale; allow the resulting - # sub-millisecond numerical residue while still requiring near-zero total momentum. - assert report["momentum"] == pytest.approx([0, 0], abs=2e-3) - assert report["maximum"] == pytest.approx(14.4) - - -@requires_node -def test_barnes_hut_matches_exact_fixture_with_subquadratic_traversal() -> None: - report = _run_node( - """ - const fixture = Array.from({ length: 80 }, (_, i) => ({ - id: 'n' + i, x: (i % 10) * 12 + (i % 3), y: Math.floor(i / 10) * 11, - vx: 0, vy: 0, gravity_mass: 1 + (i % 5), community_id: 'large', - })); - const exact = fixture.map(n => ({ ...n })), approximate = fixture.map(n => ({ ...n })); - I.applyGalaxyGravity(exact, { gravity: 2, softening: 5, alpha: 1, exactLimit: 1000 }); - const stats = I.applyGalaxyGravity(approximate, { - gravity: 2, softening: 5, alpha: 1, exactLimit: 64, theta: 0.85, - }); - let error = 0, signal = 0; - exact.forEach((node, i) => { - error += (node.vx - approximate[i].vx) ** 2 + (node.vy - approximate[i].vy) ** 2; - signal += node.vx ** 2 + node.vy ** 2; - }); - emit({ - relativeRms: Math.sqrt(error / signal), stats, quadratic: fixture.length ** 2, - momentum: [ - approximate.reduce((sum, node) => sum + node.gravity_mass * node.vx, 0), - approximate.reduce((sum, node) => sum + node.gravity_mass * node.vy, 0), - ], - }); - """ - ) - assert report["stats"]["approximations"] > 0 - assert report["stats"]["traversals"] < report["quadratic"] - assert report["relativeRms"] < 0.25 - assert report["momentum"] == pytest.approx([0, 0], abs=1e-10) - - -@requires_node -def test_community_bridge_force_scales_with_evidence_and_preserves_momentum() -> None: - report = _run_node( - """ - const run = strength => { - const nodes = [ - { id: 'left', x: 0, y: 0, vx: 0, vy: 0, gravity_mass: 2, community_id: 'left' }, - { id: 'right', x: 20, y: 0, vx: 0, vy: 0, gravity_mass: 4, community_id: 'right' }, - ]; - const stats = I.applyCommunityBridgeGravity(nodes, [{ - source_community: 'left', target_community: 'right', physics_strength: strength, - }], { gravity: 4, softening: 8, alpha: 1 }); - return { nodes, stats }; - }; - const weak = run(0.4), strong = run(0.8), none = run(0); - emit({ - ratio: strong.nodes[0].vx / weak.nodes[0].vx, - momentum: 2 * strong.nodes[0].vx + 4 * strong.nodes[1].vx, - applied: strong.stats.bridges, - none: none.nodes.map(n => [n.vx, n.vy]), - }); - """ - ) - assert report["ratio"] == pytest.approx(2) - assert report["momentum"] == pytest.approx(0, abs=1e-12) - assert report["applied"] == 1 - assert report["none"] == [[0, 0], [0, 0]] - - -@requires_node -def test_orbital_seed_is_deterministic_tangential_and_one_shot() -> None: - report = _run_node( - """ - const fixture = () => [ - { id: 'sun', x: 0, y: 0, gravity_mass: 8, community_id: 's' }, - { id: 'planet', x: 20, y: 0, gravity_mass: 1, community_id: 's' }, - ]; - const first = fixture(), second = fixture(), reduced = fixture(); - const haunted = fixture().concat([{ - id: 'history', x: 10, y: 10, vx: 9, vy: -7, gravity_mass: 0, - community_id: 's', ghost: true, - }]); - I.seedGalaxyOrbits(first, 42, 48, 8, false); - I.seedGalaxyOrbits(second, 42, 48, 8, false); - const initial = first.map(n => [n.vx, n.vy]); - first[1].vx = 123; first[1].vy = -456; - I.seedGalaxyOrbits(first, 42, 48, 8, false); - I.seedGalaxyOrbits(reduced, 42, 48, 8, true); - I.seedGalaxyOrbits(reduced, 42, 48, 8, false); - I.seedGalaxyOrbits(haunted, 42, 48, 8, false); - emit({ - deterministic: initial, - second: second.map(n => [n.vx, n.vy]), - tangentialDot: 20 * initial[1][0], - oneShot: [first[1].vx, first[1].vy], - reduced: reduced.map(n => [n.vx, n.vy]), - ghost: [haunted[2].vx, haunted[2].vy], - hauntedStar: [haunted[0].vx, haunted[0].vy], - }); - """ - ) - assert report["deterministic"] == report["second"] - assert report["tangentialDot"] == pytest.approx(0, abs=1e-12) - assert report["oneShot"] == [123, -456] - assert report["reduced"] == report["deterministic"] - assert report["ghost"] == [0, 0] - assert report["hauntedStar"] == pytest.approx([0, 0], abs=1e-12) - - -@requires_node -def test_late_planet_gets_a_one_shot_orbit_without_erasing_the_existing_system() -> None: - """Incremental reveal seeds the fresh planet and preserves the old star-relative phase.""" - report = _run_node( - """ - const nodes = [ - { id: 'star', anchor_role: 'community', community_id: 'solar', - system_anchor_id: 'star', orbit_tier: 0, gravity_mass: 8, radius: 5, - x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'p1', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 1, - gravity_mass: 1, radius: 3, x: 16, y: 0, vx: 0, vy: 0 }, - ]; - const momentum = () => ['vx', 'vy'].map(axis => nodes.reduce((sum, node) => - sum + node.gravity_mass * (Number(node[axis]) || 0), 0)); - const relative = (node, anchor) => [node.vx - anchor.vx, node.vy - anchor.vy]; - I.seedGalaxyOrbits(nodes, 901, 48, 32, false); - const star = nodes[0], p1 = nodes[1]; - const starBefore = [star.x, star.y, star.vx, star.vy]; - const oldRelative = relative(p1, star); - const oldPhase = [p1.x - star.x, p1.y - star.y]; - const beforeMomentum = momentum(); - const p2 = { id: 'p2', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 2, - gravity_mass: 1, radius: 3, x: 0, y: 24, vx: 0, vy: 0 }; - nodes.push(p2); - const revealedMomentum = momentum(); - I.seedGalaxyOrbits(nodes, 901, 48, 32, false); - const afterRelative = relative(p1, star); - const freshRelative = relative(p2, star); - const freshRadialDot = (p2.x - star.x) * freshRelative[0] - + (p2.y - star.y) * freshRelative[1]; - const oldAngular = oldPhase[0] * oldRelative[1] - oldPhase[1] * oldRelative[0]; - const freshAngular = (p2.x - star.x) * freshRelative[1] - - (p2.y - star.y) * freshRelative[0]; - const afterMomentum = momentum(); - const afterFirst = nodes.map(node => [node.vx, node.vy]); - I.seedGalaxyOrbits(nodes, 901, 48, 32, false); - emit({ - oldRelative, afterRelative, oldPhase, - newPhase: [p1.x - star.x, p1.y - star.y], - freshRelative, freshRadialDot, oldAngular, freshAngular, - beforeMomentum, revealedMomentum, afterMomentum, - starBefore, starAfter: [star.x, star.y, star.vx, star.vy], - afterFirst, afterSecond: nodes.map(node => [node.vx, node.vy]), - seeded: nodes.map(node => !!node.__galaxyOrbitSeeded), - }); - """ - ) - assert report["seeded"] == [True, True, True] - assert math.hypot(*report["freshRelative"]) > 1e-6 - assert report["freshRadialDot"] == pytest.approx(0, abs=1e-10) - assert math.copysign(1, report["freshAngular"]) == math.copysign( - 1, report["oldAngular"] - ) - assert report["afterRelative"] == pytest.approx(report["oldRelative"], abs=1e-10) - assert report["newPhase"] == pytest.approx(report["oldPhase"], abs=1e-12) - # The seeded local system intentionally has nonzero total momentum: its star is the - # stationary local carrier rather than a barycentric recoil sink. - assert report["revealedMomentum"] == pytest.approx(report["beforeMomentum"], abs=1e-10) - assert report["afterMomentum"] != pytest.approx(report["beforeMomentum"], abs=1e-10) - assert report["starAfter"] == pytest.approx(report["starBefore"], abs=1e-12) - for first, second in zip(report["afterFirst"], report["afterSecond"]): - assert second == pytest.approx(first, abs=1e-12) - - -@requires_node -def test_many_massive_satellites_each_keep_a_star_only_circular_seed_and_visible_phase() -> None: - """Aggregate stellar recoil and the soft pressure band cannot zero a planet's orbit seed.""" - report = _run_node( - """ - const nodes = [{ id: 'star', anchor_role: 'community', community_id: 'solar', - gravity_mass: 8, radius: 5, x: 0, y: 0, vx: 0, vy: 0 }]; - // The counter-orbiting probe lies inside the star's smooth 6-unit pressure band. The - // many much heavier bodies on the other side make aggregate anchor recoil dominant in - // the old relative-acceleration seeder (total satellite mass is 40 > star mass 8). - nodes.push({ id: 'probe', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 1, - gravity_mass: 1, radius: 3, x: -13, y: 0, vx: 0, vy: 0 }); - for (let index = 0; index < 13; index += 1) { - const angle = -0.78 + index * 0.13, radius = 21 + index * 2.2; - nodes.push({ id: `heavy-${index}`, community_id: 'solar', system_anchor_id: 'star', - orbit_tier: index + 2, gravity_mass: 3, radius: 2, - x: Math.cos(angle) * radius, y: Math.sin(angle) * radius, vx: 0, vy: 0 }); - } - const star = nodes[0], localG = I.galaxyStellarGravityConstant(48), softening = 32; - I.seedGalaxyOrbits(nodes, 763, 48, softening, false); - const seeded = nodes.slice(1).map(node => { - const dx = node.x - star.x, dy = node.y - star.y, radius = Math.hypot(dx, dy); - const relativeVx = node.vx - star.vx, relativeVy = node.vy - star.vy; - const rawInward = localG * star.gravity_mass * radius - / Math.pow(radius * radius + softening * softening, 1.5); - return { - id: node.id, radius, expectedSpeed: Math.sqrt(rawInward * radius), - relativeSpeed: Math.hypot(relativeVx, relativeVy), - radialDot: dx * relativeVx + dy * relativeVy, - angular: dx * relativeVy - dy * relativeVx, - }; - }); - const initialAngles = new Map(nodes.slice(1).map(node => [node.id, - Math.atan2(node.y - star.y, node.x - star.x)])); - const travel = new Map(nodes.slice(1).map(node => [node.id, 0])); - const delta = (next, previous) => Math.atan2(Math.sin(next - previous), - Math.cos(next - previous)); - let clearance = Infinity, maximumSpeed = 0, maximumRelativeRadialAcceleration = -Infinity; - const options = { - gravity: 48, softening, central: false, includeMutualSystems: false, - includeRelations: false, includeBridges: false, includeCollisions: false, - includeOrbitalSeparation: false, skipSystemAnchorPairs: true, - systemAnchorExclusionPadding: 1.5, localRelativeSpeedLimit: 48, - // This runtime-centrality oracle isolates the dominant-star law. The separate - // pressure test covers the deliberate outward near-surface band. - systemAnchorRepulsionAcceleration: 0, - timestep: 0.032, velocityDecay: 0.00005, speedLimit: 48, - }; - for (let step = 0; step < 360; step += 1) { - const acceleration = I.galaxyAccelerations(nodes, [], [], options); - const anchorAcceleration = acceleration.get(star); - nodes.slice(1).forEach(node => { - const dx = node.x - star.x, dy = node.y - star.y; - const radius = Math.hypot(dx, dy); - const bodyAcceleration = acceleration.get(node); - maximumRelativeRadialAcceleration = Math.max(maximumRelativeRadialAcceleration, - ((bodyAcceleration.ax - anchorAcceleration.ax) * dx - + (bodyAcceleration.ay - anchorAcceleration.ay) * dy) / radius); - }); - const tick = I.integrateGalaxyLeapfrog(nodes, [], [], options); - maximumSpeed = Math.max(maximumSpeed, tick.maximumSpeed); - nodes.slice(1).forEach(node => { - const angle = Math.atan2(node.y - star.y, node.x - star.x); - travel.set(node.id, travel.get(node.id) + delta(angle, initialAngles.get(node.id))); - initialAngles.set(node.id, angle); - clearance = Math.min(clearance, Math.hypot(node.x - star.x, node.y - star.y) - - node.radius - star.radius - 1.5); - }); - } - emit({ seeded, travel: [...travel.values()], clearance, maximumSpeed, - maximumRelativeRadialAcceleration, - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); - """ - ) - assert report["finite"] is True - assert report["clearance"] >= -1e-9 - assert report["maximumSpeed"] <= 48 - seeded = report["seeded"] - assert len(seeded) == 14 - # The velocity is the star-only softened circular law, even for the pressure-band probe; - # all massive satellites share one local spin direction and none has a radial-only seed. - assert all(item["relativeSpeed"] == pytest.approx(item["expectedSpeed"], rel=1e-10) - for item in seeded), seeded - assert all(abs(item["radialDot"]) <= 1e-10 for item in seeded), seeded - assert all(abs(item["angular"]) > 1e-8 for item in seeded), seeded - signs = {math.copysign(1, item["angular"]) for item in seeded} - assert len(signs) == 1 - # Every live sample still sees an inward dominant-star relative acceleration even though - # satellites outweigh their star fivefold. Aggregate star recoil must be common drift, not - # an outward local force on the opposite probe. - assert report["maximumRelativeRadialAcceleration"] < 0, report - assert min(abs(value) for value in report["travel"]) > 0.45, report - - -@requires_node -def test_system_orbital_seed_preserves_barycentre_and_hierarchical_motion() -> None: - report = _run_node( - """ - const fixture = () => [ - { id: 'a', x: -100, y: 0, gravity_mass: 16, community_id: 'a' }, - { id: 'b', x: 80, y: 0, gravity_mass: 9, community_id: 'b' }, - { id: 'c', x: 0, y: 120, gravity_mass: 4, community_id: 'c' }, - ]; - const first = fixture(), second = fixture(), reduced = fixture(), late = fixture(); - I.seedGalaxySystemOrbits(first, 91, 48, 40, false); - I.seedGalaxySystemOrbits(second, 91, 48, 40, false); - const totalMass = first.reduce((sum, node) => sum + node.gravity_mass, 0); - const bx = first.reduce((sum, node) => sum + node.x * node.gravity_mass, 0) / totalMass; - const by = first.reduce((sum, node) => sum + node.y * node.gravity_mass, 0) / totalMass; - const initial = first.map(node => [node.vx, node.vy]); - first[0].vx = 123; first[0].vy = -456; - I.seedGalaxySystemOrbits(first, 91, 48, 40, false); - I.seedGalaxySystemOrbits(reduced, 91, 48, 40, true); - I.seedGalaxySystemOrbits(reduced, 91, 48, 40, false); - Object.defineProperty(late[0], '__galaxySystemOrbitSeeded', { - value: true, writable: true, configurable: true, - }); - Object.defineProperty(late[1], '__galaxySystemOrbitSeeded', { - value: true, writable: true, configurable: true, - }); - late[0].vx = 1; late[0].vy = 2; - late[1].vx = -16 / 9; late[1].vy = -32 / 9; - I.seedGalaxySystemOrbits(late, 91, 48, 40, false); - emit({ - deterministic: initial, - second: second.map(node => [node.vx, node.vy]), - radialDots: second.map(node => (node.x - bx) * node.vx + (node.y - by) * node.vy), - momentum: [ - second.reduce((sum, node) => sum + node.gravity_mass * node.vx, 0), - second.reduce((sum, node) => sum + node.gravity_mass * node.vy, 0), - ], - angularSpeeds: second.map(node => { - const dx = node.x - bx, dy = node.y - by; - return Math.abs(dx * node.vy - dy * node.vx) / (dx * dx + dy * dy); - }), - moving: second.every(node => Math.hypot(node.vx, node.vy) > 0), - oneShot: [first[0].vx, first[0].vy], - reduced: reduced.map(node => [node.vx, node.vy]), - late: late.map(node => [node.vx, node.vy]), - lateSeeded: late.every(node => node.__galaxySystemOrbitSeeded), - }); - """ - ) - assert report["deterministic"] == report["second"] - # The selected global/fallback anchor is an external black-hole frame. It remains still; - # the remaining systems get distinct tangential COM kicks rather than a fake global - # momentum cancellation that would make the visible galaxy fail to rotate. - assert max(report["angularSpeeds"]) - min(report["angularSpeeds"]) > 1e-6 - assert report["second"][0] == pytest.approx([0, 0], abs=1e-12) - assert any(math.hypot(*velocity) > 1e-8 for velocity in report["second"][1:]) - assert report["momentum"] != pytest.approx([0, 0], abs=1e-10) - assert report["oneShot"] == [123, -456] - assert report["reduced"] == report["deterministic"] - assert report["late"][0] == pytest.approx([1, 2]) - assert report["late"][1] == pytest.approx([-16 / 9, -32 / 9]) - # The only untagged late system receives its own black-hole tangent. Tagged systems keep - # their supplied phase instead of all three being reset as one barycentric block. - assert math.hypot(*report["late"][2]) > 1e-8 - assert report["lateSeeded"] is True - - -@requires_node -def test_global_system_seed_uses_faster_default_speed_cap_with_an_external_anchor() -> None: - """Authored systems orbit a fixed black-hole frame at the 30%-faster default cap.""" - report = _run_node( - """ - const nodes = [ - { id: 'bh', anchor_role: 'global', community_id: 'core', gravity_mass: 1000, - x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'east-star', anchor_role: 'community', community_id: 'east', gravity_mass: 1, - x: 100, y: 0, vx: 0, vy: 0 }, - { id: 'west-star', anchor_role: 'community', community_id: 'west', gravity_mass: 1, - x: -100, y: 0, vx: 0, vy: 0 }, - ]; - const field = I.galaxyBlackHoleField(nodes, { gravity: 400, softening: 40 }); - I.seedGalaxySystemOrbits(nodes, 183, 400, 40, false); - const anchor = nodes[0]; - emit({ - fieldSpeeds: field.systems.map(item => item.circularSpeed), - relative: nodes.slice(1).map(node => { - const dx = node.x - anchor.x, dy = node.y - anchor.y; - const vx = node.vx - anchor.vx, vy = node.vy - anchor.vy; - return { speed: Math.hypot(vx, vy), radialDot: dx * vx + dy * vy, - angular: dx * vy - dy * vx }; - }), - momentum: ['vx', 'vy'].map(axis => nodes.reduce((sum, node) => - sum + node.gravity_mass * node[axis], 0)), - anchor: [anchor.x, anchor.y, anchor.vx, anchor.vy], - }); - """ - ) - base_seed_limit = 18 - seed_limit = base_seed_limit * 1.3 - assert min(report["fieldSpeeds"]) > seed_limit - # Symmetric east/west seeded systems preserve zero net carrier momentum. - assert all(seed_limit * 0.9 < item["speed"] <= seed_limit * 1.01 - for item in report["relative"]), report - assert all(abs(item["angular"]) > 1e-8 for item in report["relative"]) - assert report["momentum"] == pytest.approx([0, 0], abs=1e-10) - assert report["anchor"] == pytest.approx([0, 0, 0, 0], abs=1e-12) - - -@requires_node -def test_center_coincident_external_singleton_is_admitted_to_a_live_black_hole_orbit() -> None: - """A newly revealed one-node system at the event horizon must never remain frozen.""" - report = _run_node( - """ - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - system_anchor_id: 'black-hole', orbit_tier: 0, gravity_mass: 64, radius: 10, - x: 0, y: 0, vx: 0, vy: 0 }, - // This is the exact late/reveal failure: it has a valid system identity but arrives - // at the black-hole centre with no velocity and no local satellite to seed it. - { id: 'late-singleton', anchor_role: 'community', community_id: 'late', - system_anchor_id: 'late-singleton', orbit_tier: 0, gravity_mass: 8, radius: 5, - x: 0, y: 0, vx: 0, vy: 0 }, - ]; - const options = { - gravity: 48, softening: 32, centralSoftening: 40, - includeMutualSystems: true, mutualSystemGravityFraction: .12, - mutualSystemSoftening: 80, includeRelations: false, includeBridges: false, - includeOrbitalSeparation: false, skipSystemAnchorPairs: true, - systemAnchorExclusionPadding: 1.5, includeBlackHoleExclusion: true, - blackHoleExclusionPadding: 2.5, includeFarFieldConfinement: true, - farFieldEnvelopeScale: 1.75, farFieldMinimumRadius: 96, - farFieldSoftFraction: .82, farFieldAcceleration: 12, farFieldMaxAcceleration: 16, - localRelativeSpeedLimit: 48, timestep: .032, wallClockSeconds: 1 / 30, - inwardConvergence: true, velocityDecay: .00005, speedLimit: 48, - includeCollisions: false, - }; - I.seedGalaxyOrbits(nodes, 60421, 48, 32, false); - I.seedGalaxySystemOrbits(nodes, 60421, 48, 40, false); - const anchor = nodes[0], singleton = nodes[1]; - const phase = () => Math.atan2(singleton.y - anchor.y, singleton.x - anchor.x); - const state = () => { - const dx = singleton.x - anchor.x, dy = singleton.y - anchor.y; - const dvx = singleton.vx - anchor.vx, dvy = singleton.vy - anchor.vy; - return { radius: Math.hypot(dx, dy), tangent: dx * dvy - dy * dvx, - radial: dx * dvx + dy * dvy }; - }; - const seeded = state(), initial = phase(); - let previous = initial, travel = 0, frozenSteps = 0, speedCaps = 0, minimumClearance = Infinity; - for (let step = 0; step < 180; step += 1) { - const tick = I.integrateGalaxyLeapfrog(nodes, [], [], options); - speedCaps += tick.speedCapped ? 1 : 0; - const next = phase(); - const delta = Math.atan2(Math.sin(next - previous), Math.cos(next - previous)); - travel += delta; - if (Math.abs(delta) < 1e-8) frozenSteps++; - previous = next; - minimumClearance = Math.min(minimumClearance, - Math.hypot(singleton.x - anchor.x, singleton.y - anchor.y) - - singleton.radius - anchor.radius - options.blackHoleExclusionPadding); - } - emit({ seeded, travel, frozenSteps, speedCaps, minimumClearance, - tagged: singleton.__galaxySystemOrbitSeeded === true, - anchor: [anchor.x, anchor.y, anchor.vx, anchor.vy], - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); - """ - ) - assert report["finite"] is True - assert report["tagged"] is True - assert report["anchor"] == pytest.approx([0, 0, 0, 0], abs=1e-12) - assert report["seeded"]["radius"] >= 17.5 - 1e-8 - assert abs(report["seeded"]["tangent"]) > 1e-5 - assert report["minimumClearance"] >= -1e-8 - assert abs(report["travel"]) > 0.05 - assert report["frozenSteps"] == 0 - assert report["speedCaps"] == 0 - - -@requires_node -def test_center_coincident_core_satellite_is_seeded_outside_the_black_hole_with_phase() -> None: - """A core member arriving at its explicit black hole has the same no-freeze guarantee.""" - report = _run_node( - """ - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - system_anchor_id: 'black-hole', orbit_tier: 0, gravity_mass: 64, radius: 10, - x: 0, y: 0, vx: 0, vy: 0 }, - // Core evidence is a black-hole satellite, not an independent system COM. This - // exact coincidence used to survive local seeding and remain a painted still point. - { id: 'core-satellite', anchor_role: 'none', community_id: 'core', - system_anchor_id: 'black-hole', orbit_tier: 1, gravity_mass: 2, radius: 3, - x: 0, y: 0, vx: 0, vy: 0 }, - ]; - const options = { - gravity: 48, softening: 32, centralSoftening: 40, - includeMutualSystems: true, mutualSystemGravityFraction: .12, - mutualSystemSoftening: 80, includeRelations: false, includeBridges: false, - includeOrbitalSeparation: false, skipSystemAnchorPairs: true, - systemAnchorExclusionPadding: 1.5, includeBlackHoleExclusion: true, - blackHoleExclusionPadding: 2.5, includeFarFieldConfinement: true, - farFieldEnvelopeScale: 1.75, farFieldMinimumRadius: 96, - farFieldSoftFraction: .82, farFieldAcceleration: 12, farFieldMaxAcceleration: 16, - localRelativeSpeedLimit: 48, timestep: .032, wallClockSeconds: 1 / 30, - inwardConvergence: true, velocityDecay: .00005, speedLimit: 48, - includeCollisions: false, - }; - I.seedGalaxyOrbits(nodes, 60422, 48, 32, false); - I.seedGalaxySystemOrbits(nodes, 60422, 48, 40, false); - const anchor = nodes[0], satellite = nodes[1]; - const phase = () => Math.atan2(satellite.y - anchor.y, satellite.x - anchor.x); - const state = () => { - const dx = satellite.x - anchor.x, dy = satellite.y - anchor.y; - const dvx = satellite.vx - anchor.vx, dvy = satellite.vy - anchor.vy; - return { radius: Math.hypot(dx, dy), tangent: dx * dvy - dy * dvx, - radial: dx * dvx + dy * dvy }; - }; - const seeded = state(); - let previous = phase(), travel = 0, frozenSteps = 0, speedCaps = 0, minimumClearance = Infinity; - for (let step = 0; step < 180; step += 1) { - const tick = I.integrateGalaxyLeapfrog(nodes, [], [], options); - speedCaps += tick.speedCapped ? 1 : 0; - const next = phase(); - const delta = Math.atan2(Math.sin(next - previous), Math.cos(next - previous)); - travel += delta; - if (Math.abs(delta) < 1e-8) frozenSteps++; - previous = next; - minimumClearance = Math.min(minimumClearance, - Math.hypot(satellite.x - anchor.x, satellite.y - anchor.y) - - satellite.radius - anchor.radius - options.blackHoleExclusionPadding); - } - emit({ seeded, travel, frozenSteps, speedCaps, minimumClearance, - parent: satellite.__galaxyOrbitAnchorId || null, - tagged: satellite.__galaxyOrbitSeeded === true, - anchor: [anchor.x, anchor.y, anchor.vx, anchor.vy], - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); - """ - ) - assert report["finite"] is True - assert report["parent"] == "black-hole" - assert report["tagged"] is True - assert report["anchor"] == pytest.approx([0, 0, 0, 0], abs=1e-12) - assert report["seeded"]["radius"] >= 15.5 - 1e-8 - assert abs(report["seeded"]["tangent"]) > 1e-5 - assert report["minimumClearance"] >= -1e-8 - assert abs(report["travel"]) > 0.05 - assert report["frozenSteps"] == 0 - assert report["speedCaps"] == 0 - - -@requires_node -def test_galaxy_live_limit_matches_the_complete_overview_contract() -> None: - """The complete public overview remains expanded and physical; larger scenes stay bounded.""" - report = _run_engine( - """ - const within = [ - I.galaxySceneWithinLiveLimit({ nodes: Array(1500), links: Array(3000) }), - I.galaxySceneWithinLiveLimit({ nodes: Array(1501), links: [] }), - I.galaxySceneWithinLiveLimit({ nodes: [], links: Array(3001) }), - ]; - let nextFrame = 1; - const frames = new Map(); - window.requestAnimationFrame = callback => { - const id = nextFrame++; frames.set(id, callback); return id; - }; - window.cancelAnimationFrame = id => frames.delete(id); - const flush = now => { - const batch = [...frames.values()]; frames.clear(); batch.forEach(callback => callback(now)); - }; - const scene = (count, edgeCount) => ({ - meta: { layout_seed: 91 }, - nodes: Array.from({ length: count }, (_, index) => ({ - id: index === 0 ? 'black-hole' : `node-${index}`, - community_id: 'core', - system_anchor_id: 'black-hole', - anchor_role: index === 0 ? 'global' : 'none', - orbit_tier: index, - gravity_mass: index === 0 ? 16 : 1, - visual_radius: index === 0 ? 8 : 2, - x: index === 0 ? 0 : 45 + index, - y: index % 7, - vx: 0, - vy: 0, - })), - edges: Array.from({ length: edgeCount }, (_, index) => ({ - id: `edge-${index}`, source: 'black-hole', - target: `node-${1 + index % Math.max(1, count - 1)}`, - layer: 'semantic', strength: 0.5, rest_length: 20, spring_strength: 0.08, - })), - }); - - const galaxy = G.create(el, { reducedMotion: () => true }); - galaxy.setData(scene(1500, 3000)); - store.onZoom({ k: 0.1 }); - const before = galaxy.physicsDiagnostics(); - flush(0); flush(34); flush(68); - const live = galaxy.physicsDiagnostics(); - const autoCollapsed = galaxy.state().collapsed; - galaxy.setCollapse(true); - const explicitCollapsed = galaxy.state().collapsed; - galaxy.setCollapse(false); - galaxy.setData(scene(1501, 3000)); - const nodeOverflow = galaxy.physicsDiagnostics(); - galaxy.setData(scene(1500, 3001)); - const edgeOverflow = galaxy.physicsDiagnostics(); - galaxy.destroy(); - - const full = G.create(el, { - reducedMotion: () => false, - renderMode: 'full', - }); - full.setPreset('original'); - full.setData(scene(601, 600)); - const classicFull = full.physicsDiagnostics(); - emit({ within, before, live, autoCollapsed, explicitCollapsed, nodeOverflow, - edgeOverflow, classicFull }); - """ - ) - assert report["within"] == [True, False, False] - assert report["before"]["renderedNodes"] == 1500 - assert report["before"]["renderedLinks"] == 3000 - assert report["before"]["galaxyLiveNodeLimit"] == 1500 - assert report["before"]["galaxyLiveLinkLimit"] == 3000 - assert report["before"]["withinGalaxyLiveLimit"] is True - assert report["before"]["largeRenderTier"] is True - assert report["before"]["staticLayout"] is False - assert report["before"]["active"] is True - assert report["live"]["steps"] >= report["before"]["steps"] + 3 - assert report["live"]["active"] is True - assert report["autoCollapsed"] is False - assert report["explicitCollapsed"] is True - assert report["nodeOverflow"]["staticLayout"] is True - assert report["edgeOverflow"]["staticLayout"] is True - assert report["classicFull"]["mode"] == "original" - assert report["classicFull"]["staticLayout"] is True - - -@requires_node -def test_reduced_motion_keeps_eight_independent_solar_systems_orbiting() -> None: - """The accessible visual preference keeps a visibly quick two-scale galaxy live. - - This deliberately uses eight independently phased systems and fixed solver time rather - than wall-clock delay. The former tuning only covered a barely visible minimum travel - (0.317 rad around the black hole and 0.608 rad locally in this fixture). A Galaxy has to - make both levels of hierarchy legible in the ordinary dashboard interval. - """ - report = _run_node( - """ - const nodes=[{id:'bh',anchor_role:'global',community_id:'core',gravity_mass:16,radius:10,x:0,y:0,vx:0,vy:0}],links=[]; - for(let s=0;s<8;s++){const p=s*2.4,r=105+s*13,cx=Math.cos(p)*r,cy=Math.sin(p)*r*.82; - for(let m=0;m<3;m++){const id=`s${s}-${m}`,q=m?14+m*5:0; - nodes.push({id,community_id:`s${s}`,system_anchor_id:`s${s}-0`,anchor_role:m?'none':'community',orbit_tier:m,gravity_mass:m?1:7,radius:m?3:5,x:cx+Math.cos(p+m*1.5)*q,y:cy+Math.sin(p+m*1.5)*q,vx:0,vy:0}); - if(m)links.push({source:`s${s}-0`,target:id,rest_length:q,spring_strength:.08});}} - const o={gravity:48,softening:32,centralSoftening:40,includeMutualSystems:true,mutualSystemGravityFraction:.12,mutualSystemSoftening:80,includeRelations:true,includeRelationSprings:false,skipSystemAnchorRelations:true,orbitScale:.25,relationConstraintRate:24,relationConstraintMaxCorrection:12,relationPadding:12,includeOrbitalSeparation:true,orbitalSeparationPadding:12,orbitalSeparationStrength:.8,crossCommunitySeparationPadding:1.5,crossCommunitySeparationStrength:.144,orbitalSeparationMaxCorrection:4,orbitalSeparationMaxVelocityCorrection:8,preserveLocalTangentialVelocity:true,skipSystemAnchorPairs:true,systemAnchorExclusionPadding:1.5,includeBlackHoleExclusion:true,blackHoleExclusionPadding:2.5,includeFarFieldConfinement:true,farFieldEnvelopeScale:1.75,farFieldMinimumRadius:96,farFieldSoftFraction:.82,farFieldAcceleration:12,farFieldMaxAcceleration:16,localRelativeSpeedLimit:48,timestep:.032,wallClockSeconds:1/30,inwardConvergence:true,velocityDecay:.00005,speedLimit:48,includeCollisions:false}; - I.seedGalaxyOrbits(nodes,91,48,32,true); I.seedGalaxySystemOrbits(nodes,91,48,40,true); - const cs=()=>I.communityCenters(nodes),d=(a,b)=>Math.atan2(Math.sin(a-b),Math.cos(a-b)),systems=[...Array(8).keys()].map(i=>`s${i}`),planets=nodes.filter(n=>n.orbit_tier>0); - const pg=new Map(systems.map(k=>{const c=cs().get(k);return[k,Math.atan2(c.y,c.x)]})),pl=new Map(planets.map(n=>{const a=nodes.find(x=>x.id===n.system_anchor_id);return[n.id,Math.atan2(n.y-a.y,n.x-a.x)]})),gt=new Map(systems.map(k=>[k,0])),lt=new Map(planets.map(n=>[n.id,0])); - let clear=Infinity,max=0,envelope=0,speedCaps=0;for(let i=0;i<240;i++){const t=I.integrateGalaxyLeapfrog(nodes,links,[],o);max=Math.max(max,t.maximumSpeed);speedCaps+=t.speedCapped?1:0;envelope=t.farFieldConfinement.envelopeRadius;systems.forEach(k=>{const c=cs().get(k),a=Math.atan2(c.y,c.x);gt.set(k,gt.get(k)+d(a,pg.get(k)));pg.set(k,a)});planets.forEach(n=>{const a=nodes.find(x=>x.id===n.system_anchor_id),q=Math.atan2(n.y-a.y,n.x-a.x);lt.set(n.id,lt.get(n.id)+d(q,pl.get(n.id)));pl.set(n.id,q);clear=Math.min(clear,Math.hypot(n.x-a.x,n.y-a.y)-n.radius-a.radius-1.5)});} - emit({global:[...gt.values()],local:[...lt.values()],clear,max,speedCaps,envelope,bounded:nodes.slice(1).every(n=>Math.hypot(n.x,n.y)+n.radius<=envelope+1e-8),finite:nodes.every(n=>[n.x,n.y,n.vx,n.vy].every(Number.isFinite))}); - """ - ) - assert report["finite"] is report["bounded"] is True - assert report["clear"] >= -1e-9 - assert report["max"] <= 48 - assert report["speedCaps"] == 0 - # At 30 Hz this is eight seconds of real solver time: every solar-system COM advances a - # clearly visible 26° and every planet advances 40° about its dominant star. These - # thresholds reject the previous slow, technically-nonzero drift while leaving bounded - # eccentric motion rather than requiring a rigid carousel. - assert min(abs(value) for value in report["global"]) > 0.45, report - assert min(abs(value) for value in report["local"]) > 0.70, report - - -@requires_node -def test_reduced_motion_has_exact_dual_scale_orbit_parity_and_star_surface_safety() -> None: - """Reduced visual motion cannot alter Galaxy initial conditions or stellar boundaries.""" - report = _run_node( - """ - const make = () => { - const nodes = [{ id: 'bh', anchor_role: 'global', community_id: 'core', - gravity_mass: 20, radius: 10, x: 0, y: 0, vx: 0, vy: 0 }], links = []; - [0.25, 2.4, 4.6, 5.65].forEach((phase, index) => { - const r = 80 + index * 25, id = `s${index}`; - const x = Math.cos(phase) * r, y = Math.sin(phase) * r * 0.82; - nodes.push({ id: `${id}-star`, anchor_role: 'community', community_id: id, - system_anchor_id: `${id}-star`, orbit_tier: 0, gravity_mass: 8, radius: 5, - x, y, vx: 0, vy: 0 }); - // The first satellite begins through the painted surface. The permanent stellar - // exclusion must project it before the fast orbital clock starts. - const distance = index === 0 ? 9 : 15 + index; - nodes.push({ id: `${id}-planet`, community_id: id, - system_anchor_id: `${id}-star`, orbit_tier: 1, gravity_mass: 1, radius: 3, - x: x + Math.cos(phase + 1.1) * distance, - y: y + Math.sin(phase + 1.1) * distance, vx: 0, vy: 0 }); - links.push({ source: `${id}-star`, target: `${id}-planet`, - rest_length: distance, spring_strength: 0.08 }); - }); - return { nodes, links }; - }; - const delta = (next, previous) => Math.atan2(Math.sin(next - previous), - Math.cos(next - previous)); - const run = reducedMotion => { - const { nodes, links } = make(); - const options = { - gravity: 48, softening: 32, centralSoftening: 40, - includeMutualSystems: true, mutualSystemGravityFraction: 0.12, - mutualSystemSoftening: 80, includeRelations: true, includeRelationSprings: false, - skipSystemAnchorRelations: true, orbitScale: 0.25, relationConstraintRate: 24, - relationConstraintMaxCorrection: 12, relationPadding: 12, - includeOrbitalSeparation: true, orbitalSeparationPadding: 12, - orbitalSeparationStrength: 0.8, crossCommunitySeparationPadding: 1.5, - crossCommunitySeparationStrength: 0.144, orbitalSeparationMaxCorrection: 4, - orbitalSeparationMaxVelocityCorrection: 8, preserveLocalTangentialVelocity: true, - skipSystemAnchorPairs: true, systemAnchorExclusionPadding: 1.5, - includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, - includeFarFieldConfinement: true, farFieldEnvelopeScale: 1.75, - farFieldMinimumRadius: 96, farFieldSoftFraction: 0.82, - farFieldAcceleration: 12, farFieldMaxAcceleration: 16, - localRelativeSpeedLimit: 48, timestep: 0.032, wallClockSeconds: 1 / 30, - inwardConvergence: true, velocityDecay: 0.00005, speedLimit: 48, - includeCollisions: false, - }; - I.seedGalaxyOrbits(nodes, 4401, 48, 32, reducedMotion); - I.seedGalaxySystemOrbits(nodes, 4401, 48, 40, reducedMotion); - const centers = () => I.communityCenters(nodes); - const systemIds = ['s0', 's1', 's2', 's3']; - const globalBefore = new Map(systemIds.map(id => { - const center = centers().get(id); return [id, Math.atan2(center.y, center.x)]; - })); - const localBefore = new Map(systemIds.map(id => { - const star = nodes.find(node => node.id === `${id}-star`); - const planet = nodes.find(node => node.id === `${id}-planet`); - return [id, Math.atan2(planet.y - star.y, planet.x - star.x)]; - })); - const seededMomentum = ['vx', 'vy'].map(axis => nodes.reduce((sum, node) => - sum + node.gravity_mass * node[axis], 0)); - let clearance = Infinity, maximumSpeed = 0, envelope = 0; - for (let step = 0; step < 180; step += 1) { - const tick = I.integrateGalaxyLeapfrog(nodes, links, [], options); - maximumSpeed = Math.max(maximumSpeed, tick.maximumSpeed); - envelope = tick.farFieldConfinement.envelopeRadius; - systemIds.forEach(id => { - const star = nodes.find(node => node.id === `${id}-star`); - const planet = nodes.find(node => node.id === `${id}-planet`); - clearance = Math.min(clearance, Math.hypot(planet.x - star.x, planet.y - star.y) - - star.radius - planet.radius - options.systemAnchorExclusionPadding); - }); - } - return { - global: systemIds.map(id => { - const center = centers().get(id); - return delta(Math.atan2(center.y, center.x), globalBefore.get(id)); - }), - local: systemIds.map(id => { - const star = nodes.find(node => node.id === `${id}-star`); - const planet = nodes.find(node => node.id === `${id}-planet`); - return delta(Math.atan2(planet.y - star.y, planet.x - star.x), localBefore.get(id)); - }), - seededMomentum, clearance, maximumSpeed, envelope, - bounded: nodes.slice(1).every(node => Math.hypot(node.x, node.y) + node.radius - <= envelope + 1e-8), - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)), - final: nodes.map(node => [node.x, node.y, node.vx, node.vy]), - }; - }; - emit({ reduced: run(true), ordinary: run(false) }); - """ - ) - reduced, ordinary = report["reduced"], report["ordinary"] - # The preference is cosmetic, so every deterministic physical result is exactly identical. - for actual, expected in zip(reduced["final"], ordinary["final"]): - assert actual == pytest.approx(expected) - # Reduced motion has exact physical parity. The black hole is an external frame, so the - # visible disk's seed momentum is not artificially cancelled through its fixed anchor. - assert reduced["seededMomentum"] == pytest.approx(ordinary["seededMomentum"], abs=1e-10) - assert reduced["seededMomentum"] != pytest.approx([0, 0], abs=1e-10) - assert reduced["final"][0] == pytest.approx([0, 0, 0, 0], abs=1e-12) - assert reduced["finite"] is reduced["bounded"] is True - assert reduced["clearance"] >= -1e-9 - assert reduced["maximumSpeed"] <= 48 - assert min(abs(value) for value in reduced["global"]) > 0.3 - assert min(abs(value) for value in reduced["local"]) > 0.45 - - -@requires_node -def test_every_local_member_gets_a_live_coherent_orbit_about_its_inferred_star() -> None: - """Every non-star member must orbit its community's dominant gravity node. - - Real scenes are not homogeneous: newer payloads carry ``system_anchor_id`` and - ``orbit_tier``, while old/imported/revealed rows often carry only a community id. The - local well must be inferred for both forms. This deliberately includes core satellites, - a metadata-free legacy system, a role-free mass-dominant system, and two late arrivals. A - nonzero system COM orbit cannot satisfy this test: each body is measured in *its star's* - moving frame on every solver step. - """ - report = _run_node( - """ - const nodes = [{ id: 'black-hole', community_id: 'core', anchor_role: 'global', - system_anchor_id: 'black-hole', orbit_tier: 0, gravity_mass: 48, radius: 9, - x: 0, y: 0, vx: 0, vy: 0 }]; - const links = []; - const add = (id, community, x, y, mass, radius, extra = {}) => { - nodes.push({ id, community_id: community, gravity_mass: mass, radius, - x, y, vx: 0, vy: 0, ...extra }); - }; - const orbit = (source, target, rest) => links.push({ source, target, - rest_length: rest, spring_strength: 0.08, relation: 'orbits' }); - // Global/core body plus two core satellites. Their central gravitational node is the - // black hole itself, not a separately-labelled community star. - add('core-explicit', 'core', 36, 0, 1.5, 3, - { system_anchor_id: 'black-hole', orbit_tier: 1 }); - add('core-legacy', 'core', -49, 8, 1, 2); - orbit('black-hole', 'core-explicit', 36); orbit('black-hole', 'core-legacy', 50); - const makeSystem = (id, cx, cy, mode) => { - const star = `${id}-star`; - const starMeta = mode === 'explicit' - ? { anchor_role: 'community', system_anchor_id: star, orbit_tier: 0 } - : mode === 'legacy' ? { anchor_role: 'community' } : {}; - add(star, id, cx, cy, 10, 5, starMeta); - [[22, 0], [-30, 9], [12, -35]].forEach(([dx, dy], index) => { - const member = `${id}-planet-${index}`; - const metadata = mode === 'explicit' - ? { system_anchor_id: star, orbit_tier: index + 1 } : {}; - add(member, id, cx + dx, cy + dy, 1 + index * .2, 2.5, metadata); - orbit(star, member, Math.hypot(dx, dy)); - }); - }; - makeSystem('explicit', 118, 28, 'explicit'); - makeSystem('legacy', -132, 60, 'legacy'); - // No role or system metadata: mass is the compatibility star-selection contract. - makeSystem('mass-star', 54, -151, 'mass'); - - const seed = () => { - I.seedGalaxyOrbits(nodes, 74017, 48, 32, false); - I.seedGalaxySystemOrbits(nodes, 74017, 48, 48, false); - }; - seed(); - // Simulate a revealed/reconciled payload after its system is already moving. One is - // explicit, one legacy; both must receive a fresh star-relative tangent, never freeze. - add('explicit-late', 'explicit', 118 - 38, 28 + 16, 1.1, 2.5, - { system_anchor_id: 'explicit-star', orbit_tier: 8 }); - add('legacy-late', 'legacy', -132 + 43, 60 - 13, 1.1, 2.5); - orbit('explicit-star', 'explicit-late', Math.hypot(38, 16)); - orbit('legacy-star', 'legacy-late', Math.hypot(43, 13)); - seed(); - - const byId = () => new Map(nodes.map(node => [node.id, node])); - const map = byId(); - const expectedAnchor = { - 'core-explicit': 'black-hole', 'core-legacy': 'black-hole', - 'explicit-planet-0': 'explicit-star', 'explicit-planet-1': 'explicit-star', - 'explicit-planet-2': 'explicit-star', 'explicit-late': 'explicit-star', - 'legacy-planet-0': 'legacy-star', 'legacy-planet-1': 'legacy-star', - 'legacy-planet-2': 'legacy-star', 'legacy-late': 'legacy-star', - 'mass-star-planet-0': 'mass-star-star', 'mass-star-planet-1': 'mass-star-star', - 'mass-star-planet-2': 'mass-star-star', - }; - const delta = (next, previous) => Math.atan2(Math.sin(next - previous), - Math.cos(next - previous)); - const tracks = Object.entries(expectedAnchor).map(([id, anchorId]) => { - const node = map.get(id), anchor = map.get(anchorId); - const dx = node.x - anchor.x, dy = node.y - anchor.y; - const dvx = node.vx - anchor.vx, dvy = node.vy - anchor.vy; - return { id, anchorId, angle: Math.atan2(dy, dx), travel: 0, - initialRadius: Math.hypot(dx, dy), minimumRadius: Math.hypot(dx, dy), - maximumRadius: Math.hypot(dx, dy), minimumTangential: Math.abs(dx * dvy - dy * dvx), - initialRadial: dx * dvx + dy * dvy, - frozenSteps: 0, direction: Math.sign(dx * dvy - dy * dvx), reversals: 0 }; - }); - const options = { - gravity: 48, softening: 32, centralSoftening: 48, timestep: .032, - velocityDecay: .00005, speedLimit: 48, localPairFraction: .15, - corePairMultiplier: .75, includeMutualSystems: true, - mutualSystemGravityFraction: .12, mutualSystemSoftening: 80, - includeRelations: true, includeRelationSprings: false, - skipSystemAnchorRelations: true, skipOrbitalSystemRelations: true, - orbitScale: .25, relationConstraintRate: 24, relationConstraintMaxCorrection: 12, - relationPadding: 15, includeOrbitalSeparation: true, - orbitalSeparationPadding: 15, orbitalSeparationStrength: 1, - crossCommunitySeparationPadding: 1.5, crossCommunitySeparationStrength: .18, - orbitalSeparationMaxCorrection: 4, orbitalSeparationMaxVelocityCorrection: 8, - preserveLocalTangentialVelocity: true, preserveSystemRadii: true, - skipSystemAnchorPairs: true, systemAnchorExclusionPadding: 1.5, - systemAnchorRepulsionRange: 6, systemAnchorRepulsionAcceleration: .12, - includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, - includeFarFieldConfinement: true, farFieldEnvelopeScale: 1.75, - farFieldMinimumRadius: 96, farFieldSoftFraction: .82, - farFieldAcceleration: 12, farFieldMaxAcceleration: 16, - localRelativeSpeedLimit: 48, inwardConvergence: false, - wallClockSeconds: 1 / 30, includeCollisions: false, includeSystemPacking: false, - }; - // The first live tick assigns the deterministic carrier-spin direction. Measure - // sustained local motion after that one-time insertion, not against the stale - // pre-admission tangent inherited from the authored coordinates. - I.integrateGalaxyLeapfrog(nodes, links, [], options); - tracks.forEach(track => { - const node = map.get(track.id), anchor = map.get(track.anchorId); - const dx = node.x - anchor.x, dy = node.y - anchor.y; - const dvx = node.vx - anchor.vx, dvy = node.vy - anchor.vy; - const radius = Math.hypot(dx, dy); - track.angle = Math.atan2(dy, dx); track.direction = Math.sign(dx * dvy - dy * dvx); - track.initialRadius = track.minimumRadius = track.maximumRadius = radius; - track.minimumTangential = Math.abs(dx * dvy - dy * dvx); - }); - let speedCaps = 0, minimumClearance = Infinity, maximumSpeed = 0; - for (let step = 0; step < 240; step++) { - const tick = I.integrateGalaxyLeapfrog(nodes, links, [], options); - speedCaps += tick.speedCapped ? 1 : 0; - maximumSpeed = Math.max(maximumSpeed, tick.maximumSpeed); - tracks.forEach(track => { - const node = map.get(track.id), anchor = map.get(track.anchorId); - const dx = node.x - anchor.x, dy = node.y - anchor.y; - const dvx = node.vx - anchor.vx, dvy = node.vy - anchor.vy; - const radius = Math.hypot(dx, dy), stepAngle = delta(Math.atan2(dy, dx), track.angle); - const tangent = dx * dvy - dy * dvx; - if (Math.abs(stepAngle) < 1e-6) track.frozenSteps++; - if (track.direction && Math.sign(stepAngle) === -track.direction - && Math.abs(stepAngle) > .001) track.reversals++; - track.travel += stepAngle; track.angle = Math.atan2(dy, dx); - track.minimumRadius = Math.min(track.minimumRadius, radius); - track.maximumRadius = Math.max(track.maximumRadius, radius); - track.minimumTangential = Math.min(track.minimumTangential, Math.abs(tangent)); - minimumClearance = Math.min(minimumClearance, - radius - node.radius - anchor.radius - 1.5); - }); - } - emit({ tracks, speedCaps, maximumSpeed, minimumClearance, - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), - }); - """ - ) - assert report["finite"] is True - assert report["speedCaps"] == 0 - assert report["maximumSpeed"] < 48 - assert report["minimumClearance"] >= -1e-8 - assert len(report["tracks"]) == 13 - for track in report["tracks"]: - assert track["minimumTangential"] > 1e-5, track - assert abs(track["travel"]) > 0.35, track - assert track["frozenSteps"] == 0, track - # Tight initial contact repair can make a short eccentric correction on a late body; - # it must never degrade into a stalled back-and-forth orbit. - assert track["reversals"] <= 8, track - # A new/revealed body receives a circular seed in the star's live frame — not a radial - # inheritance from the star's galaxy orbit. Its local radius remains visibly orbital. - assert abs(track["initialRadial"]) < track["initialRadius"] * 1e-8, track - assert track["minimumRadius"] > track["initialRadius"] * 0.5, track - # A direct black-hole body may be admitted to a wider collision-free core lane. - # Star-owned planets retain the stricter local-frame radius envelope. - maximum_factor = 1.25 if track["anchorId"] == "black-hole" else 1.12 - assert track["maximumRadius"] < track["initialRadius"] * maximum_factor, track - - -@requires_node -def test_local_orbit_boundary_prevents_planet_escape_without_erasing_tangent() -> None: - """A star-relative escape is projected back inside its immutable authored envelope.""" - report = _run_node( - """ - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - system_anchor_id: 'black-hole', gravity_mass: 64, radius: 9, - x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'star', anchor_role: 'community', community_id: 'solar', - system_anchor_id: 'star', gravity_mass: 12, radius: 6, - galactic_radius: 120, galactic_target_radius: 120, - x: 120, y: 0, vx: 1, vy: 2 }, - { id: 'planet', anchor_role: 'none', community_id: 'solar', - system_anchor_id: 'star', orbit_tier: 1, orbit_radius: 30, - gravity_mass: 1, radius: 3, x: 150, y: 0, vx: 1, vy: 2 }, - { id: 'other-star', anchor_role: 'community', community_id: 'other', - system_anchor_id: 'other-star', gravity_mass: 9, radius: 5, - galactic_radius: 190, galactic_target_radius: 190, - x: -190, y: 0, vx: -2, vy: 3 }, - ]; - I.seedGalaxyOrbits(nodes, 8017, 48, 32, false, { - orbitalSpeed: 100, localGravitySetting: 48, - }); - const star = nodes[1], planet = nodes[2], other = nodes[3]; - const baseRadius = planet.__galaxyOrbitBaseRadius; - const otherBefore = { x: other.x, y: other.y, vx: other.vx, vy: other.vy }; - planet.x = star.x + baseRadius * 2.4; - planet.y = star.y; - planet.vx = star.vx + 18; - planet.vy = star.vy + 7; - const direct = I.enforceGalaxyLocalOrbitBoundaries(nodes, { - orbitalSpeed: 100, systemAnchorExclusionPadding: 1.5, - }); - const afterDirect = { - radius: Math.hypot(planet.x - star.x, planet.y - star.y), - radial: planet.vx - star.vx, - tangent: planet.vy - star.vy, - }; - const otherAfterDirect = { x: other.x, y: other.y, vx: other.vx, vy: other.vy }; - planet.x = star.x + baseRadius * 3; - planet.y = star.y; - planet.vx = star.vx + 24; - planet.vy = star.vy + 5; - const integrated = I.integrateGalaxyLeapfrog(nodes, [], [], { - central: false, gravity: 0, softening: 32, timestep: .032, - orbitalSpeed: 100, velocityDecay: 0, speedLimit: 48, - includeRelations: false, includeRelationSprings: false, - includeMutualSystems: false, includeOrbitalSeparation: false, - includeSystemPacking: false, includeBlackHoleExclusion: false, - includeFarFieldConfinement: false, includeCollisions: false, - systemAnchorExclusionPadding: 1.5, - }); - const afterIntegrated = { - radius: Math.hypot(planet.x - star.x, planet.y - star.y), - radial: planet.vx - star.vx, - tangent: planet.vy - star.vy, - }; - emit({ baseRadius, direct, afterDirect, otherAfterDirect, - integrated: integrated.localOrbitBoundary, afterIntegrated, otherBefore }); - """ - ) - maximum_radius = report["baseRadius"] * 1.08 - assert report["direct"]["correctedNodes"] == 1 - assert report["direct"]["maximumBoundaryRatioBefore"] > 2 - assert report["direct"]["maximumBoundaryRatioAfter"] <= 1 - assert report["afterDirect"]["radius"] == pytest.approx(maximum_radius) - assert report["afterDirect"]["radial"] <= 1e-9 - assert report["afterDirect"]["tangent"] == pytest.approx(7) - assert report["integrated"]["correctedNodes"] == 1 - assert report["integrated"]["maximumBoundaryRatioAfter"] <= 1 - assert report["afterIntegrated"]["radius"] <= maximum_radius + 1e-8 - assert report["afterIntegrated"]["radial"] <= 1e-8 - assert abs(report["afterIntegrated"]["tangent"]) > 1 - assert report["otherAfterDirect"] == report["otherBefore"] - - -@requires_node -def test_every_black_hole_system_member_gets_both_global_and_local_orbital_motion() -> None: - """Every black-hole carrier follows the server-authored parent chain. - - Direct children, descendants, and nested descendants retain one global carrier orbit plus - their independent local orbits in both the live and O(n) oversized render paths. - """ - report = _run_node( - """ - const make = () => { - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - system_anchor_id: 'black-hole', gravity_mass: 64, radius: 9, - x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'core-star', community_id: 'core-satellite', - system_anchor_id: 'black-hole', gravity_mass: 8, radius: 5, - x: 38, y: 0, vx: 0, vy: 0 }, - { id: 'core-planet', community_id: 'core-satellite', - system_anchor_id: 'core-star', gravity_mass: 1, radius: 2.5, - x: 50, y: 0, vx: 0, vy: 0 }, - { id: 'core-moon', community_id: 'core-satellite', - system_anchor_id: 'core-planet', gravity_mass: 0.2, radius: 1.5, - x: 56, y: 0, vx: 0, vy: 0 }, - { id: 'outer-star', anchor_role: 'community', community_id: 'outer', - system_anchor_id: 'outer-star', gravity_mass: 8, radius: 5, - x: 120, y: 18, vx: 0, vy: 0 }, - { id: 'outer-planet', community_id: 'outer', system_anchor_id: 'outer-star', - gravity_mass: 1, radius: 2.5, x: 138, y: 18, vx: 0, vy: 0 }, - ]; - const links = [ - { source: 'black-hole', target: 'core-star', relation: 'orbits' }, - { source: 'core-star', target: 'core-planet', relation: 'orbits' }, - { source: 'core-planet', target: 'core-moon', relation: 'orbits' }, - { source: 'outer-star', target: 'outer-planet', relation: 'orbits' }, - ]; - return { nodes, links }; - }; - const delta = (next, previous) => Math.atan2(Math.sin(next - previous), - Math.cos(next - previous)); - const run = kinematic => { - const { nodes, links } = make(); - const options = { - layoutSeed: 501, gravity: 48, softening: 32, centralSoftening: 48, - localSoftening: 40, orbitalSpeed: 48, blackHoleMass: 1, - gravitationalConstant: 1, localGravitationalConstant: 1, - timestep: 0.032, velocityDecay: 0.00005, speedLimit: 48, - includeMutualSystems: true, mutualSystemGravityFraction: 0.12, - mutualSystemSoftening: 80, includeRelations: false, - includeOrbitalSeparation: false, includeSystemPacking: false, - includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, - includeFarFieldConfinement: true, farFieldEnvelopeScale: 1.75, - farFieldMinimumRadius: 96, farFieldSoftFraction: 0.82, - localRelativeSpeedLimit: 48, wallClockSeconds: 1 / 30, - includeCollisions: false, - }; - I.seedGalaxyOrbits(nodes, 501, 48, 32, false, options); - I.seedGalaxySystemOrbits(nodes, 501, 48, 40, false, options); - const groups = [...I.galaxyOrbitGroups(nodes).entries()] - .map(([id, group]) => [id, group.nodes.map(node => node.id)]); - const blackHole = nodes[0], coreStar = nodes[1], corePlanet = nodes[2]; - const coreMoon = nodes[3]; - const outerStar = nodes[4], outerPlanet = nodes[5]; - const globalNodes = [coreStar, corePlanet, coreMoon, outerStar, outerPlanet]; - const localPairs = [[corePlanet, coreStar], [coreMoon, corePlanet], - [outerPlanet, outerStar]]; - const globalPrevious = new Map(globalNodes.map(node => [node.id, - Math.atan2(node.y - blackHole.y, node.x - blackHole.x)])); - const localPrevious = new Map(localPairs.map(([node, star]) => [node.id, - Math.atan2(node.y - star.y, node.x - star.x)])); - const globalTravel = new Map(globalNodes.map(node => [node.id, 0])); - const localTravel = new Map(localPairs.map(([node]) => [node.id, 0])); - const step = () => kinematic - ? I.advanceGalaxyKinematicOrbits(nodes, options) - : I.integrateGalaxyLeapfrog(nodes, links, [], options); - for (let index = 0; index < 240; index++) { - step(); - globalNodes.forEach(node => { - const angle = Math.atan2(node.y - blackHole.y, node.x - blackHole.x); - globalTravel.set(node.id, globalTravel.get(node.id) - + delta(angle, globalPrevious.get(node.id))); - globalPrevious.set(node.id, angle); - }); - localPairs.forEach(([node, star]) => { - const angle = Math.atan2(node.y - star.y, node.x - star.x); - localTravel.set(node.id, localTravel.get(node.id) - + delta(angle, localPrevious.get(node.id))); - localPrevious.set(node.id, angle); - }); - } - return { groups, global: [...globalTravel.values()], local: [...localTravel.values()], - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)) }; - }; - emit({ live: run(false), kinematic: run(true) }); - """ - ) - for mode in ("live", "kinematic"): - result = report[mode] - assert report[mode]["finite"] is True - assert abs(min(result["global"], key=abs)) > 0.01, result - assert abs(min(result["local"], key=abs)) > 0.01, result - core_group = next(group for group in report["kinematic"]["groups"] if group[0] == "black-hole") - assert set(core_group[1]) == {"black-hole", "core-star", "core-planet", "core-moon"} - - -@requires_node -def test_reseeding_a_live_black_hole_lane_does_not_rewind_its_phase() -> None: - report = _run_node( - """ - const nodes = [ - { id: 'black-hole', anchor_role: 'global', community_id: 'core', - system_anchor_id: 'black-hole', gravity_mass: 64, radius: 9, - x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'child', community_id: 'child', system_anchor_id: 'black-hole', - gravity_mass: 3, radius: 3, x: 120, y: 0, vx: 0, vy: 0 }, - ]; - const options = { gravity: 48, softening: 32, centralSoftening: 40, - localSoftening: 40, layoutSeed: 77, orbitalSpeed: 48, - timestep: 1 / 30, includeSystemPacking: false }; - I.seedGalaxyOrbits(nodes, 77, 48, 32, false, options); - for (let step = 0; step < 60; step++) I.advanceGalaxyKinematicOrbits(nodes, options); - const before = [nodes[1].x, nodes[1].y, nodes[1].__galaxyCoreLaneAngle]; - I.seedGalaxyOrbits(nodes, 77, 48, 32, false, options); - const after = [nodes[1].x, nodes[1].y, nodes[1].__galaxyCoreLaneAngle]; - emit({ before, after }); - """ - ) - assert report["after"] == pytest.approx(report["before"], abs=1e-12) - - -@requires_node -def test_tagged_local_orbit_is_repaired_when_a_render_lifecycle_zeroes_its_phase() -> None: - """An orbit-parent tag is provenance, never a permanent exemption from repair. - - The failure mode is a reused/statically-painted node whose velocity has been reset to the - star frame while its non-enumerable one-shot tag remains. Returning to Galaxy must detect - that zero relative tangent and restore the local orbit without reseeding a healthy phase. - """ - report = _run_node( - """ - const nodes = [ - { id: 'black-hole', community_id: 'core', anchor_role: 'global', - system_anchor_id: 'black-hole', orbit_tier: 0, gravity_mass: 48, radius: 9, - x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'star', community_id: 'solar', anchor_role: 'community', - system_anchor_id: 'star', orbit_tier: 0, gravity_mass: 10, radius: 5, - x: 120, y: 20, vx: 0, vy: 0 }, - { id: 'planet', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 1, - gravity_mass: 1, radius: 2.5, x: 151, y: 20, vx: 0, vy: 0 }, - ]; - const local = () => { - const star = nodes[1], planet = nodes[2], dx = planet.x - star.x, - dy = planet.y - star.y, dvx = planet.vx - star.vx, dvy = planet.vy - star.vy; - return { tangent: dx * dvy - dy * dvx, relativeSpeed: Math.hypot(dvx, dvy), - tag: planet.__galaxyOrbitAnchorId || null }; - }; - I.seedGalaxyOrbits(nodes, 9109, 48, 32, false); - I.seedGalaxySystemOrbits(nodes, 9109, 48, 48, false); - const healthy = local(); - // Emulate a legacy/static lifecycle that has retained object identity and its hidden - // parent tag but cleared the relative phase before re-entering Galaxy. - nodes[2].vx = nodes[1].vx; nodes[2].vy = nodes[1].vy; - const stalled = local(); - I.seedGalaxyOrbits(nodes, 9109, 48, 32, false); - I.seedGalaxySystemOrbits(nodes, 9109, 48, 48, false); - const repaired = local(); - emit({ healthy, stalled, repaired, finite: nodes.every(node => - [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); - """ - ) - assert report["finite"] is True - assert report["healthy"]["tag"] == "star" - assert report["healthy"]["relativeSpeed"] > 0.05 - assert report["stalled"]["tag"] == "star" - assert report["stalled"]["relativeSpeed"] == pytest.approx(0, abs=1e-12) - assert report["repaired"]["tag"] == "star" - assert report["repaired"]["relativeSpeed"] > 0.05 - assert abs(report["repaired"]["tangent"]) > 1e-5 - - -@requires_node -def test_explicit_star_is_the_inert_local_carrier_while_dense_planets_sweep() -> None: - """A named community star never absorbs local gravity or contact recoil. - - The star is allowed to move as a whole around the black hole. What must *not* happen is - a planet-only force, surface correction, or dense planet/planet separation translating or - accelerating that star in its own local frame. The oversized kinematic path has the same - rule: its cached black-hole carrier is the star itself, while every satellite advances a - separately visible local angle. - """ - report = _run_node( - """ - const localNodes = [ - { id: 'star', community_id: 'solar', anchor_role: 'community', - system_anchor_id: 'star', orbit_tier: 0, gravity_mass: 12, radius: 5, - x: 120, y: -32, vx: 2.5, vy: -1.25 }, - // The first body begins inside the painted stellar edge; the latter two overlap one - // another. This exercises gravity, star-surface projection, and radius-preserving - // dense pressure in one deliberately hostile local frame. - { id: 'near', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 1, - gravity_mass: 1, radius: 3, x: 124, y: -32, vx: 2.5, vy: -1.25 }, - { id: 'crowded-a', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 2, - gravity_mass: 1, radius: 2.5, x: 145, y: -32, vx: 2.5, vy: -1.25 }, - { id: 'crowded-b', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 3, - gravity_mass: 1.2, radius: 2.5, x: 145.4, y: -31.8, vx: 2.5, vy: -1.25 }, - ]; - const star = localNodes[0]; - const carrier = () => [star.x, star.y, star.vx, star.vy]; - const before = carrier(); - const gravity = I.applyGalaxySystemAnchorGravity(localNodes, { - gravity: 48, softening: 18, accelerationCap: 100, - repulsionPadding: 1.5, repulsionRange: 6, repulsionAcceleration: .12, - }); - const afterGravity = carrier(); - const exclusion = I.applyGalaxySystemAnchorExclusion(localNodes, { padding: 1.5 }); - const afterExclusion = carrier(); - const separation = I.applyGalaxyOrbitalSeparation(localNodes, { - padding: 3, strength: 1, maxCorrection: 8, maxVelocityCorrection: 12, - skipSystemAnchorPairs: true, preserveSystemRadii: true, - }); - const afterSeparation = carrier(); - - const nodes = [ - { id: 'bh', community_id: 'core', anchor_role: 'global', gravity_mass: 64, radius: 9, - x: 0, y: 0, vx: 0, vy: 0 }, - { id: 'kin-star', community_id: 'kin', anchor_role: 'community', - system_anchor_id: 'kin-star', orbit_tier: 0, gravity_mass: 12, radius: 5, - x: 154, y: 48, vx: 0, vy: 0 }, - ]; - for (let index = 0; index < 6; index++) { - const angle = index * Math.PI * 2 / 6 + .17; - const radius = 18 + index * 4; - nodes.push({ id: `planet-${index}`, community_id: 'kin', system_anchor_id: 'kin-star', - orbit_tier: index + 1, gravity_mass: 1 + index * .1, radius: 2.5, - x: 154 + Math.cos(angle) * radius, y: 48 + Math.sin(angle) * radius, - vx: 0, vy: 0 }); - } - const bh = nodes[0], kinStar = nodes[1]; - const planet = nodes[2]; - const delta = (next, previous) => Math.atan2(Math.sin(next - previous), - Math.cos(next - previous)); - let previousLocal = Math.atan2(planet.y - kinStar.y, planet.x - kinStar.x); - let previousGlobal = Math.atan2(kinStar.y - bh.y, kinStar.x - bh.x); - let localTravel = 0, globalTravel = 0, maximumCarrierError = 0, maximumVelocityError = 0; - for (let step = 0; step < 180; step++) { - I.advanceGalaxyKinematicOrbits(nodes, { - layoutSeed: 451, gravity: 48, softening: 32, centralSoftening: 40, - localSoftening: 40, timestep: 1 / 30, - }); - const orbit = kinStar.__galaxyKinematicGlobalOrbit; - const expectedX = bh.x + Math.cos(orbit.angle) * orbit.radius; - const expectedY = bh.y + Math.sin(orbit.angle) * orbit.radius; - maximumCarrierError = Math.max(maximumCarrierError, - Math.hypot(kinStar.x - expectedX, kinStar.y - expectedY)); - // Tangential direction is exact even though its magnitude is implementation-owned. - maximumVelocityError = Math.max(maximumVelocityError, - Math.abs((kinStar.x - bh.x) * kinStar.vx + (kinStar.y - bh.y) * kinStar.vy)); - const nextLocal = Math.atan2(planet.y - kinStar.y, planet.x - kinStar.x); - const nextGlobal = Math.atan2(kinStar.y - bh.y, kinStar.x - bh.x); - localTravel += delta(nextLocal, previousLocal); - globalTravel += delta(nextGlobal, previousGlobal); - previousLocal = nextLocal; previousGlobal = nextGlobal; - } - emit({ before, afterGravity, afterExclusion, afterSeparation, gravity, exclusion, - separation, localTravel, globalTravel, maximumCarrierError, maximumVelocityError, - localRadius: Math.hypot(planet.x - kinStar.x, planet.y - kinStar.y), - finite: nodes.concat(localNodes).every(node => [node.x, node.y, node.vx, node.vy] - .every(Number.isFinite)), - }); - """ - ) - assert report["finite"] is True - # Local gravity, a penetrating planet, and a dense planet/planet correction are all - # one-sided about the explicit star. Its black-hole carrier is not a local momentum sink. - assert report["afterGravity"] == pytest.approx(report["before"], abs=1e-12) - assert report["afterExclusion"] == pytest.approx(report["before"], abs=1e-12) - assert report["afterSeparation"] == pytest.approx(report["before"], abs=1e-12) - assert report["gravity"]["satellites"] == 3 - assert report["exclusion"]["contacts"] > 0 - assert report["separation"]["radialPreservedContacts"] > 0 - # In the Complete-view kinematic clock the star follows its own BH carrier exactly, while - # the planet has a materially faster, independently visible star-relative orbit. - assert report["maximumCarrierError"] < 1e-9 - assert report["maximumVelocityError"] < 1e-7 - assert abs(report["globalTravel"]) > 0.1 - assert abs(report["localTravel"]) > 0.2 - assert report["localRadius"] > 8 - - -@requires_node -def test_future_singleton_waits_for_its_moving_star_before_receiving_one_local_seed() -> None: - """A singleton must not consume its orbit seed before its dominant star is revealed. - - This is the lifecycle ordering that previously left an initially unlinked/revealed member - frozen: the object survived the renderer transition, but no longer qualified for a seed once - its star arrived. The repair must be one-shot in the star's moving frame, then remain - idempotent on the next ordinary render. The named star is the local inertial carrier, so - admitting this planet must never recoil it. - """ - report = _run_node( - """ - const future = { id: 'future-planet', community_id: 'future', gravity_mass: 1, - radius: 2.5, x: 164, y: 53, vx: 3, vy: -2 }; - const nodes = [ - { id: 'black-hole', community_id: 'core', anchor_role: 'global', - system_anchor_id: 'black-hole', orbit_tier: 0, gravity_mass: 48, radius: 9, - x: 0, y: 0, vx: 0, vy: 0 }, future, - ]; - const momentum = members => ['vx', 'vy'].map(axis => members.reduce((sum, node) => - sum + node.gravity_mass * node[axis], 0)); - I.seedGalaxyOrbits(nodes, 31011, 48, 32, false); - const isolated = { - seeded: !!future.__galaxyOrbitSeeded, - parent: future.__galaxyOrbitAnchorId || null, - velocity: [future.vx, future.vy], - }; - // The scene is already moving when the star arrives; this must be seeded relative to - // the live star rather than the origin or a stale zero-velocity coordinate. - const star = { id: 'future-star', community_id: 'future', anchor_role: 'community', - system_anchor_id: 'future-star', orbit_tier: 0, gravity_mass: 10, radius: 5, - x: 140, y: 35, vx: 2, vy: -1 }; - nodes.push(star); - const starBefore = [star.x, star.y, star.vx, star.vy]; - const before = momentum([star, future]); - I.seedGalaxyOrbits(nodes, 31011, 48, 32, false); - const local = () => { - const dx = future.x - star.x, dy = future.y - star.y; - const dvx = future.vx - star.vx, dvy = future.vy - star.vy; - return { parent: future.__galaxyOrbitAnchorId || null, - seeded: !!future.__galaxyOrbitSeeded, tangent: dx * dvy - dy * dvx, - radial: dx * dvx + dy * dvy, relativeSpeed: Math.hypot(dvx, dvy), - phase: [future.vx, future.vy, star.vx, star.vy] }; - }; - const seeded = local(), after = momentum([star, future]); - I.seedGalaxyOrbits(nodes, 31011, 48, 32, false); - const repeated = local(), final = momentum([star, future]); - emit({ isolated, before, seeded, after, repeated, final, starBefore, - finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); - """ - ) - assert report["finite"] is True - assert report["isolated"]["seeded"] is False - assert report["isolated"]["parent"] is None - assert report["seeded"]["parent"] == "future-star" - assert report["seeded"]["seeded"] is True - assert report["seeded"]["relativeSpeed"] > 0.05 - assert abs(report["seeded"]["tangent"]) > 1e-5 - assert abs(report["seeded"]["radial"]) < 1e-8 - # Local admission changes the planet's velocity but does not apply an equal-and-opposite - # kick to the explicit star. The whole system can later acquire one BH-frame translation. - assert report["seeded"]["phase"][2:] == pytest.approx(report["starBefore"][2:], abs=1e-12) - assert report["after"] != pytest.approx(report["before"], abs=1e-10) - assert report["repeated"]["phase"] == pytest.approx(report["seeded"]["phase"], abs=1e-12) - assert report["final"] == pytest.approx(report["after"], abs=1e-12) - - -@requires_node -def test_galaxy_is_default_and_consumes_the_complete_scene_contract() -> None: - report = _run_engine( - """ - const linkForce = { - id(value) { this.idValue = value; return this; }, - distance(value) { this.distanceValue = value; return this; }, - strength(value) { this.strengthValue = value; return this; }, - }; - globalThis.d3 = { - forceLink: () => linkForce, - forceCollide: () => ({ iterations() { return this; } }), - }; - const api = G.create(el, { reducedMotion: () => true }); - api.setData({ - meta: { layout_seed: 73, scene_hash: 'scene' }, - communities: [{ id: 'left' }, { id: 'right' }], - community_bridges: [{ - id: 'bridge', source_community: 'left', target_community: 'right', - physics_strength: 0.8, - }], - nodes: [ - { id: 'a', x: -20, y: 0, gravity_mass: 1, visual_radius: 3, community_id: 'left' }, - { id: 'b', x: 0, y: 0, gravity_mass: 4, visual_radius: 7, community_id: 'left' }, - { id: 'c', x: 30, y: 0, gravity_mass: 2, visual_radius: 5, community_id: 'right' }, - ], - edges: [ - { id: 'internal', source: 'a', target: 'b', rest_length: 20, spring_strength: 0.16 }, - { id: 'cross', source: 'b', target: 'c', rest_length: 30, spring_strength: 0.2 }, - { id: 'ghost', source: 'a', target: 'c', rest_length: 10, spring_strength: 0.2, ghost: true, physics_strength: 0 }, - ], - }); - const exported = api.exportData(); - emit({ - mode: api.state().settings.mode, - settings: { - repel: api.state().settings.repel, - link: api.state().settings.link, - gravity: api.state().settings.gravity, - }, - sizeBy: api.state().sizeBy, - forces: { - charge: store.d3Forces.charge === null, - link: store.d3Forces.link === null, - x: store.d3Forces.x === null, - y: store.d3Forces.y === null, - galaxy: store.d3Forces.galaxy === null, - center: store.d3Forces.galaxyCenter === null, - relations: store.d3Forces.galaxyRelations === null, - defaultCenter: store.d3Forces.center === null, - bridges: store.d3Forces.communityBridges === null, - }, - radii: Object.fromEntries(store.graphData.nodes.map(node => [node.id, node.radius])), - d3Budget: [store.cooldownTime, store.cooldownTicks, store.warmupTicks], - diagnostics: api.physicsDiagnostics(), - exported: { - seed: exported.meta.layout_seed, - communities: exported.communities.length, - bridges: exported.community_bridges.length, - }, - positions: store.graphData.nodes.map(node => [node.x, node.y]), - }); - """ - ) - assert report["mode"] == "galaxy" - assert report["settings"] == {"repel": 100, "link": 8, "gravity": 96} - assert report["sizeBy"] == "mass" - assert report["forces"] == { - "charge": True, - "link": True, - "x": True, - "y": True, - "galaxy": True, - "center": True, - "relations": True, - "defaultCenter": True, - "bridges": True, - } - def radius(mass: float) -> float: - return 1.2 * (1.5 + 2.0 * mass ** (2.0 / 3.0)) - assert report["radii"]["a"] == pytest.approx(radius(1)) - assert report["radii"]["b"] == pytest.approx(radius(4)) - assert report["radii"]["c"] == pytest.approx(radius(2)) - assert report["d3Budget"] == [0, 0, 0] - assert report["diagnostics"]["timestep"] == pytest.approx(0.032) - assert report["diagnostics"]["velocityDecay"] == pytest.approx(0.00005) - assert report["diagnostics"]["gravitySetting"] == 96 - assert report["diagnostics"]["blackHoleGravity"] == pytest.approx(1615.3424319876754) - assert report["diagnostics"]["localGravity"] == pytest.approx(240) - assert report["diagnostics"]["linkSetting"] == 8 - assert report["diagnostics"]["relationOrbitScale"] == pytest.approx(0.25) - assert report["diagnostics"]["orbitalSeparationSetting"] == 100 - assert report["diagnostics"]["orbitalSeparationPadding"] == pytest.approx(15) - assert report["diagnostics"]["orbitalSeparationStrength"] == pytest.approx(1) - assert report["diagnostics"]["crossSystemRepulsionStrength"] == 0 - assert report["diagnostics"]["systemOrbitSeedSpeedLimit"] == pytest.approx(23.4) - assert report["diagnostics"]["systemAnchorExclusionPadding"] == pytest.approx(1.5) - assert report["diagnostics"]["systemAnchorRepulsionRange"] == pytest.approx(6) - assert report["diagnostics"]["systemAnchorRepulsionAcceleration"] == pytest.approx(0.12) - assert report["diagnostics"]["reducedMotion"] is True - assert report["exported"] == {"seed": 73, "communities": 2, "bridges": 1} - assert report["positions"] == [[-20, 0], [0, 0], [30, 0]] - - -@requires_node -def test_collapsed_galaxy_systems_sum_live_mass_and_use_square_root_radius() -> None: - report = _run_engine( - """ - const api = G.create(el, { reducedMotion: () => true }); - api.setData({ - communities: [{ id: 'left' }, { id: 'right' }], - nodes: [ - { id: 'a', x: 0, y: 0, gravity_mass: 4, visual_radius: 5, community_id: 'left' }, - { id: 'history', x: 5, y: 0, gravity_mass: 0, visual_radius: 9, community_id: 'left', ghost: true }, - { id: 'b', x: 30, y: 0, gravity_mass: 9, visual_radius: 8, community_id: 'right' }, - { id: 'old', x: 60, y: 0, gravity_mass: 0, visual_radius: 6, community_id: 'archive', ghost: true }, - ], - edges: [ - { source: 'a', target: 'b' }, - { source: 'a', target: 'history', ghost: true, physics_strength: 0 }, - ], - }); - api.setScope({ showUnlinked: true, minDegree: 0 }); - api.setCollapse(true); - emit(store.graphData.nodes.map(node => ({ - id: node.id, members: node.members, mass: node.gravity_mass, - visualRadius: node.visual_radius, radius: node.radius, ghost: node.ghost, - })).sort((a, b) => a.id.localeCompare(b.id))); - """ - ) - archive, left, right = report - def radius(mass: float) -> float: - return 1.2 * (1.5 + 2.0 * mass ** (2.0 / 3.0)) - assert archive == { - "id": "cluster-archive", "members": 1, "mass": 0, - "visualRadius": 0, "radius": 2.5, "ghost": True, - } - assert {key: left[key] for key in ("id", "members", "mass", "ghost")} == { - "id": "cluster-left", "members": 2, "mass": 4, "ghost": False, - } - assert left["visualRadius"] == pytest.approx(radius(4)) - assert left["radius"] == pytest.approx(radius(4)) - assert {key: right[key] for key in ("id", "members", "mass", "ghost")} == { - "id": "cluster-right", "members": 1, "mass": 9, "ghost": False, - } - assert right["visualRadius"] == pytest.approx(radius(9)) - assert right["radius"] == pytest.approx(radius(9)) - - -@requires_node -def test_oversized_galaxy_pins_deterministic_scene_positions_without_live_forces() -> None: - report = _run_engine( - """ - const api = G.create(el, { reducedMotion: () => false }); - const scene = () => { - const data = chain(1500); - data.meta = { layout_seed: 91 }; - data.nodes.forEach((node, index) => { - node.x = index - 300; node.y = (index % 7) * 3; - }); - return data; - }; - api.setData(scene()); - const first = store.graphData.nodes.map(node => [node.x, node.y, node.fx, node.fy]); - api.setData(scene()); - const nodes = store.graphData.nodes; - const repeated = nodes.map(node => [node.x, node.y, node.fx, node.fy]); - const diagnostics = api.physicsDiagnostics(); - emit({ - mode: api.state().settings.mode, - total: nodes.length, - pinned: nodes.filter(node => Number.isFinite(node.fx) && Number.isFinite(node.fy)).length, - finite: nodes.every(node => Number.isFinite(node.x) && Number.isFinite(node.y)), - same: nodes.every(node => node.fx === node.x && node.fy === node.y), - deterministic: first.every((position, index) => position.every((value, axis) => - value === repeated[index][axis])), - endpoints: [[nodes[0].x, nodes[0].y], [nodes.at(-1).x, nodes.at(-1).y]], - systemAnchorExclusion: diagnostics.systemAnchorExclusion, - cooldown: [store.cooldownTime, store.cooldownTicks, store.warmupTicks], - forces: ['galaxy', 'galaxyCenter', 'galaxyRelations', 'communityBridges', - 'charge', 'link'].map(name => store.d3Forces[name] === null), - }); - """ - ) - assert report["mode"] == "galaxy" - assert report["total"] == report["pinned"] == 1501 - assert report["finite"] is report["same"] is report["deterministic"] is True - # The selected community star may project its nearest satellite before a static paint; - # the far endpoint is unaffected and proves positions are otherwise preserved. - assert report["endpoints"][1] == [1200, 6] - assert report["systemAnchorExclusion"]["minimumClearance"] >= -1e-9 - assert report["cooldown"] == [0, 0, 0] - assert report["forces"] == [True, True, True, True, True, True] - - -@requires_node -def test_galaxy_reheat_unfreeze_and_drag_never_reseed_orbital_velocity() -> None: - report = _run_engine( - """ - const api = G.create(el, { reducedMotion: () => false }); - api.setData({ - meta: { layout_seed: 42 }, - nodes: [ - { id: 'sun', x: 0, y: 0, gravity_mass: 8, visual_radius: 8, community_id: 's' }, - { id: 'planet', x: 20, y: 0, gravity_mass: 1, visual_radius: 3, community_id: 's' }, - ], - edges: [{ source: 'sun', target: 'planet', rest_length: 20, spring_strength: 0.1 }], - }); - const planet = store.graphData.nodes.find(node => node.id === 'planet'); - const initial = [planet.vx, planet.vy]; - api.reheat(); - const reheated = [planet.vx, planet.vy]; - api.freeze(true); - api.freeze(false); - const unfrozen = [planet.vx, planet.vy]; - store.onNodeDragStart(planet); - store.onNodeDragEnd(planet); - const dragged = [planet.vx, planet.vy]; - - const full = G.create(el, { reducedMotion: () => true }); - full.setRenderMode('full'); - full.setData(chain(400)); - emit({ initial, reheated, unfrozen, dragged, - d3Calls: { - alpha: calls.d3AlphaTarget || 0, - resets: invocations.resetCountdown || 0, - reheats: invocations.d3ReheatSimulation || 0, - }, - }); - """ - ) - assert abs(report["initial"][1]) > 0 - assert report["reheated"] == pytest.approx(report["initial"]) - assert report["unfrozen"] == pytest.approx(report["initial"]) - assert report["dragged"] == pytest.approx(report["initial"]) - assert report["d3Calls"] == {"alpha": 0, "resets": 0, "reheats": 0} - - -@requires_node -def test_live_galaxy_fills_only_missing_compatibility_coordinates_once() -> None: - report = _run_engine( - """ - const scene = { - meta: { layout_seed: 321 }, - nodes: [ - { id: 'server', x: 120, y: -30, gravity_mass: 8, community_id: 'system' }, - { id: 'missing-a', gravity_mass: 2, community_id: 'system' }, - { id: 'missing-b', gravity_mass: 1, community_id: 'other' }, - ], - edges: [ - { source: 'server', target: 'missing-a' }, - { source: 'missing-a', target: 'missing-b' }, - ], - }; - const snapshot = nodes => nodes.map(node => [node.id, node.x, node.y, node.vx, node.vy]); - const api = G.create(el, { reducedMotion: () => false }); - api.setData(scene); - const initial = snapshot(store.graphData.nodes); - api.reheat(); - api.freeze(true); - api.freeze(false); - const afterExplicitActions = snapshot(store.graphData.nodes); - - const second = G.create(el, { reducedMotion: () => false }); - second.setData(scene); - emit({ - initial, - afterExplicitActions, - repeated: snapshot(store.graphData.nodes), - allFinite: initial.every(item => item.slice(1).every(Number.isFinite)), - d3Budget: [store.cooldownTime, store.cooldownTicks, store.warmupTicks], - d3Wakes: { - alpha: calls.d3AlphaTarget || 0, - resets: invocations.resetCountdown || 0, - reheats: invocations.d3ReheatSimulation || 0, - }, - }); - """ - ) - assert report["allFinite"] is True - assert report["initial"][0][1:3] == [120, -30] - for initial, after, repeated in zip( - report["initial"], report["afterExplicitActions"], report["repeated"] - ): - assert initial[0] == after[0] == repeated[0] - assert initial[1:] == pytest.approx(after[1:]) - assert initial[1:] == pytest.approx(repeated[1:]) - assert report["d3Budget"] == [0, 0, 0] - assert report["d3Wakes"] == {"alpha": 0, "resets": 0, "reheats": 0} - - -@requires_node -def test_galaxy_phase_is_isolated_from_legacy_layouts_and_restores_server_seed() -> None: - report = _run_engine( - """ - const scene = { - meta: { layout_seed: 17 }, - nodes: [ - { id: 'sun', x: -40, y: 3, gravity_mass: 8, community_id: 's' }, - { id: 'planet', x: 25, y: -4, gravity_mass: 1, community_id: 's' }, - ], - edges: [{ source: 'sun', target: 'planet' }], - }; - - const first = G.create(el, { reducedMotion: () => false }); - first.setPreset('compact'); - first.setData(scene); - const legacyDiscardedServer = store.graphData.nodes.map(node => node.x == null); - first.setPreset('galaxy'); - const firstGalaxy = store.graphData.nodes.map(node => [node.id, node.x, node.y]); - - const api = G.create(el, { reducedMotion: () => false }); - api.setData(scene); - const byId = Object.fromEntries(store.graphData.nodes.map(node => [node.id, node])); - byId.sun.x = -22; byId.sun.y = 11; byId.sun.vx = 1.25; byId.sun.vy = -0.5; - byId.planet.x = 31; byId.planet.y = 9; byId.planet.vx = -2; byId.planet.vy = 0.75; - api.setPreset('compact'); - store.graphData.nodes.forEach((node, index) => { - node.x = 700 + index * 100; node.y = -900; node.vx = 40; node.vy = -40; - }); - api.setPreset('galaxy'); - emit({ - legacyDiscardedServer, - firstGalaxy, - restored: store.graphData.nodes.map(node => [ - node.id, node.x, node.y, node.vx, node.vy, - ]), - d3Budget: [store.cooldownTime, store.cooldownTicks, store.warmupTicks], - }); - """ - ) - assert report["legacyDiscardedServer"] == [True, True] - assert report["firstGalaxy"] == [["sun", -40, 3], ["planet", 25, -4]] - assert report["restored"] == [ - ["sun", -22, 11, 1.25, -0.5], - ["planet", 31, 9, -2, 0.75], - ] - assert report["d3Budget"] == [0, 0, 0] - - -@requires_node -def test_auto_fit_cap_does_not_limit_manual_graph_inspection() -> None: - """The auto-fit guard must not become a global force-graph zoom limit.""" - report = _run_engine( - """ - G.create(el, {}); - emit({ maxZoom: store.maxZoom === undefined ? null : store.maxZoom }); - """ - ) - assert report["maxZoom"] is None - source = ASSET.read_text(encoding="utf-8") - assert "function autoFit(" in source - assert "api.fit = () => { if (!destroyed) fg.zoomToFit" in source - - -def test_dashboard_falls_back_to_the_classic_renderer_when_the_engine_throws() -> None: - source = DASHBOARD.read_text(encoding="utf-8") - # The opt-in flag must be latched off after a failure, and the render path must catch. - assert "GRAPH_ENGINE_FAILED" in source - assert "if(GRAPH_ENGINE_FAILED)return false" in source - assert "graphEngineFallback(error)" in source - engine_path = source[source.index("function graphRenderEngine"):] - engine_path = engine_path[: engine_path.index("\nfunction ")] - assert "try{" in engine_path and "}catch(error){" in engine_path - - -# ── XSS: untrusted entity labels reaching force-graph ─────────────────────────────── - - -def test_force_graph_tooltip_is_still_an_inner_html_sink() -> None: - """Guards the *reason* the engine sets its own label accessors. - - force-graph defaults ``nodeLabel``/``linkLabel`` to the accessor ``"name"`` and renders a - string label through ``innerHTML``. Node names here are entity labels extracted from - ingested memories, i.e. untrusted. If a vendor bump ever changes this, revisit whether - the explicit escaped accessors below are still the right shape. - """ - vendor = VENDOR.read_text(encoding="utf-8", errors="ignore") - assert 'nodeLabel:{default:"name"' in vendor - assert 'linkLabel:{default:"name"' in vendor - - -def test_engine_never_relies_on_the_default_label_accessor() -> None: - source = ASSET.read_text(encoding="utf-8") - assert ".nodeLabel(node => esc(nodeName(node)))" in source - assert ".linkLabel(" in source - assert "eval(" not in source - # The engine paints to canvas; the only markup sink it may use is clearing its own - # container on teardown. Anything else would be a route for an unescaped entity label. - writes = re.findall(r"\w+\.(?:inner|outer)HTML\s*=\s*[^;]+", source) - assert writes == ["el.innerHTML = ''"], writes - assert not re.search(r"insertAdjacentHTML|document\.write|createContextualFragment", source) - - -@requires_node -@pytest.mark.parametrize( - "payload", - [ - "", - "", - "\" onmouseover=\"alert(1)", - "", - ], -) -def test_entity_labels_are_escaped_before_they_can_reach_a_dom_sink(payload: str) -> None: - report = _run_node( - "emit({ escaped: I.esc(%s), named: I.nodeName({ label: %s }) });" - % (json.dumps(payload), json.dumps(payload)) - ) - escaped = report["escaped"] - assert "<" not in escaped and ">" not in escaped - assert '"' not in escaped and "'" not in escaped - assert "<" in escaped or """ in escaped - # nodeName is the raw value; escaping is the accessor's job, so this documents the split. - assert report["named"] == payload - - -# ── payload compatibility with the shipped /graph endpoint ────────────────────────── - - -@requires_node -def test_engine_accepts_both_the_api_and_renderer_link_shapes() -> None: - report = _run_node( - """ - const api = { from: 'a', to: 'b' }; - const renderer = { source: { id: 'c' }, target: 'd' }; - emit({ - apiSource: I.linkEndpoint(api, 'source'), - apiTarget: I.linkEndpoint(api, 'target'), - rendererSource: I.linkEndpoint(renderer, 'source'), - rendererTarget: I.linkEndpoint(renderer, 'target'), - label: I.nodeName({ label: 'Ada' }), - name: I.nodeName({ name: 'Grace' }), - fallback: I.nodeName({ id: 'ent_1' }), - }); - """ - ) - assert report["apiSource"] == "a" and report["apiTarget"] == "b" - assert report["rendererSource"] == "c" and report["rendererTarget"] == "d" - assert report["label"] == "Ada" - assert report["name"] == "Grace" - assert report["fallback"] == "ent_1" - - -@requires_node -def test_valid_time_accepts_seconds_milliseconds_and_iso_strings() -> None: - report = _run_node( - """ - emit({ - seconds: I.asOfValue(1700000000), - millis: I.asOfValue(1700000000000), - iso: I.asOfValue('2023-11-14T22:13:20Z'), - blank: I.asOfValue(''), - junk: I.asOfValue('not a date'), - }); - """ - ) - assert report["seconds"] == report["millis"] == 1700000000000 - assert report["iso"] == 1700000000000 - assert report["blank"] is None and report["junk"] is None - - -# ── client-side analysis: correctness and cost ────────────────────────────────────── - - -@requires_node -def test_bridge_detection_matches_a_known_graph() -> None: - """A triangle has no bridges; the tail hanging off it is all bridges.""" - report = _run_node( - """ - const nodes = ['a', 'b', 'c', 'd', 'e'].map(id => ({ id })); - const links = [['a','b'], ['b','c'], ['c','a'], ['c','d'], ['d','e']] - .map(([source, target]) => ({ source, target })); - const adj = I.communities(nodes, links); - I.findBridges(nodes, links, adj); - emit({ - bridges: links.filter(l => l.bridge).map(l => l.source + '-' + l.target), - communities: new Set(nodes.map(n => n.community)).size, - }); - """ - ) - assert report["bridges"] == ["c-d", "d-e"] - assert report["communities"] == 1 - - -@requires_node -def test_parallel_edges_are_not_reported_as_bridges() -> None: - report = _run_node( - """ - const nodes = [{ id: 'a' }, { id: 'b' }]; - const links = [{ source: 'a', target: 'b' }, { source: 'a', target: 'b' }]; - const adj = I.communities(nodes, links); - I.findBridges(nodes, links, adj); - emit({ bridges: links.filter(l => l.bridge).length }); - """ - ) - assert report["bridges"] == 0 - - -@requires_node -def test_explorer_exports_its_visible_data_and_reports_bridge_metrics() -> None: - """Filtering and analysis controls must affect the user-facing export/readout, - rather than only changing paint on an otherwise stale payload.""" - report = _run_engine( - """ - const reports = []; - const api = G.create(el, { reducedMotion: () => true, onMetrics: value => reports.push(value) }); - api.setData({ - nodes: [ - { id: 'a', repo: 'engraphis' }, { id: 'b', repo: 'engraphis' }, - { id: 'c', repo: 'elsewhere' }, - ], - links: [ - { source: 'a', target: 'b', valid_from: 100, valid_to: 200 }, - { source: 'b', target: 'c', valid_from: 100 }, - ], - }); - api.setBridges(true); - api.setRepoFilter('engraphis'); - const filtered = api.exportData(); - api.focus('a'); - api.clearFocus(); - api.setRepoFilter(''); - api.setAsOf(250); - api.setGhosts(false); - const withoutGhosts = api.exportData(); - api.setGhosts(true); - const withGhosts = api.exportData(); - emit({ - bridges: reports[reports.length - 1].bridges, - filtered, state: api.state(), withoutGhosts, withGhosts, - }); - """ - ) - assert report["bridges"] == 2 - assert [node["id"] for node in report["filtered"]["nodes"]] == ["a", "b"] - assert [(link["source"], link["target"]) for link in report["filtered"]["links"]] == [ - ("a", "b") - ] - assert report["state"]["focusId"] is None and report["state"]["highlight"] is None - assert len(report["withoutGhosts"]["links"]) == 1 - assert len(report["withGhosts"]["links"]) == 2 - - -@requires_node -def test_disconnected_entities_are_labelled_as_separate_communities() -> None: - report = _run_node( - """ - const nodes = ['a', 'b', 'c', 'd'].map(id => ({ id })); - const links = [{ source: 'a', target: 'b' }, { source: 'c', target: 'd' }]; - const adj = I.communities(nodes, links); - emit({ groups: new Set(nodes.map(n => n.community)).size }); - """ - ) - assert report["groups"] == 2 - - -@requires_node -def test_graph_analysis_is_stack_safe_and_bounded_on_a_large_store() -> None: - """A long chain of entities is the worst case for both analyses. - - A recursive Tarjan overflows the call stack here, and exact Brandes betweenness is - O(V*E) — minutes of blocked main thread. Both are guarded, so this must finish well - inside the bound even on a slow machine. - """ - report = _run_node( - """ - const N = 40000; - const nodes = [], links = []; - for (let i = 0; i < N; i++) { - nodes.push({ id: 'n' + i }); - if (i) links.push({ source: 'n' + (i - 1), target: 'n' + i }); - } - const adj = I.communities(nodes, links); - const started = Date.now(); - I.findBridges(nodes, links, adj); - I.betweenness(nodes, adj); - const scores = nodes.map(n => n.betweenness); - emit({ - ms: Date.now() - started, - allBridges: links.every(l => l.bridge), - finite: scores.every(Number.isFinite), - peak: Math.max.apply(null, scores.slice(0, 1000).concat(scores.slice(-1000))), - }); - """ - ) - assert report["allBridges"] is True - assert report["finite"] is True - # Ends of a chain are never on a shortest path between others. - assert report["peak"] < 0.5 - assert report["ms"] < 30000, f"graph analysis took {report['ms']}ms on 40k entities" - - -@requires_node -def test_influence_relations_do_not_merge_two_topics_into_one_community() -> None: - """Community Islands must not fuse two topics over a single cross-topic relation. - - ``influences`` edges routinely span otherwise separate bodies of work. The classic - renderer keeps them drawn and traversable but builds its clustering adjacency without - them (``GCOMM_ADJ``); adding every link to one adjacency gives both topics the same - colour and the same force centre. - """ - report = _run_node( - """ - const nodes = ['a', 'b', 'c', 'd'].map(id => ({ id })); - const links = [ - { source: 'a', target: 'b', label: 'mentions' }, - { source: 'c', target: 'd', label: 'mentions' }, - { source: 'b', target: 'c', label: 'influences' }, - ]; - const adj = I.communities(nodes, links); - I.findBridges(nodes, links, adj); - emit({ - groups: new Set(nodes.map(n => n.community)).size, - merged: nodes[1].community === nodes[2].community, - neighbours: (adj.b || []).slice().sort(), - bridges: links.filter(l => l.bridge).length, - }); - """ - ) - assert report["groups"] == 2 - assert report["merged"] is False - # The relation itself stays in the traversal adjacency: hover neighbourhood, focus depth - # and bridge detection all still see it. Only the clustering ignores it. - assert report["neighbours"] == ["a", "c"] - assert report["bridges"] == 3 - - -@requires_node -def test_community_ids_are_ranked_by_size_so_the_legend_describes_the_right_nodes() -> None: - """Legend labels and canvas swatches must agree about which cluster is "Cluster 1". - - ``graphRenderLegend()`` sorts communities by size and calls the largest "Cluster 1", but - node colour indexes the palette by the community *id* (``commPal()[community % n]``). - Assigning ids in raw payload order therefore made the legend describe one component with - another's colour whenever a smaller component appeared first — which the payload order - alone decides. The classic ``graphComputeCommunities()`` sorts before assigning; so must - this. - """ - report = _run_node( - """ - // Payload order is deliberately worst-case: the singleton comes first, the largest - // component last, so raw iteration order and size order disagree completely. - const nodes = ['solo', 'm1', 'm2', 'a', 'b', 'c'].map(id => ({ id })); - const links = [ - { source: 'm1', target: 'm2' }, - { source: 'a', target: 'b' }, - { source: 'b', target: 'c' }, - ]; - I.communities(nodes, links); - const byId = {}; - nodes.forEach(n => { byId[n.id] = n.community; }); - emit({ byId, distinct: new Set(nodes.map(n => n.community)).size }); - """ - ) - assert report["distinct"] == 3 - # Largest component (3 nodes) owns palette slot 0, i.e. the legend's "Cluster 1". - assert report["byId"]["a"] == 0 - assert report["byId"]["b"] == 0 - assert report["byId"]["c"] == 0 - # Then the 2-node component, then the singleton — strictly by size, not by payload order. - assert report["byId"]["m1"] == 1 - assert report["byId"]["m2"] == 1 - assert report["byId"]["solo"] == 2 - - -@requires_node -def test_max_helper_survives_arrays_past_the_spread_limit() -> None: - """``Math.max(...array)`` throws RangeError long before a store is unrenderable.""" - report = _run_node("emit({ max: I.maxOf(new Array(400000).fill(7), 1) });") - assert report["max"] == 7 - - -@requires_node -def test_colour_helpers_handle_the_shorthand_hex_the_palettes_may_carry() -> None: - report = _run_node( - """ - emit({ - short: I.hexRgb('#abc'), - long: I.hexRgb('#8c83e8'), - empty: I.hexRgb(''), - light: I.contrastOn('#ffffff'), - dark: I.contrastOn('#000000'), - }); - """ - ) - assert report["short"] == [170, 187, 204] - assert report["long"] == [140, 131, 232] - assert report["empty"] == [140, 131, 232] - assert report["light"] == "#111827" - assert report["dark"] == "#f8fafc" - - -# ── render configuration: what the engine actually installs on force-graph ────────── - - -@requires_node -def test_flow_particles_are_capped_on_a_large_relation_set() -> None: - """Three animated particles per relation does not survive a real ``/graph`` response. - - force-graph advances every particle on every frame, so a few thousand relations is tens - of thousands of animated objects and an unusable canvas. The classic renderer refuses to - draw them past 800 links; the opt-in engine must use the same cutoff rather than trusting - that no store is big. - """ - report = _run_engine( - """ - const api = G.create(el, {}); - const particlesFor = link => store.linkDirectionalParticles(link || { layer: 'semantic' }); - api.setStyle('cyber'); - api.setSettings({ flow: true }); - api.setData(chain(40)); - const small = particlesFor(); - api.setData(chain(800)); - const atLimit = particlesFor(); - api.setData(chain(801)); - const overLimit = particlesFor(); - api.setData(chain(4000)); - emit({ small, atLimit, overLimit, realistic: particlesFor() * 4000, - particleWidth: store.linkDirectionalParticleWidth, - particleArrow: typeof store.linkDirectionalParticleCanvasObject === 'function' }); - """ - ) - assert report["small"] == 3 - assert report["atLimit"] == 3 - assert report["overLimit"] == 0 - # The number this guards: 4k relations x 3 particles was 12,000 animated objects a frame. - assert report["realistic"] == 0 - assert report["particleWidth"] == 1 - assert report["particleArrow"] is True - - -@requires_node -def test_unfreezing_reapplies_enabled_relation_flow_after_a_frozen_render() -> None: - """Freeze must not leave a still-enabled relation-flow switch visually inert.""" - - report = _run_engine( - """ - const api = G.create(el, {}); - const particles = () => store.linkDirectionalParticles({ layer: 'semantic' }); - api.setSettings({ flow: true }); - api.setData(chain(2)); - const live = particles(); - api.freeze(true); - api.setData(chain(3)); - const frozen = particles(); - api.freeze(false); - emit({ live, frozen, resumed: particles() }); - """ - ) - assert report == {"live": 3, "frozen": 0, "resumed": 3} - - -@requires_node -def test_a_dashboard_sync_that_turns_freeze_off_reheats_the_renderer() -> None: - """Classic redraws send the full settings object, so ``frozen:false`` must be actionable.""" - - report = _run_engine( - """ - const api = G.create(el, {}); - api.setPreset('compact'); - api.setData(chain(2)); - api.freeze(true); - const before = invocations.d3ReheatSimulation || 0; - api.setSettings({ frozen: false }); - emit({ - state: api.state().settings.frozen, - alpha: store.d3AlphaDecay, - reheats: (invocations.d3ReheatSimulation || 0) - before, - cooldown: store.cooldownTime, - }); - """ - ) - assert report == {"state": False, "alpha": 0.035, "reheats": 1, "cooldown": 2200} - - -@requires_node -def test_reduced_motion_keeps_auto_fit_instant_while_physics_stays_live() -> None: - """OS visual-motion preferences suppress camera animation, not layout physics.""" - - report = _run_engine( - """ - const timers = []; - globalThis.setTimeout = (callback, delay) => { timers.push(delay); callback(); return timers.length; }; - globalThis.clearTimeout = () => {}; - store.getGraphBbox = { x: [-10, 10], y: [-10, 10] }; - const api = G.create(el, { reducedMotion: () => true }); - api.setData(chain(2)); - emit({ timers, center: store.centerAt, zoom: store.zoom, - cooldown: [store.cooldownTime, store.cooldownTicks, store.warmupTicks], - reduced: api.physicsDiagnostics().reducedMotion, - }); - """ - ) - assert report["timers"] == [0] - assert report["center"][-1] == 0 - assert report["zoom"][-1] == 0 - assert report["cooldown"] == [0, 0, 0] - assert report["reduced"] is True - - -def test_legacy_flow_particles_use_small_directional_arrows() -> None: - """Classic and its static compatibility copy must not regress to round flow dots.""" - for path in (DASHBOARD, CLASSIC_DASHBOARD): - source = path.read_text(encoding="utf-8") - assert "linkDirectionalArrowLength(GPERF.dense?0:.625)" in source - assert ( - "linkDirectionalParticleWidth(.85).linkDirectionalParticleCanvasObject" - "(graphPaintFlowArrow)" in source - ) - - -#: A canvas 2D stand-in that counts the fills the galaxy starfield performs. The engine wraps -#: ``onRenderFramePre`` in a try/catch, so a stub too thin to survive the real paint would read -#: as "no stars drawn"; the small-graph leg of the test below is what proves it is thick enough. -CANVAS_STUB = """ -let fills = 0; -const ctx = { - globalAlpha: 1, globalCompositeOperation: '', fillStyle: '', strokeStyle: '', lineWidth: 1, - save() {}, restore() {}, beginPath() {}, arc() {}, ellipse() {}, stroke() {}, - fill() { fills += 1; }, - createRadialGradient() { return { addColorStop() {} }; }, -}; -""" - - -@requires_node -def test_galaxy_stops_animating_once_the_graph_is_large() -> None: - """A settled graph must fall off the CPU, and galaxy was the one style that never did. - - The starfield lives in ``onRenderFramePre``, which force-graph's change detection cannot - see, so the engine holds ``autoPauseRedraw(false)`` for it — repainting every node and link - every frame, forever, even after particles and the simulation have stopped. The classic - path simply drops the starfield past ``GPERF.large`` (``if(GPERF.large)return``); with the - stars gone there is nothing left that needs a frame the vendor would not schedule itself. - """ - report = _run_engine( - CANVAS_STUB - + """ - const api = G.create(el, {}); - api.setStyle('galaxy'); - - api.setData(chain(40)); - const smallAutoPause = store.autoPauseRedraw; - fills = 0; store.onRenderFramePre(ctx, 1); - const smallStars = fills; - - // 3001 entities / 3000 relations — past the classic renderer's 600-node signal. - api.setData(chain(3000)); - const bigAutoPause = store.autoPauseRedraw; - fills = 0; store.onRenderFramePre(ctx, 1); - const bigStars = fills; - - // Style is what costs the frames, not size alone: cyber never asked for them. - api.setStyle('cyber'); - api.setData(chain(40)); - emit({ smallAutoPause, bigAutoPause, smallStars, bigStars, - cyberAutoPause: store.autoPauseRedraw }); - """ - ) - # The custom 30 Hz physical clock invalidates only when it advances; force-graph's separate - # full-rate redraw loop remains parked even while the affordable starfield is present. - assert report["smallAutoPause"] is True - assert report["smallStars"] > 0, "canvas stub never reached the starfield" - # Large galaxy graph: no starfield, and the redraw loop is handed back to force-graph. - assert report["bigStars"] == 0 - assert report["bigAutoPause"] is True, "a large galaxy graph repaints every frame forever" - assert report["cyberAutoPause"] is True - - -@requires_node -def test_type_colours_follow_the_active_theme_not_a_hard_coded_dark_palette() -> None: - """``applyTheme()`` recolours the canvas, but the engine had no theme to recolour to. - - The legend and controls read the ``--entity-*`` custom properties, so switching to Light, - Midnight, Solarized or Sepia moved them while the canvas kept the dark-theme constants — - an inconsistent palette and, on the light themes, poor contrast. The engine cannot read - CSS variables from a canvas, so the dashboard supplies the resolved values. - """ - report = _run_engine( - """ - const api = G.create(el, {}); - // setData first: the force-graph stand-in only starts answering graphData() once the - // engine has pushed data into it, where the real vendor seeds an empty graph. - // Linked, because the default scope hides degree-zero entities. - api.setData({ - nodes: [{ id: 'a', etype: 'person_or_concept' }, { id: 'b', etype: 'person_or_concept' }], - links: [{ source: 'a', target: 'b', layer: 'entity' }], - }); - api.setColorBy('type'); - api.setStyle('classic'); - // `store` holds the values handed to force-graph, so this is the node object the - // engine actually painted from — recoloured in place by refreshColors()/render(). - const colour = () => store.graphData.nodes[0].color; - - const fallback = colour(); - api.setThemeColors({ person_or_concept: '#112233' }); - const themed = colour(); - - // A style palette still outranks the theme, exactly as classic graphTypeColor() does. - api.setStyle('cyber'); - const styled = colour(); - - // ...and an explicit user override still outranks both. - api.setStyle('classic'); - api.setTypeColor('person_or_concept', '#abcdef'); - const overridden = colour(); - - // A theme with no entry for the type must not strand the previous theme's colour. - api.setThemeColors({}); - emit({ fallback, themed, styled, overridden, cleared: colour() }); - """ - ) - assert report["fallback"] == "#8c83e8" - assert report["themed"] == "#112233", "the engine ignores the active theme" - assert report["styled"] == "#ff3ea5" - assert report["overridden"] == "#abcdef" - # The override survives; only the theme tier was replaced. - assert report["cleared"] == "#abcdef" - - -@requires_node -def test_hovering_a_node_asks_for_a_redraw() -> None: - """A highlight nobody repaints is invisible. - - ``onNodeHover`` mutates closure state the paint callbacks read. With reduced motion on, - flow disabled, or a settled simulation, force-graph's ``autoPauseRedraw`` loop has nothing - left to animate and will not repaint just because the callback fired. - """ - report = _run_engine( - """ - const api = G.create(el, { reducedMotion: () => true }); - api.setData({ nodes: [{ id: 'a' }, { id: 'b' }], links: [{ source: 'a', target: 'b' }] }); - const settled = calls.nodeCanvasObject; - store.onNodeHover({ id: 'a' }); - const hovered = calls.nodeCanvasObject; - store.onNodeHover(null); - emit({ - settled, hovered, cleared: calls.nodeCanvasObject, - particles: store.linkDirectionalParticles({ layer: 'semantic' }), - }); - """ - ) - # Reduced motion: nothing is in flight, so an unrequested redraw would never arrive. - assert report["particles"] == 0 - assert report["hovered"] > report["settled"] - assert report["cleared"] > report["hovered"] - - -@requires_node -def test_unlinked_entities_are_shown_by_default_and_can_be_hidden() -> None: - """The default graph is complete, while the user can still request a linked-only view.""" - report = _run_engine( - """ - const seen = []; - const api = G.create(el, { onStats: stats => seen.push(stats.nodes) }); - api.setData({ - nodes: [{ id: 'a' }, { id: 'b' }, { id: 'lonely' }], - links: [{ source: 'a', target: 'b' }], - }); - const shown = seen[seen.length - 1]; - api.setScope({ showUnlinked: false }); - const hidden = seen[seen.length - 1]; - api.setScope({ showUnlinked: true }); - emit({ hidden, shown, restored: seen[seen.length - 1] }); - """ - ) - assert report["hidden"] == 2 - assert report["shown"] == 3 - assert report["restored"] == 3 - - -#: Executes the *real* ``graphRenderEngine`` source against stubs. Only its collaborators are -#: faked; the function itself is a verbatim slice, so what it forwards to the engine — and when -#: it parks a freshly created renderer — is observed rather than asserted about the source text. -RENDER_HARNESS = """ -const fs = require('fs'); -const src = fs.readFileSync(process.argv.slice(1).find(a => a.endsWith('dashboard.js')), 'utf8'); -const scenario = JSON.parse(process.argv[process.argv.length - 1]); -const start = src.indexOf('function graphRenderEngine('); -const slice = src.slice(start, src.indexOf('/* Nav away from the graph view', start)); - -/* The theme-colour lookup is sliced verbatim too, not stubbed: the property under test is - that the dashboard resolves the *active* CSS custom properties and hands them over, so - faking the resolver would assert nothing. Only `getComputedStyle` below is synthetic. */ -const between = (from, to) => src.slice(src.indexOf(from), src.indexOf(to, src.indexOf(from))); -const themeSrc = between('const ETYPE_TOKEN=', 'const GRAPH_PALETTES=') - + between('function cssvar(', 'function graphValidColor(') - + between('function graphThemeTypeColors(', 'function graphContrastColor('); - -/* A stand-in for a non-dark theme: every --entity-* token differs from the engine's - hard-coded THEME_ETYPE constants, so a renderer that ignored these would be visible. */ -const THEME_VARS = { - '--entity-concept': '#112233', '--entity-mention': '#223344', '--entity-hashtag': '#334455', - '--entity-email': '#445566', '--entity-organization': '#556677', '--entity-location': '#667788', - '--color-accent': '#778899', '--color-panel': '#9a7654', '--color-canvas': '#345678', - '--color-text-dim': '#123456', -}; -globalThis.getComputedStyle = () => ({ getPropertyValue: name => THEME_VARS[name] || '' }); - -const log = { created: 0, paused: 0, seeded: 0, scope: null, themeColors: null, error: null }; -const checkbox = { checked: scenario.showUnlinked }; -const element = { classList: { toggle() {} }, setAttribute() {}, set textContent(value) {} }; -globalThis.document = { - getElementById: id => (id === 'graph-show-iso' ? checkbox : element), - querySelectorAll: () => [], - body: {}, -}; -const engine = { - setSettings() {}, setStyle() {}, setColorBy() {}, setPalette() {}, setTypeColors() {}, - setLayers() {}, setScope(patch) { log.scope = patch; }, - setThemeColors(map) { log.themeColors = map; }, - setData(data) { log.seeded = data.nodes.length; }, -}; -const api = { - apply(fn, fit, reheat) { fn(engine); log.apply = { fit: !!fit, reheat: !!reheat }; }, communityMap: () => ({}), - freeze() {}, destroy() {}, resume() {}, pause() { log.paused += 1; }, -}; -globalThis.EngraphisGraph = { create() { log.created += 1; return api; } }; -globalThis.window = { GSET: { mode: 'compact', frozen: false } }; -globalThis.GRAPH = { nodes: [] }; -globalThis.GRAPH_ENGINE = null; -globalThis.GACTIVE_DATA = null; -globalThis.GCOLOR_OVERRIDES = {}; -/* The state the nav-away pause recorded while GRAPH_ENGINE was still null. */ -globalThis.GRAPH_ENGINE_PARKED = scenario.parked; -globalThis.showAs = () => {}; -globalThis.prefersReducedMotion = () => !!scenario.reducedMotion; -for (const name of ['graphSetLayoutStatus', 'graphSyncReadouts', 'graphUpdateEditedBadge', - 'graphUpdateHud', 'graphRenderLegend', 'graphSetHighlight', - 'graphSetSimulationStatus', 'syncGraphExplorerSelection', 'graphNodeClick', - 'graphEngineEmptyMessage']) globalThis[name] = () => {}; -globalThis.graphEngineFallback = error => { - log.error = String((error && error.message) || error); -}; - -const graphRenderEngine = new Function(themeSrc + slice + '\\nreturn graphRenderEngine;')(); -const rendered = graphRenderEngine({ - nodes: [{ id: 'a' }, { id: 'b' }, { id: 'lonely' }], - links: [{ source: 'a', target: 'b' }], -}, true, true); -console.log(JSON.stringify(Object.assign({ rendered }, log))); -""" - - -def _run_render( - *, show_unlinked: bool = False, parked: bool = False, reduced_motion: bool = False -) -> dict: - source = DASHBOARD.read_text(encoding="utf-8") - # The harness slices real source; keep its landmarks honest. - assert "function graphRenderEngine(" in source - assert "/* Nav away from the graph view" in source - scenario = json.dumps({ - "showUnlinked": show_unlinked, - "parked": parked, - "reducedMotion": reduced_motion, - }) - result = subprocess.run( - [NODE, "-e", RENDER_HARNESS, str(DASHBOARD), scenario], - cwd=ROOT, - capture_output=True, - text=True, - check=False, - ) - assert result.returncode == 0, result.stderr - report = json.loads(result.stdout.strip().splitlines()[-1]) - assert report["error"] is None, report["error"] - assert report["rendered"] is True - return report - - -@requires_node -@pytest.mark.parametrize("checked", [False, True]) -def test_dashboard_tells_the_engine_whether_to_show_unlinked_entities(checked: bool) -> None: - """"Show unlinked nodes" is filtered twice, and only one half was wired up. - - ``graphData()`` starts supplying degree-zero entities when the box is ticked, but the - engine re-filters on its own ``showUnlinked``/``minDegree`` state — which stays at the - defaults that drop exactly those entities — unless the dashboard says otherwise. - """ - report = _run_render(show_unlinked=checked) - - assert report["scope"] is not None, "the engine never learns the checkbox state" - assert report["scope"]["showUnlinked"] is checked - # minDegree matters just as much: showUnlinked alone still loses to `degree >= 1`. - assert report["scope"]["minDegree"] == (0 if checked else 1) - - -@requires_node -def test_dashboard_hands_the_engine_the_active_themes_entity_colours() -> None: - """The other half of the theme fix: the engine can only use what it is given.""" - report = _run_render() - - assert report["themeColors"] is not None, "the engine never learns the active theme" - # Resolved from the stubbed --entity-* custom properties, not from any JS constant. - assert report["themeColors"]["person_or_concept"] == "#112233" - assert report["themeColors"]["organization"] == "#556677" - assert report["themeColors"]["accent"] == "#778899" - assert report["themeColors"]["surface"] == "#9a7654" - assert report["themeColors"]["canvas"] == "#345678" - assert report["themeColors"]["relation_label"] == "#123456" - assert report["themeColors"]["label"] == "#e7e9ee" - # Every type the legend can show must be covered, or the canvas falls back per type. - assert set(report["themeColors"]) == { - "person_or_concept", "mention", "hashtag", "email", "organization", "location", - "accent", "surface", "canvas", "relation_label", "label", - } - - -def test_a_theme_switch_repaints_the_opt_in_canvas() -> None: - """``applyTheme()`` is the only place a theme change is observable. - - It already calls ``graphRecolor()``; that path has to reach the engine, or the canvas keeps - the previous theme until the next full graph render. - """ - source = DASHBOARD.read_text(encoding="utf-8") - assert "if(typeof graphRecolor==='function')graphRecolor()" in source - recolor = source[source.index("function graphRecolor()"):] - recolor = recolor[: recolor.index("\nfunction graphFit")] - assert "engine.setThemeColors(graphThemeTypeColors())" in recolor - - -@requires_node -def test_a_renderer_created_after_leaving_the_graph_view_is_born_paused() -> None: - """The rAF leak this PR already fixed once, reached by a different route. - - ``/graph`` and both lazy scripts resolve asynchronously. Leaving Graph before they do runs - the pause while ``GRAPH_ENGINE`` is still null, so the pending callback would create and - start a renderer against a hidden pane that nothing ever pauses again. - """ - parked = _run_render(parked=True) - assert parked["created"] == 1 - assert parked["paused"] == 1, "a renderer created off-view keeps repainting forever" - - # On the view, the same path must not park a renderer the user is looking at. - live = _run_render(parked=False) - assert live["created"] == 1 - assert live["paused"] == 0 - - -@requires_node -def test_classic_graph_starts_live_even_when_the_os_prefers_reduced_motion() -> None: - """Reduced visual motion cannot suppress the explicit physics default.""" - - report = _run_render(reduced_motion=True) - assert report["apply"] == {"fit": True, "reheat": True} - - source = CLASSIC_DASHBOARD.read_text(encoding="utf-8") - assert "window.GSET.frozen=false;" in source - engine = source[source.index("function graphRenderEngine("):] - engine = engine[:engine.index("/* Nav away from the graph view")] - assert "},fit,reheat);" in engine - assert "reheat&&!prefersReducedMotion()" not in engine - - -def test_classic_freeze_switch_keeps_the_status_readout_in_sync() -> None: - source = CLASSIC_DASHBOARD.read_text(encoding="utf-8") - start = source.index("function graphToggleFreeze(") - handler = source[start:source.index("\nfunction graphToggleLabels", start)] - assert "GRAPH_ENGINE.freeze(control.checked);graphSetSimulationStatus(control.checked?'Layout frozen':'Adaptive layout',false);return" in handler - - -def test_leaving_the_graph_view_records_the_pause_as_well_as_applying_it() -> None: - source = DASHBOARD.read_text(encoding="utf-8") - assert "if(v==='graph')graphEngineResume();else graphEnginePause()" in source - pause = source[source.index("function graphEnginePause()"):] - pause = pause[: pause.index("\nfunction graphInvalidateData")] - assert "GRAPH_ENGINE_PARKED=true" in pause - assert "GRAPH_ENGINE_PARKED=false" in pause - - -#: Force-graph resolves each link's ``source``/``target`` from an id to the node object once it -#: owns the data, and the paint callbacks read ``.x``/``.y`` off those objects. The recording -#: stand-in stores the arrays untouched, so a test that wants to *drive* a link painter has to -#: do that resolution — and give the nodes coordinates — itself. -LAY_OUT = """ -const layOut = () => { - const data = store.graphData; - const byId = new Map(data.nodes.map(n => [n.id, n])); - data.nodes.forEach((n, i) => { n.x = i * 10; n.y = i; }); - data.links.forEach(l => { - const s = byId.get(l.source && l.source.id !== undefined ? l.source.id : l.source); - const t = byId.get(l.target && l.target.id !== undefined ? l.target.id : l.target); - if (s) l.source = s; - if (t) l.target = t; - }); - return data; -}; -let painted = []; -const linkCtx = { - font: '', fillStyle: '', textAlign: '', textBaseline: '', - fillText(text) { painted.push(String(text)); }, -}; -const paintLinks = (scale, links) => { - painted = []; - const mode = store.linkCanvasObjectMode ? store.linkCanvasObjectMode() : undefined; - const draw = store.linkCanvasObject; - if (mode === 'after' && draw) (links || store.graphData.links).forEach(l => draw(l, linkCtx, scale)); - return painted.slice(); -}; -""" - - -@requires_node -def test_relation_labels_are_painted_when_the_labels_box_is_ticked() -> None: - """**Labels** turns on two label layers on the classic path; the engine only had one. - - ``graphToggleLabels`` forwards the checkbox straight to ``setSettings({labels})``, and the - classic renderer answers it with *both* entity names and a ``linkCanvasObject`` that paints - each meaningful ``link.label``. Implicit ``co_occurs`` links are structural and deliberately - excluded. The opt-in engine configured no link painter at all, so relation names silently - disappeared under ``?graph-engine=next`` and could only be read by hovering one edge at a - time. - """ - report = _run_engine( - LAY_OUT - + """ - const api = G.create(el, { reducedMotion: () => true }); - api.setData({ - nodes: [{ id: 'a' }, { id: 'b' }], - links: [ - { source: 'a', target: 'b', layer: 'entity', label: 'mentions' }, - { source: 'b', target: 'a', layer: 'semantic', label: 'co_occurs' }, - ], - }); - layOut(); - const unticked = paintLinks(4); - api.setSettings({ labels: true }); - api.setThemeColors({ relation_label: '#123456' }); - const ticked = paintLinks(4); - const labelColor = linkCtx.fillStyle; - // Relation labels are the noisiest layer: they stay off until the user zooms in. - const zoomedOut = paintLinks(1); - emit({ unticked, ticked, zoomedOut, labelColor }); - """ - ) - assert report["unticked"] == [] - assert report["ticked"] == ["mentions"], "the Labels checkbox never paints relation names" - assert report["labelColor"] == "#123456", "relation labels ignore the active theme" - assert report["zoomedOut"] == [] - - -def test_classic_graph_hides_implicit_co_occurrence_edge_labels() -> None: - """The Labels toggle keeps meaningful relation names but omits structural co-occurrences.""" - static = DASHBOARD.read_text(encoding="utf-8") - classic = CLASSIC_DASHBOARD.read_text(encoding="utf-8") - assert static == classic, "the classic dashboard assets must remain synchronized" - label_guard = "function graphShowRelationLabel(label){return !!label&&String(label).toLowerCase()!=='co_occurs'}" - assert label_guard in static - assert "if(scale<2.4||!graphShowRelationLabel(link.label)||!link.source.x" in static - - -@requires_node -def test_node_labels_are_capped_at_the_configured_density() -> None: - """A high density setting must still bound per-frame node-label painting.""" - report = _run_engine( - """ - let labels = []; - const ctx = { - globalAlpha: 1, fillStyle: '', strokeStyle: '', lineWidth: 1, font: '', textBaseline: '', - save() {}, restore() {}, beginPath() {}, arc() {}, stroke() {}, fill() {}, - createLinearGradient() { return { addColorStop() {} }; }, - createRadialGradient() { return { addColorStop() {} }; }, - fillText(text) { labels.push(String(text)); }, - }; - const api = G.create(el, { reducedMotion: () => true }); - api.setData(chain(20)); - api.setSettings({ labels: true, labelDensity: 3 }); - store.graphData.nodes.forEach((node, index) => { - node.x = index * 10; node.y = 0; - }); - const beforePost = labels.slice(); - store.onRenderFramePost(ctx, 1); - const names = labels.filter(value => value.startsWith('n')); - emit({ beforePost, names, distinct: [...new Set(names)] }); - """ - ) - assert report["beforePost"] == [], "node labels must wait until every node body is painted" - assert len(report["distinct"]) == 3 - assert len(report["names"]) == 6 # shadow + foreground per selected node - - -def test_collapsed_cluster_labels_use_the_active_theme_text_colour() -> None: - source = ASSET.read_text(encoding="utf-8") - cluster_label = source[source.index("if (label.cluster)"):source.index("} else {", source.index("if (label.cluster)"))] - assert "state.themeColors.label || '#e7e9ee'" in cluster_label - - -@requires_node -def test_node_labels_use_the_active_theme_text_colour() -> None: - """Classic labels paint onto the canvas, so near-white is unreadable on light themes.""" - - report = _run_engine( - LAY_OUT - + """ - const api = G.create(el, { reducedMotion: () => true }); - api.setData(chain(2)); - const data = layOut(); - api.setStyle('classic'); - api.setThemeColors({ label: '#123456' }); - api.setHighlight('n0'); - const styles = []; - const ctx = { - set fillStyle(value) { styles.push(value); }, get fillStyle() { return ''; }, - font: '', textBaseline: '', lineWidth: 0, strokeStyle: '', globalAlpha: 1, - beginPath() {}, arc() {}, fill() {}, stroke() {}, fillText() {}, save() {}, restore() {}, - createRadialGradient() { return { addColorStop() {} }; }, - createLinearGradient() { return { addColorStop() {} }; }, - }; - store.onRenderFramePost(ctx, 1); - emit({ styles }); - """ - ) - assert "#123456" in report["styles"], "node labels ignored the active theme text colour" - - -@requires_node -def test_drag_release_is_kinematic_and_never_wakes_unrelated_systems() -> None: - """Pointer placement changes one node without touching global alpha or other bodies.""" - report = _run_engine( - """ - const linkForce = { - id() { return this; }, distance() { return this; }, strength() { return this; }, - }; - globalThis.d3 = { - forceLink: () => linkForce, - forceCollide: () => ({ iterations() { return this; } }), - }; - store.d3Forces = { center: { vendorDefault: true } }; - const api = G.create(el, { reducedMotion: () => true }); - api.setData({ - nodes: [ - { id: 'dragged', x: -20, y: 0, gravity_mass: 4, community_id: 'local' }, - { id: 'neighbour', x: 0, y: 0, gravity_mass: 2, community_id: 'local' }, - { id: 'orphan', x: 80, y: 30, gravity_mass: 7, community_id: 'remote' }, - ], - edges: [{ source: 'dragged', target: 'neighbour', rest_length: 20, spring_strength: 0.1 }], - }); - api.setScope({ showUnlinked: true, minDegree: 0 }); - const byId = Object.fromEntries(store.graphData.nodes.map(node => [node.id, node])); - byId.dragged.vx = 9; byId.dragged.vy = -7; - byId.neighbour.vx = 3; byId.neighbour.vy = 4; - byId.orphan.vx = -5; byId.orphan.vy = 6; - const untouched = () => ['neighbour', 'orphan'].map(id => { - const node = byId[id]; - return [id, node.x, node.y, node.vx, node.vy, node.fx, node.fy]; - }); - const wakes = () => ({ - alphaTarget: calls.d3AlphaTarget || 0, - alphaDecay: calls.d3AlphaDecay || 0, - resets: invocations.resetCountdown || 0, - reheats: invocations.d3ReheatSimulation || 0, - }); - const before = { untouched: untouched(), wakes: wakes() }; - store.onNodeDragStart(byId.dragged); - const duringForces = ['charge', 'galaxy', 'galaxyCenter', 'galaxyRelations', - 'communityBridges', 'link', 'x', 'y', 'radial', 'collide', 'center', - 'velocityGuard'] - .map(name => store.d3Forces[name] === null); - byId.dragged.x = byId.dragged.fx = 35; - byId.dragged.y = byId.dragged.fy = 12; - const during = { untouched: untouched(), wakes: wakes() }; - store.onNodeDragEnd(byId.dragged); - setTimeout(() => emit({ - before, during, - after: { untouched: untouched(), wakes: wakes() }, - duringForces, - dragged: [byId.dragged.x, byId.dragged.y, byId.dragged.vx, byId.dragged.vy, - byId.dragged.fx, byId.dragged.fy], - restored: { - linkRemoved: store.d3Forces.link === null, - galaxy: typeof store.d3Forces.galaxy, - galaxyCenter: typeof store.d3Forces.galaxyCenter, - relations: typeof store.d3Forces.galaxyRelations, - bridges: typeof store.d3Forces.communityBridges, - guard: typeof store.d3Forces.velocityGuard, - centerRemoved: store.d3Forces.center === null, - }, - }), 0); - """ - ) - assert all(report["duringForces"]) - assert report["before"]["untouched"] == report["during"]["untouched"] - assert report["before"]["untouched"] == report["after"]["untouched"] - assert report["during"]["wakes"]["alphaTarget"] == report["before"]["wakes"]["alphaTarget"] - assert report["after"]["wakes"] == report["during"]["wakes"] - for key in ("alphaDecay", "resets", "reheats"): - assert report["during"]["wakes"][key] == report["before"]["wakes"][key] - assert report["dragged"] == [35, 12, 9, -7, None, None] - assert report["restored"] == { - "linkRemoved": True, - "galaxy": "object", - "galaxyCenter": "object", - "relations": "object", - "bridges": "object", - "guard": "object", - "centerRemoved": True, - } - - -@requires_node -def test_galaxy_drag_never_touches_d3_alpha_or_countdown() -> None: - report = _run_engine( - """ - globalThis.d3 = {}; - const api = G.create(el, { reducedMotion: () => true }); - api.setData({ - nodes: [ - { id: 'a', x: 0, y: 0, gravity_mass: 4, community_id: 'a' }, - { id: 'b', x: 80, y: 0, gravity_mass: 2, community_id: 'b' }, - ], - edges: [], - }); - api.setScope({ showUnlinked: true, minDegree: 0 }); - const dragged = store.graphData.nodes[0]; - api.reheat(); - const before = { - alpha: calls.d3AlphaTarget || 0, - resets: invocations.resetCountdown || 0, - reheats: invocations.d3ReheatSimulation || 0, - }; - store.onNodeDragStart(dragged); - store.onNodeDragEnd(dragged); - emit({ - alphaStops: (calls.d3AlphaTarget || 0) - before.alpha, - countdownResets: (invocations.resetCountdown || 0) - before.resets, - reheats: (invocations.d3ReheatSimulation || 0) - before.reheats, - }); - """ - ) - assert report == {"alphaStops": 0, "countdownResets": 0, "reheats": 0} - - -def test_drag_keeps_galaxy_live_without_any_d3_reheat_path() -> None: - """Dragging fixes one moving source; it must not detach or wake global physics.""" - source = ASSET.read_text(encoding="utf-8") - assert "function isolateDragPhysics()" not in source - assert "function restoreDragPhysics()" not in source - assert "if (activeDragNode) return false" not in source - assert "fixedNodeId: activeDragNode ? activeDragNode.id : null" in source - assert "GALAXY_DRAG_GRAVITY_CAPTURE_RADIUS" in source - assert "GALAXY_DRAG_GRAVITY_MULTIPLIER = 2" in source - assert "dragSource: activeDragNode" in source - begin = source[source.index("function beginNodeDrag(node) {"):] - begin = begin[: begin.index(" function finishNodeDrag", 1)] - finish = source[source.index("function finishNodeDrag(node) {"):] - finish = finish[: finish.index(" /* A drag uses", 1)] - forbidden = ("prepareReheat(", "softReheat(", "resetCountdown(", - "d3AlphaTarget(", "d3AlphaDecay(", "d3ReheatSimulation(") - assert not any(call in begin for call in forbidden) - assert not any(call in finish for call in forbidden) - assert "cancelGalaxyDynamics(" not in begin - assert "setSimulationBudget(false" not in begin - follow = source[source.index("function followDraggedNode(node) {"):] - follow = follow[: follow.index(" function beginNodeDrag", 1)] - assert "applyDraggedNodeGravity(" not in follow - assert "dragFollowers = captureDragFollowers(node)" in follow - assert "reheatLiveLayout" not in source - assert "makeDragFollowForce" not in source - - -@requires_node -def test_galaxy_freeze_keeps_d3_fully_stopped_before_and_after_unfreeze() -> None: - """Galaxy resumes its own clock; it must never reactivate D3's position integrator.""" - - report = _run_engine( - """ - const api = G.create(el, {}); - api.setData(chain(2)); - api.freeze(true); - api.setData(chain(3)); - const frozen = { - time: store.cooldownTime, ticks: store.cooldownTicks, warmup: store.warmupTicks, - }; - api.freeze(false); - emit({ - frozen, - resumed: { - time: store.cooldownTime, ticks: store.cooldownTicks, warmup: store.warmupTicks, - }, - }); - """ - ) - assert report["frozen"] == {"time": 0, "ticks": 0, "warmup": 0} - assert report["resumed"] == {"time": 0, "ticks": 0, "warmup": 0} - - -@requires_node -def test_freeze_is_the_physics_gate_even_with_reduced_motion() -> None: - """The switch must never claim physics is live while an OS preference disables it.""" - - report = _run_engine( - """ - const reheats = () => invocations.d3ReheatSimulation || 0; - const api = G.create(el, { reducedMotion: () => true }); - api.setData(chain(2)); - const started = { budget: [store.cooldownTime, store.cooldownTicks], - diagnostics: api.physicsDiagnostics(), reheats: reheats() }; - api.freeze(true); - const frozen = { diagnostics: api.physicsDiagnostics(), reheats: reheats() }; - api.freeze(false); - emit({ started, frozen, - resumed: { diagnostics: api.physicsDiagnostics(), reheats: reheats() } }); - """ - ) - assert report["started"]["budget"] == [0, 0] - assert report["started"]["diagnostics"]["reducedMotion"] is True - assert report["frozen"]["diagnostics"]["frozen"] is True - assert report["resumed"]["diagnostics"]["frozen"] is False - assert report["started"]["reheats"] == report["frozen"]["reheats"] == report["resumed"]["reheats"] == 0 - - -@requires_node -def test_persistent_galaxy_clock_is_fixed_bounded_and_lifecycle_safe() -> None: - report = _run_engine( - """ - let nextFrame = 1; - const frameQueue = new Map(); - window.requestAnimationFrame = callback => { - const id = nextFrame++; - frameQueue.set(id, callback); - return id; - }; - window.cancelAnimationFrame = id => frameQueue.delete(id); - const flush = timestamp => { - const batch = [...frameQueue.values()]; - frameQueue.clear(); - batch.forEach(callback => callback(timestamp)); - }; - let hidden = false, visibilityHandler = null; - globalThis.document = { - get hidden() { return hidden; }, - addEventListener(name, handler) { - if (name === 'visibilitychange') visibilityHandler = handler; - }, - removeEventListener(name, handler) { - if (name === 'visibilitychange' && visibilityHandler === handler) visibilityHandler = null; - }, - }; - - const api = G.create(el, { reducedMotion: () => false }); - api.setData({ - nodes: [ - { id: 'heavy', x: -20, y: 0, gravity_mass: 4, community_id: 'one' }, - { id: 'light', x: 20, y: 0, gravity_mass: 1, community_id: 'one' }, - ], - edges: [{ source: 'heavy', target: 'light' }], - }); - const actualNodes = store.graphData.nodes; - const expectedNodes = actualNodes.map(node => ({ ...node })); - I.integrateGalaxyLeapfrog(expectedNodes, store.graphData.links, [], { - gravity: 48, - softening: 38.4, - centralSoftening: 48, - bridgeSoftening: 38.4, - exactLimit: 64, - theta: 0.85, - localPairFraction: 0.15, - corePairMultiplier: 0.75, - includeBridges: false, - includeRelations: true, - includeRelationSprings: false, - skipSystemAnchorRelations: true, - skipOrbitalSystemRelations: true, - orbitScale: 0.25, - relationStrengthMultiplier: 2, - relationForceCap: 1.6, - relationAccelerationCap: 3.2, - relationConstraintStrengthMultiplier: 2, - relationConstraintResponseMultiplier: 1, - relationConstraintRate: 24, - relationConstraintMaxCorrection: 12, - relationPadding: 15, - includeOrbitalSeparation: true, - orbitalSeparationPadding: 15, - orbitalSeparationStrength: 1, - crossCommunitySeparationPadding: 1.5, - crossCommunitySeparationStrength: 0.18, - orbitalSeparationMaxCorrection: 4, - orbitalSeparationMaxVelocityCorrection: 8, - preserveLocalTangentialVelocity: true, - preserveSystemRadii: true, - skipSystemAnchorPairs: true, - systemAnchorExclusionPadding: 1.5, - systemAnchorRepulsionRange: 6, - systemAnchorRepulsionAcceleration: 0.12, - includeMutualSystems: true, - mutualSystemGravityFraction: 0.12, - mutualSystemSoftening: 80, - localRelativeSpeedLimit: 48, - timestep: 0.032, - inwardConvergence: true, - wallClockSeconds: 1 / 30, - velocityDecay: 0.00005, - speedLimit: 48, - includeCollisions: false, - collisionPadding: 1.5, - collisionStrength: 0.7, - collisionIterations: 1, - }); - flush(100); - const first = { - actual: actualNodes.map(node => [node.x, node.y, node.vx, node.vy]), - expected: expectedNodes.map(node => [node.x, node.y, node.vx, node.vy]), - diagnostics: api.physicsDiagnostics(), - budget: [store.cooldownTime, store.cooldownTicks, store.warmupTicks], - d3ForcesOff: ['charge', 'link', 'center', 'galaxy', 'galaxyCenter', - 'galaxyRelations', 'communityBridges', 'collide', 'velocityGuard'] - .every(name => store.d3Forces[name] === null), - }; - - api.freeze(true); - const frozenPositions = actualNodes.map(node => [node.x, node.y, node.vx, node.vy]); - flush(5000); - const frozen = { - positions: actualNodes.map(node => [node.x, node.y, node.vx, node.vy]), - diagnostics: api.physicsDiagnostics(), - queued: frameQueue.size, - }; - api.freeze(false); - flush(9000); - const resumed = api.physicsDiagnostics(); - - hidden = true; - visibilityHandler(); - const hiddenPositions = actualNodes.map(node => [node.x, node.y, node.vx, node.vy]); - flush(50000); - const whileHidden = { - positions: actualNodes.map(node => [node.x, node.y, node.vx, node.vy]), - diagnostics: api.physicsDiagnostics(), - }; - hidden = false; - visibilityHandler(); - flush(100000); - const visibleAgain = api.physicsDiagnostics(); - - const dragged = actualNodes[0], unrelated = actualNodes[1]; - store.onNodeDragStart(dragged); - const unrelatedBeforeDrag = [unrelated.x, unrelated.y, unrelated.vx, unrelated.vy]; - dragged.x = dragged.fx = 75; - dragged.y = dragged.fy = 25; - flush(100100); - const duringDrag = [unrelated.x, unrelated.y, unrelated.vx, unrelated.vy]; - const stepsBeforeRelease = api.physicsDiagnostics().steps; - store.onNodeDragEnd(dragged); - flush(100200); - const releaseFrame = { - unrelated: [unrelated.x, unrelated.y, unrelated.vx, unrelated.vy], - steps: api.physicsDiagnostics().steps, - dragged: [dragged.x, dragged.y, dragged.vx, dragged.vy, dragged.fx, dragged.fy], - }; - flush(100234); - const afterDragEvolution = api.physicsDiagnostics(); - - api.pause(); - const pausedSteps = api.physicsDiagnostics().steps; - flush(200000); - const paused = api.physicsDiagnostics(); - api.resume(); - flush(300000); - const resumedAfterPause = api.physicsDiagnostics(); - api.destroy(); - emit({ - first, - frozenPositions, - frozen, - resumed, - hiddenPositions, - whileHidden, - visibleAgain, - unrelatedBeforeDrag, - duringDrag, - stepsBeforeRelease, - releaseFrame, - afterDragEvolution, - pausedSteps, - paused, - resumedAfterPause, - queuedAfterDestroy: frameQueue.size, - d3Wakes: { - alpha: calls.d3AlphaTarget || 0, - resets: invocations.resetCountdown || 0, - reheats: invocations.d3ReheatSimulation || 0, - }, - }); - """ - ) - assert report["first"]["actual"][0] == pytest.approx([0, 0, 0, 0]) - assert all( - math.isfinite(value) - for body in report["first"]["actual"] - for value in body - ) - assert report["first"]["diagnostics"]["steps"] == 1 - assert report["first"]["diagnostics"]["lastSubsteps"] == 1 - first = report["first"]["diagnostics"] - assert report["first"]["budget"] == [0, 0, 0] - assert report["first"]["d3ForcesOff"] is True - assert first["frames"] == first["steps"] == first["lastSubsteps"] == 1 - assert first["timestep"] == pytest.approx(0.032) - assert first["velocityDecay"] == pytest.approx(0.00005) - assert first["reducedMotion"] is False - assert first["kineticEnergy"] > 0 - assert first["speedCapActivations"] == 0 - - assert report["frozen"]["positions"] == report["frozenPositions"] - assert report["frozen"]["diagnostics"]["frozen"] is True - assert report["frozen"]["diagnostics"]["steps"] == 1 - assert report["frozen"]["queued"] == 0 - # Resuming after a long wall-clock gap performs one ordinary step, never three catch-up steps. - assert report["resumed"]["steps"] == 2 - assert report["resumed"]["lastSubsteps"] == 1 - - assert report["whileHidden"]["positions"] == report["hiddenPositions"] - assert report["whileHidden"]["diagnostics"]["steps"] == 2 - assert report["whileHidden"]["diagnostics"]["hidden"] is True - assert report["visibleAgain"]["steps"] == 3 - assert report["visibleAgain"]["lastSubsteps"] == 1 - - # Dragging owns only the primary node. The custom clock keeps integrating its related - # body around that moving mass source, without waking D3 or running catch-up substeps. - assert report["duringDrag"] != report["unrelatedBeforeDrag"] - assert report["releaseFrame"]["unrelated"] != report["unrelatedBeforeDrag"] - assert 3 < report["stepsBeforeRelease"] <= 6 - assert report["stepsBeforeRelease"] < report["releaseFrame"]["steps"] \ - <= report["stepsBeforeRelease"] + 3 - assert report["afterDragEvolution"]["steps"] \ - == report["releaseFrame"]["steps"] + 1 - assert all(value is not None for value in report["releaseFrame"]["dragged"][:4]) - assert report["releaseFrame"]["dragged"][4:] == [None, None] - - assert report["paused"]["steps"] == report["pausedSteps"] \ - == report["afterDragEvolution"]["steps"] - assert report["paused"]["running"] is False - assert report["resumedAfterPause"]["steps"] == report["pausedSteps"] + 1 - assert report["queuedAfterDestroy"] == 0 - assert report["d3Wakes"] == {"alpha": 0, "resets": 0, "reheats": 0} - - -@requires_node -def test_explicit_galaxy_reheat_never_adds_bonus_physical_slices() -> None: - report = _run_engine( - """ - let nextFrame = 1; - const frameQueue = new Map(); - window.requestAnimationFrame = callback => { - const id = nextFrame++; - frameQueue.set(id, callback); - return id; - }; - window.cancelAnimationFrame = id => frameQueue.delete(id); - const flush = timestamp => { - const batch = [...frameQueue.values()]; - frameQueue.clear(); - batch.forEach(callback => callback(timestamp)); - }; - const api = G.create(el, { reducedMotion: () => false }); - api.setData({ - nodes: [ - { id: 'black-hole', x: 0, y: 0, vx: 0, vy: 0, gravity_mass: 20, - community_id: 'core', anchor_role: 'global' }, - { id: 'unlinked-star', x: 140, y: 0, vx: 0, vy: 2, gravity_mass: 6, - community_id: 'outer' }, - ], - edges: [], - }); - flush(100); - flush(134); - const star = store.graphData.nodes.find(node => node.id === 'unlinked-star'); - const before = { - phase: [star.x, star.y, star.vx, star.vy], - diagnostics: api.physicsDiagnostics(), - }; - api.reheat(); - const queued = api.physicsDiagnostics(); - [200, 234, 268, 302, 336].forEach(flush); - const after = { - phase: [star.x, star.y, star.vx, star.vy], - diagnostics: api.physicsDiagnostics(), - }; - api.reheat(); - const recoalesced = api.physicsDiagnostics(); - api.freeze(true); - emit({ - before, queued, after, recoalesced, - frozen: api.physicsDiagnostics(), - d3: { - alpha: calls.d3AlphaTarget || 0, - resets: invocations.resetCountdown || 0, - reheats: invocations.d3ReheatSimulation || 0, - }, - }); - """ - ) - assert report["queued"]["reheatActivations"] == 1 - assert report["queued"]["reheatStepsRemaining"] == 0 - assert report["queued"]["reheatStepsApplied"] == 0 - assert report["after"]["diagnostics"]["reheatStepsApplied"] == 0 - assert report["after"]["diagnostics"]["reheatStepsRemaining"] == 0 - assert report["after"]["diagnostics"]["lastReheatSubsteps"] == 0 - assert report["after"]["diagnostics"]["steps"] \ - == report["before"]["diagnostics"]["steps"] + 5 - assert report["after"]["diagnostics"]["frames"] \ - == report["before"]["diagnostics"]["frames"] + 5 - assert report["after"]["diagnostics"]["lastSubsteps"] == 1 - assert report["after"]["phase"] != pytest.approx(report["before"]["phase"]) - assert report["recoalesced"]["reheatActivations"] == 2 - assert report["recoalesced"]["reheatStepsRemaining"] == 0 - assert report["recoalesced"]["reheatStepsApplied"] == 0 - assert report["frozen"]["reheatStepsRemaining"] == 0 - assert report["d3"] == {"alpha": 0, "resets": 0, "reheats": 0} - - -@requires_node -def test_manual_drag_keeps_clock_live_and_nearby_bodies_follow_fixed_source() -> None: - """Pointer ownership never freezes the graph; one source stays fixed while neighbours move.""" - - report = _run_engine( - """ - let nextFrame = 1; - const frameQueue = new Map(); - window.requestAnimationFrame = callback => { - const id = nextFrame++; - frameQueue.set(id, callback); - return id; - }; - window.cancelAnimationFrame = id => frameQueue.delete(id); - const flush = timestamp => { - const batch = [...frameQueue.values()]; - frameQueue.clear(); - batch.forEach(callback => callback(timestamp)); - }; - const manualWindowListeners = Object.create(null); - window.addEventListener = (name, handler) => { manualWindowListeners[name] = handler; }; - window.removeEventListener = (name, handler) => { - if (manualWindowListeners[name] === handler) delete manualWindowListeners[name]; - }; - const elementListeners = Object.create(null); - el.addEventListener = (name, handler) => { elementListeners[name] = handler; }; - el.removeEventListener = (name, handler) => { - if (elementListeners[name] === handler) delete elementListeners[name]; - }; - el.querySelector = selector => selector === 'canvas' ? { - getBoundingClientRect: () => ({ left: 0, top: 0 }), - } : null; - store.screen2GraphCoords = (x, y) => ({ x, y }); - - const api = G.create(el, { reducedMotion: () => false }); - api.setData({ - nodes: [ - { id: 'black-hole', anchor_role: 'global', x: 0, y: 0, - gravity_mass: 8, community_id: 'core' }, - { id: 'heavy', x: -30, y: 0, gravity_mass: 4, community_id: 'one' }, - { id: 'light', x: 30, y: 0, gravity_mass: 1, community_id: 'one' }, - { id: 'moon', x: 50, y: 20, gravity_mass: 1, community_id: 'one' }, - { id: 'remote', x: 140, y: -35, gravity_mass: 1, community_id: 'two' }, - ], - edges: [{ source: 'heavy', target: 'light' }], - }); - api.setScope({ showUnlinked: true, minDegree: 0 }); - flush(100); - const nodes = Object.fromEntries(store.graphData.nodes.map(node => [node.id, node])); - const pointer = (type, x, y) => ({ - type, button: 0, isPrimary: true, pointerId: 7, clientX: x, clientY: y, - preventDefault() {}, stopPropagation() {}, - }); - const unrelatedPhase = () => [nodes.remote.x, nodes.remote.y, nodes.remote.vx, nodes.remote.vy]; - const followerPhase = () => [nodes.light.x, nodes.light.y, nodes.light.vx, nodes.light.vy]; - const moonPhase = () => [nodes.moon.x, nodes.moon.y, nodes.moon.vx, nodes.moon.vy]; - const candidatePhase = () => [nodes.heavy.x, nodes.heavy.y, nodes.heavy.vx, nodes.heavy.vy]; - - const beforeDown = { - unrelated: unrelatedPhase(), follower: followerPhase(), moon: moonPhase(), - candidate: candidatePhase(), - steps: api.physicsDiagnostics().steps, - }; - elementListeners.pointerdown(pointer('pointerdown', nodes.heavy.x, nodes.heavy.y)); - const afterDown = { - unrelated: unrelatedPhase(), follower: followerPhase(), moon: moonPhase(), - candidate: candidatePhase(), - steps: api.physicsDiagnostics().steps, - }; - // Pointer-down alone is not a drag, and it must not suspend the Galaxy clock. - flush(5000); - const heldBeforeMove = { - unrelated: unrelatedPhase(), follower: followerPhase(), moon: moonPhase(), - candidate: candidatePhase(), - steps: api.physicsDiagnostics().steps, - }; - manualWindowListeners.pointermove(pointer('pointermove', nodes.heavy.x + 90, nodes.heavy.y + 45)); - const placedCandidate = candidatePhase(); - flush(6000); - const duringDrag = { - unrelated: unrelatedPhase(), follower: followerPhase(), moon: moonPhase(), - candidate: candidatePhase(), followers: api.physicsDiagnostics().dragFollowers, - steps: api.physicsDiagnostics().steps, - dragging: api.physicsDiagnostics().dragging, - }; - manualWindowListeners.pointerup(pointer('pointerup', nodes.heavy.x, nodes.heavy.y)); - const releaseSteps = api.physicsDiagnostics().steps; - flush(7000); // physics continues immediately; no restore/isolation frame exists - const releaseFrame = { unrelated: unrelatedPhase(), steps: api.physicsDiagnostics().steps }; - flush(7034); - const evolvedSteps = api.physicsDiagnostics().steps; - - // A click also leaves the ordinary clock live. - const clickBefore = candidatePhase(); - const clickBeforeSteps = api.physicsDiagnostics().steps; - elementListeners.pointerdown(pointer('pointerdown', nodes.heavy.x, nodes.heavy.y)); - flush(9000); - const clickHeld = candidatePhase(); - const clickHeldSteps = api.physicsDiagnostics().steps; - manualWindowListeners.pointerup(pointer('pointerup', nodes.heavy.x, nodes.heavy.y)); - const clickReleased = candidatePhase(); - const clickReleaseSteps = api.physicsDiagnostics().steps; - flush(9034); - const clickEvolvedSteps = api.physicsDiagnostics().steps; - - emit({ - beforeDown, afterDown, heldBeforeMove, duringDrag, - placedCandidate, releaseSteps, releaseFrame, evolvedSteps, - clickBefore, clickHeld, clickReleased, clickBeforeSteps, clickHeldSteps, - clickReleaseSteps, clickEvolvedSteps, - d3Wakes: { - alpha: calls.d3AlphaTarget || 0, - resets: invocations.resetCountdown || 0, - reheats: invocations.d3ReheatSimulation || 0, - }, - }); - """ - ) - assert report["afterDown"] == report["beforeDown"] - assert report["heldBeforeMove"]["steps"] > report["beforeDown"]["steps"] - assert report["heldBeforeMove"]["unrelated"] != report["beforeDown"]["unrelated"] - assert report["duringDrag"]["unrelated"] != report["heldBeforeMove"]["unrelated"] - assert report["duringDrag"]["follower"] != report["beforeDown"]["follower"] - assert report["duringDrag"]["moon"] != report["beforeDown"]["moon"] - assert report["duringDrag"]["candidate"] == pytest.approx(report["placedCandidate"]) - assert report["duringDrag"]["steps"] > report["heldBeforeMove"]["steps"] - assert report["duringDrag"]["dragging"] == "heavy" - assert set(report["duringDrag"]["followers"]) == {"light", "moon", "remote"} - assert report["releaseFrame"]["unrelated"] != report["duringDrag"]["unrelated"] - assert report["releaseFrame"]["steps"] > report["releaseSteps"] - assert report["evolvedSteps"] > report["releaseSteps"] - assert report["clickHeldSteps"] > report["clickBeforeSteps"] - assert report["clickHeld"] != pytest.approx(report["clickBefore"]) - assert report["clickReleased"] == pytest.approx(report["clickHeld"]) - assert report["clickEvolvedSteps"] > report["clickReleaseSteps"] - assert report["d3Wakes"] == {"alpha": 0, "resets": 0, "reheats": 0} - - -def test_primary_graph_dependencies_are_lazy_retryable_and_csp_clean() -> None: - """The primary Ledger must not pay for graph assets before Graph opens.""" - - markup = PRIMARY_INDEX.read_text(encoding="utf-8") - source = PRIMARY_LEDGER.read_text(encoding="utf-8") - vendor = PRIMARY_VENDOR.read_text(encoding="utf-8") - styles = PRIMARY_CSS.read_text(encoding="utf-8") - for asset in ("d3.min.js", "force-graph.min.js", "engraphis-graph.js"): - assert asset not in markup - assert 'id="graph-repel" type="range" min="0" max="400" value="100"' in markup - assert 'id="graph-link" type="range" min="4" max="80" value="8"' in markup - assert 'id="graph-gravity" type="range" min="0" max="400" value="96"' in markup - assert "{ id: 'graph-repel', key: 'repel', fallback: 100 }" in source - assert "{ id: 'graph-link', key: 'link', fallback: 8 }" in source - assert "{ id: 'graph-gravity', key: 'gravity', fallback: 96 }" in source - - loader_start = source.index("function ensureGraphAssets") - loader = source[ - loader_start:source.index("function showNotice", loader_start) - ] - 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'" - ) - assert d3 < force_graph < renderer - assert '/v2-assets/ledger.js?v=20260815-merge-ready-1' in markup - assert "if (graphAssetsPromise === attempt) releaseGraphAssetsAttempt(attempt)" in loader - assert "graphAssetsRetry = Math.min(graphAssetsRetry + 1, 10)" in loader - all_loader = source[source.index("function ensureGraphAllAsset()"): - source.index("function ensureGraphAssets(")] - assert "engraphis-graph-every.js?" in all_loader # cache-buster version intentionally unpinned - assert "engraphis-graph-every.js" not in loader.split("function releaseGraphAssetsAttempt", 1)[0] - assert not re.search(r'document\.createElement\(["\']style["\']\)', vendor) - assert ".force-graph-container canvas {" in styles - assert ".force-graph-container .grabbable:active {" in styles - assert ".float-tooltip-kap {" in styles - - -def test_primary_graph_starts_unfrozen_so_the_force_controls_take_effect() -> None: - """A fresh graph must settle, rather than make every tuning control look inert.""" - - assert "graphFrozen: false" in PRIMARY_LEDGER.read_text(encoding="utf-8") - assert "state.graphFrozen = false;" in PRIMARY_LEDGER.read_text(encoding="utf-8") - assert 'id="graph-freeze" class="graph-switch"' in PRIMARY_INDEX.read_text(encoding="utf-8") - freeze_control = PRIMARY_INDEX.read_text(encoding="utf-8").split('id="graph-freeze"', 1)[1] - assert 'aria-checked="false"' in freeze_control - - -def test_primary_dashboard_has_no_visible_notice_popup() -> None: - """Action feedback must not cover the dashboard with a dismissible toast.""" - - markup = PRIMARY_INDEX.read_text(encoding="utf-8") - source = PRIMARY_LEDGER.read_text(encoding="utf-8") - styles = (ROOT / "engraphis" / "dashboard_assets" / "ledger.css").read_text(encoding="utf-8") - assert 'id="notice"' not in markup - assert ">Dismiss<" not in markup - assert 'id="notice-text" class="sr-only"' in markup - assert "byId('notice').hidden" not in source - assert "notice-close" not in source - assert ".notice {" not in styles - - -def test_primary_layout_choices_resume_a_frozen_graph_including_full_mode() -> None: - """An explicit layout choice must visibly apply rather than merely change its selected chip.""" - - source = PRIMARY_LEDGER.read_text(encoding="utf-8") - handler = source.split("all('[data-graph-preset-choice]')", 1)[1].split( - "all('[data-graph-style-choice]')", 1 - )[0] - assert "const resumeLayout = state.graphFrozen;" in handler - assert "state.graphFrozen = false;" in handler - assert "state.graphEngine.freeze(false);" in handler - assert "state.graphEngine.setPreset(preset);" in handler - - -@requires_node -def test_focusing_an_entity_the_canvas_is_not_showing_does_not_report_success() -> None: - """``zoomToNode`` is the dashboard's visibility oracle, and it was answering from memory. - - ``graphFocus`` treats ``false`` as "offer the recovery path" — tick *Show unlinked*, retry, - and otherwise say *Entity not in view*. The engine answered from ``raw.nodes``, which keeps - the coordinates force-graph left on a node from an earlier render, so a node hidden by the - auto-collapsed view (only ``cluster-*`` bubbles are drawn below zoom 0.42) or by a scope - filter still reported success — the camera moved to nothing and the user got no explanation. - """ - report = _run_engine( - """ - const collapses = []; - const api = G.create(el, { - reducedMotion: () => true, onCollapseChange: value => collapses.push(value), - }); - api.setData({ - nodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'lonely' }], - links: [{ source: 'a', target: 'b' }, { source: 'b', target: 'c' }], - }); - const shownIds = () => (store.graphData.nodes || []).map(n => n.id); - // Everything visible once, so every entity carries real coordinates from here on. - api.setScope({ showUnlinked: true, minDegree: 0 }); - store.graphData.nodes.forEach((n, i) => { n.x = i * 10; n.y = i; }); - - // 1. Hidden by the scope filter, but still remembered with valid coordinates. - api.setScope({ showUnlinked: false, minDegree: 1 }); - const filtered = { found: api.zoomToNode('lonely'), shown: shownIds() }; - - // 2. Hidden by the collapsed view, which paints cluster bubbles instead of entities. - api.setCollapse(true); - const whileCollapsed = shownIds(); - const expanding = api.zoomToNode('c'); - // Galaxy preserves the coordinates from the expanded scene instead of throwing them - // away and waiting for a fresh simulation tick. - const rendered = (store.graphData.nodes || []).find(n => n.id === 'c'); - rendered.x = 20; rendered.y = 2; - const focused = api.zoomToNode('c'); - emit({ - filtered, whileCollapsed, expanding, focused, collapses, - afterFocus: shownIds(), collapsed: api.state().collapsed, - }); - """ - ) - # A filtered-out entity is not in view, so the dashboard must be told to recover. - assert report["filtered"]["found"] is False, "a filtered-out entity reported as visible" - assert "lonely" not in report["filtered"]["shown"] - # A collapsed view really is showing only bubbles... - assert report["whileCollapsed"] == ["cluster-0"] - # ...so focusing a named entity expands it. Galaxy retains its known scene coordinate and - # can center immediately instead of waiting for a second simulation frame. - assert report["expanding"] is True - assert report["focused"] is True - assert report["collapsed"] is False - assert "c" in report["afterFocus"], "the entity is still not on the canvas" - assert report["collapses"][-1] is False, "the dashboard was never told the view expanded" - - -@requires_node -def test_revealing_a_graph_fact_centers_the_rendered_entity_without_a_fit_race() -> None: - """A Graph facts row must reveal one stable entity, not restart and fit a subgraph. - - The camera must use the coordinates ForceGraph is currently painting. That avoids stale - raw-node coordinates and, by cancelling pending ``zoomToFit``, prevents the delayed global - fit that used to pull the selected entity off-screen after the row click. - """ - report = _run_engine( - """ - const api = G.create(el, { reducedMotion: () => true }); - api.setData({ - nodes: [{ id: 'a' }, { id: 'selected' }, { id: 'c' }], - links: [{ source: 'a', target: 'selected' }, { source: 'selected', target: 'c' }], - }); - const seeded = calls.graphData; - // Deliberately differ from raw data: `reveal` must follow what the canvas renders. - store.graphData = { nodes: [{ id: 'selected', x: 37, y: -53 }], links: [] }; - const revealed = api.reveal('selected'); - emit({ - revealed, seeded, after: calls.graphData, - centerAt: store.centerAt, zoom: store.zoom, - fits: calls.zoomToFit || 0, - }); - """ - ) - assert report["revealed"] is True - assert report["after"] == report["seeded"], "revealing a fact reseeded the graph" - assert report["centerAt"] == [37, -53, 0] - assert report["zoom"] == [3, 0] - assert report["fits"] == 0, "a global fit competed with the selected-node camera move" - - -@requires_node -def test_appearance_only_changes_do_not_restart_the_layout() -> None: - """Style, Color by, Labels and Flow repaint the graph; they must not re-run it. - - ``visible()`` allocates fresh arrays on every call, and force-graph treats any ``graphData`` - call as a data update: it re-copies the nodes and d3 resets the simulation alpha to 1. So - every appearance-only setter threw the settled layout away and made the whole graph move. - The classic renderer guards the same seed with ``if(dataChanged)FG.graphData(data)``. - """ - report = _run_engine( - """ - const api = G.create(el, { reducedMotion: () => true }); - const nodes = [{ id: 'lonely', etype: 'organization' }], links = []; - for (let i = 0; i < 12; i++) nodes.push({ id: 'n' + i, etype: 'person_or_concept' }); - for (let i = 0; i < 11; i++) links.push({ source: 'n' + i, target: 'n' + (i + 1) }); - api.setData({ nodes, links }); - const seeded = calls.graphData; - const before = store.graphData.nodes[0].color; - const repaintsBefore = calls.nodeCanvasObject; - - api.setStyle('galaxy'); - api.setColorBy('type'); - api.setSettings({ labels: true }); - api.setSettings({ flow: false }); - const paintOnly = calls.graphData; - const recoloured = store.graphData.nodes[0].color; - const repaintsAfter = calls.nodeCanvasObject; - - // A genuine change to the visible set still has to reach force-graph. - api.setScope({ showUnlinked: false, minDegree: 1 }); - emit({ - seeded, paintOnly, afterScope: calls.graphData, before, recoloured, - repaintsBefore, repaintsAfter, shown: store.graphData.nodes.length, - }); - """ - ) - assert report["paintOnly"] == report["seeded"], "an appearance change restarted the layout" - assert report["afterScope"] > report["seeded"], "a real view change never reached the canvas" - assert report["shown"] == 12 - # Skipping the reseed must not mean skipping the paint. - assert report["recoloured"] != report["before"] - assert report["repaintsAfter"] > report["repaintsBefore"] - - -@requires_node -def test_simulation_time_is_bounded_on_a_large_graph() -> None: - """force-graph's default cooldown is 15 seconds; nothing here was overriding it. - - The classic path caps a large graph at 1.1s / 80 ticks precisely because running the layout - — and therefore repainting every node and link — for the full default window is what makes a - big store feel broken on load and after every reheat. - """ - report = _run_engine( - """ - const api = G.create(el, {}); - api.setPreset('compact'); - api.setData(chain(40)); - const small = { - time: store.cooldownTime, ticks: store.cooldownTicks, warmup: store.warmupTicks, - alpha: store.d3AlphaDecay, velocity: store.d3VelocityDecay, - }; - // 3001 entities / 3000 relations — past the classic renderer's 600-node signal. - api.setData(chain(3000)); - const big = { - time: store.cooldownTime, ticks: store.cooldownTicks, warmup: store.warmupTicks, - alpha: store.d3AlphaDecay, velocity: store.d3VelocityDecay, - }; - const frozen = G.create(el, { reducedMotion: () => true }); - frozen.setData(chain(40)); - frozen.freeze(true); - emit({ - small, big, - frozen: { time: store.cooldownTime, ticks: store.cooldownTicks }, - }); - """ - ) - assert report["small"]["time"] == 2200 - assert report["small"]["ticks"] == 160 - # The number this guards: the vendor default left a 3k-relation store simulating for 15s. - assert report["big"]["time"] == 1100 - assert report["big"]["ticks"] == 80 - assert report["big"]["warmup"] == 18 - # A large graph also settles harder, exactly as GPERF.large does on the classic path. - assert report["big"]["alpha"] > report["small"]["alpha"] - assert report["big"]["velocity"] > report["small"]["velocity"] - # Freeze, not the OS visual-motion preference, is the explicit static-layout control. - assert report["frozen"]["time"] == 0 - assert report["frozen"]["ticks"] == 0 - - -@requires_node -def test_physics_sliders_reheat_the_simulation_the_way_the_classic_renderer_does() -> None: - """Installing a new force on a settled graph moves nothing without a reheat. - - ``graphSet`` (dashboard.js) routes Repel/Link/Gravity/Size/Font/Link-width/Label-density - through ``setSettings`` under ``?graph-engine=next``. The classic branch of that same - function treats ``repel|link|gravity|size`` as *layout* changes: it re-applies the forces - and then reheats unless the user explicitly froze the graph. The engine's ``applyForces()`` - only swaps the charge/link/forceX-forceY/collide values into the running simulation — and a - settled graph sits at alpha~0 — so without the reheat those four sliders are inert until - the user finds the Reheat button. The paint-only settings must *not* reheat: restarting - the layout because a label got bigger throws away the arrangement the user is reading. - """ - report = _run_engine( - """ - const reheats = () => invocations.d3ReheatSimulation || 0; - const bump = (api, patch) => { const before = reheats(); api.setSettings(patch); return reheats() - before; }; - - const api = G.create(el, {}); - api.setPreset('compact'); - api.setData(chain(40)); - const layout = { - repel: bump(api, { repel: 260 }), - link: bump(api, { link: 90 }), - gravity: bump(api, { gravity: 12 }), - size: bump(api, { size: 5 }), - mode: bump(api, { mode: 'radial' }), - }; - const paint = { - font: bump(api, { font: 11 }), - linkw: bump(api, { linkw: 2.4 }), - labelDensity: bump(api, { labelDensity: 40 }), - labels: bump(api, { labels: true }), - flow: bump(api, { flow: false }), - }; - - const reduced = G.create(el, { reducedMotion: () => true }); - reduced.setPreset('compact'); - reduced.setData(chain(40)); - const reducedMotion = bump(reduced, { repel: 260 }); - emit({ layout, paint, reducedMotion }); - """ - ) - # The four sliders the classic renderer calls a layout change, plus the preset itself. - assert report["layout"] == { - "repel": 1, "link": 1, "gravity": 1, "size": 1, "mode": 1 - }, "a physics slider installed new forces on a settled graph and nothing moved" - # Appearance-only settings keep the arrangement the user is looking at. - assert report["paint"] == { - "font": 0, "linkw": 0, "labelDensity": 0, "labels": 0, "flow": 0 - }, "an appearance change restarted the layout" - assert report["reducedMotion"] == 1, "reduced motion silently disabled live physics" - - -@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. - - The screenshot regression occurred at a few thousand relationships: the UI showed a - centre-gravity value, but the full-graph branch had removed every D3 force and fixed every - node's coordinates. It is safe to run a bounded simulation at this size, so the same - centre force and reheat contract as Overview must remain observable in Full mode. - """ - report = _run_engine( - """ - const axes = { x: [], y: [] }; - const bodyForce = () => ({ strength(value) { this.value = value; return this; } }); - globalThis.d3 = { - forceManyBody: bodyForce, - forceLink: () => ({ id(value) { this.idValue = value; return this; }, distance(value) { this.value = value; return this; } }), - forceX: target => { const force = { target, strength(value) { this.value = value; return this; } }; axes.x.push(force); return force; }, - forceY: target => { const force = { target, strength(value) { this.value = value; return this; } }; axes.y.push(force); return force; }, - forceCollide: () => ({ iterations(value) { this.value = value; return this; } }), - }; - const api = G.create(el, {}); - api.setPreset('compact'); - api.setRenderMode('full'); - // Keep this below the responsive full-graph ceiling. Larger full graphs deliberately - // take the deterministic, centred layout so a complete workspace cannot lock the UI. - api.setData(chain(400)); - api.setSettings({ gravity: 98 }); - const nodes = store.graphData.nodes; - emit({ - mode: api.state().renderMode, - x: { target: typeof axes.x.at(-1).target === 'function' ? axes.x.at(-1).target(nodes[0]) : axes.x.at(-1).target, value: axes.x.at(-1).value }, - y: { target: typeof axes.y.at(-1).target === 'function' ? axes.y.at(-1).target(nodes[0]) : axes.y.at(-1).target, value: axes.y.at(-1).value }, - reheat: invocations.d3ReheatSimulation || 0, - cooldown: store.cooldownTime, - pinned: nodes.filter(node => node.fx !== undefined || node.fy !== undefined).length, - }); - """ - ) - assert report["mode"] == "full" - assert report["x"] == {"target": 0, "value": 0.98} - assert report["y"] == {"target": 0, "value": 0.98} - assert report["reheat"] == 0, "soft alpha updates must not invoke the unbounded full reheat path" - assert report["cooldown"] == 1100 - assert report["pinned"] == 0 - - -@requires_node -def test_full_graph_beyond_responsive_force_budget_is_centred_and_responds_to_gravity() -> None: - """A complete graph past the responsive budget takes the centred static fallback. - - Above the live-force ceiling the deterministic layout protects responsiveness. Its - geometry is nevertheless a centred grid whose compactness follows the same gravity input, - so the user retains a meaningful correction even for a very large workspace. - """ - report = _run_engine( - """ - const span = nodes => Math.max(...nodes.map(node => node.x)) - Math.min(...nodes.map(node => node.x)); - const api = G.create(el, {}); - api.setPreset('compact'); - api.setRenderMode('full'); - // `chain` supplies N+1 nodes, so this is one past the live-force ceiling. - api.setData(chain(600)); - const before = span(store.graphData.nodes); - const reheatBefore = invocations.d3ReheatSimulation || 0; - api.setSettings({ gravity: 400 }); - const nodes = store.graphData.nodes; - emit({ - before, after: span(nodes), - reheat: (invocations.d3ReheatSimulation || 0) - reheatBefore, - pinned: nodes.filter(node => Number.isFinite(node.fx) && Number.isFinite(node.fy)).length, - total: nodes.length, - cooldown: store.cooldownTime, - }); - """ - ) - assert report["after"] < report["before"] * 0.5 - assert report["reheat"] == 0 - assert report["pinned"] == report["total"] == 601 - assert report["cooldown"] == 0 - - -@requires_node -def test_curves_arrows_and_relation_labels_are_dropped_on_a_dense_graph() -> None: - """Three per-edge costs the classic path turns off past ``GPERF.dense`` (links > 1500). - - A curved link is a quadratic bezier instead of a straight line, an arrowhead is a filled - triangle, and a relation label is a text layout — each per relation, each every frame. At - this density they are unreadable anyway, so the classic renderer pays for none of them. - """ - report = _run_engine( - LAY_OUT - + """ - const api = G.create(el, { reducedMotion: () => true }); - api.setSettings({ labels: true }); - - api.setData(chain(1500)); - const atLimit = { - curve: store.linkCurvature, arrow: store.linkDirectionalArrowLength, - }; - - api.setData(chain(1501)); - const overLimit = { - curve: store.linkCurvature, arrow: store.linkDirectionalArrowLength, - }; - // One laid-out relation is enough to drive the label painter at this size. - const data = layOut(); - data.links[0].label = 'mentions'; - const denseUnhighlighted = paintLinks(4, [data.links[0]]); - store.onNodeHover(data.nodes[0]); - const denseHighlighted = paintLinks(4, [data.links[0]]); - emit({ atLimit, overLimit, denseUnhighlighted, denseHighlighted }); - """ - ) - # 1500 links is the classic threshold itself, so nothing is dropped yet. - assert report["atLimit"]["curve"] == 0.12 - assert report["atLimit"]["arrow"] == 0.625 - assert report["overLimit"]["curve"] == 0 - assert report["overLimit"]["arrow"] == 0 - # Relation labels come back for the one neighbourhood the user is actually pointing at. - assert report["denseUnhighlighted"] == [] - assert report["denseHighlighted"] == ["mentions"] - - -#: A ``d3`` stand-in for the force constructors ``applyForces()`` reaches for. The asset reads -#: ``d3`` as a free variable, so assigning it on ``globalThis`` is what the browser's global -#: script tag does; without it ``applyForces()`` returns before it ever configures collision. -D3_STUB = """ -let collide = null; -globalThis.d3 = { - forceX: () => ({ strength: () => ({}) }), - forceY: () => ({ strength: () => ({}) }), - forceRadial: () => ({ strength: () => ({}) }), - forceCollide: radius => ({ radius, iterations(n) { collide = { radius, iterations: n }; return this; } }), -}; -""" - - -@requires_node -def test_layout_presets_use_distinct_force_geometry() -> None: - """Each layout button must install a visibly different arrangement strategy.""" - - for dashboard in (DASHBOARD, CLASSIC_DASHBOARD): - classic_forces = dashboard.read_text(encoding="utf-8") - forces = classic_forces[classic_forces.index("function graphApplyForces()") : classic_forces.index("function graphSetHighlight(")] - assert "if(mode==='communities')" in forces - assert "else if(mode==='radial'&&d3.forceRadial)" in forces - assert "else if(mode==='constellation')" in forces - - report = _run_engine( - """ - const targets = { x: [], y: [], radial: [] }; - const force = target => ({ target, strengthValue: null, strength(value) { - if (arguments.length) { this.strengthValue = value; return this; } - return this.strengthValue; - } }); - globalThis.d3 = { - forceX: target => { targets.x.push(target); return force(target); }, - forceY: target => { targets.y.push(target); return force(target); }, - forceRadial: target => { targets.radial.push(target); return force(target); }, - forceCollide: () => ({ iterations: () => ({}) }), - }; - const api = G.create(el, { reducedMotion: () => true }); - api.setData({ - nodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }, { id: 'e' }, { id: 'f' }], - links: [ - { source: 'a', target: 'b' }, { source: 'a', target: 'c' }, { source: 'a', target: 'd' }, - { source: 'e', target: 'f' }, - ], - }); - const read = mode => { - targets.x = []; targets.y = []; targets.radial = []; - api.setPreset(mode); - const xForce = store.d3Forces.x, radialForce = store.d3Forces.radial; - const nodes = store.graphData.nodes; - const point = node => typeof xForce.target === 'function' ? xForce.target(node) : xForce.target; - return { - xKind: typeof xForce.target, - xStrength: xForce.strengthValue, - first: point(nodes[0]), - second: point(nodes[nodes.length - 1]), - radial: radialForce ? radialForce.target(nodes[0]) : null, - radialOuter: radialForce ? radialForce.target(nodes[nodes.length - 1]) : null, - }; - }; - emit({ - compact: read('compact'), original: read('original'), communities: read('communities'), - radial: read('radial'), constellation: read('constellation'), - }); - """ - ) - assert report["compact"]["first"] == 0 - assert report["original"]["first"] == 0 - assert report["compact"]["xStrength"] > report["original"]["xStrength"] - # Communities mode keeps a gentle origin-based centering: a function target at a - # distant grid slot would fight an explicit drag (the e2e drag-release contract), - # so the mode's visible grouping comes from the charge/repel geometry instead. - assert report["communities"]["xKind"] == "number" - assert report["communities"]["first"] == 0 - assert report["radial"]["radial"] is not None - assert report["radial"]["radial"] < report["radial"]["radialOuter"] - assert report["constellation"]["xKind"] == "function" - assert report["constellation"]["first"] != 0 - - -@requires_node -def test_collision_runs_one_pass_on_a_large_graph_like_the_classic_renderer() -> None: - """``forceCollide().iterations(2)`` is a second full quadtree traversal per node per tick. - - ``graphApplyForces()`` on the classic path spends it only when it is affordable - (``.iterations(GPERF.large?1:2)``). The opt-in engine computes the same ``large`` signal for - its cooldown and alpha-decay constants but was pinning two iterations regardless, so the one - case where the extra pass hurts most — the initial layout and every reheat of a big store — - was the case that paid for it twice over. - """ - report = _run_engine( - D3_STUB - + """ - const api = G.create(el, { reducedMotion: () => true }); - api.setPreset('compact'); - - api.setData(chain(40)); - const small = collide.iterations; - - // 601 entities / 600 relations — one past the classic renderer's 600-node cutoff. - api.setData(chain(600)); - const big = collide.iterations; - - // A slider move re-runs applyForces() on the running simulation; it must not undo this. - api.setSettings({ repel: 90 }); - const afterSlider = collide.iterations; - emit({ small, big, afterSlider, radiusIsAFunction: typeof collide.radius === 'function' }); - """ - ) - assert report["small"] == 2 - assert report["big"] == 1, "a large graph still runs two collision passes per tick" - assert report["afterSlider"] == 1, "a slider move restored the expensive collision pass" - # Guards the whole call rather than the argument in isolation: a per-node radius, not a - # constant, is what makes collision agree with the sizes the renderer actually painted. - assert report["radiusIsAFunction"] is True - - -#: Counts the gradient and blur primitives independently. They are per node, per frame, so the -#: large-graph branch must never rebuild them hundreds of times during a layout tick. -GLOW_CANVAS_STUB = """ -let gradients = 0, blurs = 0, fills = 0; -const ctx = { - globalAlpha: 1, globalCompositeOperation: '', strokeStyle: '', lineWidth: 1, font: '', - textBaseline: '', shadowColor: '', - set shadowBlur(v) { if (v) blurs += 1; }, - get shadowBlur() { return 0; }, - set fillStyle(v) {}, get fillStyle() { return ''; }, - save() {}, restore() {}, beginPath() {}, arc() {}, ellipse() {}, stroke() {}, - setLineDash() {}, fillText() {}, - fill() { fills += 1; }, - createRadialGradient() { gradients += 1; return { addColorStop() {} }; }, - createLinearGradient() { gradients += 1; return { addColorStop() {} }; }, -}; -const paintNodes = () => { - gradients = 0; blurs = 0; fills = 0; - const draw = store.nodeCanvasObject; - store.graphData.nodes.forEach((n, i) => { n.x = i * 10; n.y = i; draw(n, ctx, 4); }); - return { gradients, blurs, fills }; -}; -""" - - -@requires_node -@pytest.mark.parametrize("style", ["galaxy", "solar"]) -def test_per_node_glow_is_dropped_on_a_large_graph(style: str) -> None: - """Every ``rich`` node was getting a bloom or a gradient on every frame, at any size. - - The classic renderer gates all three of them on ``!GPERF.large`` — the galaxy halo, the solar - corona and its sphere shading. A radial gradient is a fresh object per node; at the >600-node - cutoff that is hundreds rebuilt per tick, on top of the layout, which is what made a dense - workspace crawl even after the other large-graph optimisations kicked in. - - ``fills`` is the control: the nodes are still being drawn, so a zero glow count means the - effect was skipped, not that the paint never ran. - """ - report = _run_engine( - GLOW_CANVAS_STUB - + f""" - const api = G.create(el, {{ reducedMotion: () => true }}); - api.setStyle("{style}"); - - api.setData(chain(40)); - const small = paintNodes(); - - api.setData(chain(600)); - const big = paintNodes(); - emit({{ small, big }}); - """ - ) - small, big = report["small"], report["big"] - assert small["fills"] > 0 and big["fills"] > 0, "canvas stub never reached the node painter" - assert small["gradients"] + small["blurs"] > 0, "the small graph lost its glow entirely" - assert big["gradients"] == 0, f"{style} still builds a radial gradient per node when large" - assert big["blurs"] == 0, f"{style} still shadow-blurs every node when large" - - -@requires_node -def test_material_recipes_keep_four_fixed_families_and_only_react_at_the_edges() -> None: - """A graph palette is an identity accent, not a licence to repaint every alloy the same. - - This replaces the old gradient-stop counts: those merely documented one shared thin-film - painter. The pure recipe seam makes the intended material contract directly testable. - """ - report = _run_node( - """ - const slate = { accent: '#a39bf1', surface: '#16191f', canvas: '#0b0d13' }; - const matrix = { accent: '#3ce072', surface: '#04140a', canvas: '#020703' }; - const make = (theme, palette, identity) => Object.fromEntries( - ['cyber', 'galaxy', 'solar', 'classic'].map(style => - [style, I.materialRecipe(style, theme, palette, identity)])); - emit({ slate: make(slate, 'ocean', '#37bde4'), matrix: make(matrix, 'ember', '#f59e55') }); - """ - ) - slate, matrix = report["slate"], report["matrix"] - assert {recipe["family"] for recipe in slate.values()} == { - "iridescent-pvd", "anodized-alloy", "brushed-copper", "satin-gunmetal" - } - assert slate["cyber"]["film"] == slate["cyber"]["fixedPalette"] - assert len(slate["cyber"]["film"]) >= 4 - # Fixed material signatures survive a theme/palette switch; only the substrate/identity - # inputs may react. Solar must never inherit Cyber's cyan/magenta spectrum. - for style in slate: - assert slate[style]["family"] == matrix[style]["family"] - assert slate[style]["fixedPalette"] == matrix[style]["fixedPalette"] - assert slate[style]["substrate"] != matrix[style]["substrate"] - assert slate[style]["identity"] != matrix[style]["identity"] - assert "#19d8ed" not in {value.lower() for value in slate["solar"]["fixedPalette"]} - - -@requires_node -def test_material_tiers_are_screen_space_not_graph_size_heuristics() -> None: - report = _run_node( - """ - emit({ - tiny: I.materialTier(4), bezel: I.materialTier(8), full: I.materialTier(16), - exactLow: I.materialTier(5.99), exactBezel: I.materialTier(6), - exactFull: I.materialTier(12), forced: I.materialTier(32, true), - }); - """ - ) - assert report == { - "tiny": "signature", "bezel": "bezel", "full": "full", - "exactLow": "signature", "exactBezel": "bezel", "exactFull": "full", - "forced": "signature", - } - - -@requires_node -def test_galaxy_parent_bodies_keep_full_material_without_promoting_small_systems_to_stars() -> None: - report = _run_node( - """ - const gradient = () => ({ addColorStop() {} }); - const ctx = { - save() {}, restore() {}, beginPath() {}, closePath() {}, arc() {}, fill() {}, stroke() {}, - moveTo() {}, lineTo() {}, drawImage() {}, scale() {}, - createLinearGradient: gradient, createRadialGradient: gradient, - createConicGradient: gradient, setLineDash() {}, - globalAlpha: 1, globalCompositeOperation: 'source-over', - lineWidth: 1, fillStyle: '', strokeStyle: '', shadowBlur: 0, shadowColor: '', - }; - I.setMaterialCanvasFactory(() => null); - const recipe = I.materialRecipe( - 'solar', { accent: '#a39bf1', surface: '#16191f' }, 'ember', '#d78242' - ); - const lanes = [ - { anchorId: 'star', members: 3 }, - { anchorId: 'planet-with-moon', members: 1 }, - { anchorId: 'leaf', members: 0 }, - ]; - emit({ - parentTier: I.paintMaterialSurface(ctx, 0, 0, 4, 1, recipe, true, true), - leafTier: I.paintMaterialSurface(ctx, 0, 0, 4, 1, recipe, true, false), - primaries: [...I.galaxyPrimaryAnchorIds(lanes)].sort(), - stars: [...I.galaxyStarAnchorIds(lanes)].sort(), - }); - """ - ) - - assert report == { - "parentTier": "full", - "leafTier": "signature", - "primaries": ["planet-with-moon", "star"], - "stars": ["star"], - } - source = ASSET.read_text(encoding="utf-8") - style_node = source[source.index("function styleNode"): - source.index("function paintNodeLabel")] - assert "materialLow, galaxyPrimary" in style_node - assert "materialLow, true" in style_node - - -@requires_node -def test_material_colour_invariants_are_distinct_and_deterministic() -> None: - """Pin visual intent in RGB rather than vendor-specific gradient primitive counts.""" - report = _run_node( - """ - const theme = { accent: '#a39bf1', surface: '#16191f', canvas: '#0b0d13' }; - const sample = style => ['top', 'center', 'bottom'].map(position => - I.sampleMaterialColour(style, position, '#37bde4', theme)); - emit({ once: Object.fromEntries(['cyber', 'galaxy', 'solar', 'classic'].map(s => [s, sample(s)])), - twice: Object.fromEntries(['cyber', 'galaxy', 'solar', 'classic'].map(s => [s, sample(s)])) }); - """ - ) - assert report["once"] == report["twice"], "static materials must not rotate or flicker" - cyber_top, _, cyber_bottom = report["once"]["cyber"] - galaxy = report["once"]["galaxy"][1] - solar = report["once"]["solar"][1] - classic = report["once"]["classic"][1] - assert cyber_top[0] > cyber_bottom[0] and cyber_bottom[1] > cyber_top[1], ( - "Cyber must retain the fixed warm/magenta-top, cyan-lower iridescent direction" - ) - assert galaxy[2] > galaxy[0] and galaxy[2] > galaxy[1], "Galaxy must read blue/violet" - assert solar[0] > solar[1] > solar[2], "Solar must read as warm copper, never cyan" - assert max(classic[:3]) - min(classic[:3]) <= 55, "Classic must remain low-saturation steel" - - -@requires_node -def test_material_cache_is_bounded_and_warm_repaints_allocate_nothing() -> None: - report = _run_node( - """ - const gradient = () => ({ addColorStop() {} }); - const ctx = { - save() {}, restore() {}, beginPath() {}, closePath() {}, arc() {}, fill() {}, stroke() {}, - clearRect() {}, fillRect() {}, translate() {}, rotate() {}, scale() {}, clip() {}, - createLinearGradient: gradient, createRadialGradient: gradient, createConicGradient: gradient, - setLineDash() {}, drawImage() {}, globalAlpha: 1, globalCompositeOperation: 'source-over', - lineWidth: 1, fillStyle: '', strokeStyle: '', shadowBlur: 0, shadowColor: '', - }; - I.setMaterialCanvasFactory(() => ({ width: 0, height: 0, getContext: () => ctx })); - I.clearMaterialCache(true); - const options = { style: 'cyber', radius: 16, dpr: 2, - identity: '#37bde4', themeColors: { accent: '#a39bf1', surface: '#16191f' } }; - I.renderMaterialSample(options); - const cold = I.materialCacheStats(); - I.renderMaterialSample(options); - const warm = I.materialCacheStats(); - for (let n = 0; n < cold.limit + 3; n += 1) { - I.renderMaterialSample({ ...options, identity: '#' + n.toString(16).padStart(6, '0') }); - } - const saturated = I.materialCacheStats(); - I.setMaterialCanvasFactory(null); - emit({ cold, warm, saturated }); - """ - ) - assert report["cold"]["allocations"] == 1 - assert report["warm"]["allocations"] == report["cold"]["allocations"] - assert report["warm"]["hits"] > report["cold"]["hits"] - assert report["saturated"]["size"] <= report["saturated"]["limit"] - assert report["saturated"]["evictions"] > 0 - - -@requires_node -def test_material_cache_is_invalidated_by_theme_palette_style_and_dpr_changes() -> None: - report = _run_engine( - """ - const gradient = () => ({ addColorStop() {} }); - const ctx = { - save() {}, restore() {}, beginPath() {}, closePath() {}, arc() {}, fill() {}, stroke() {}, - clearRect() {}, fillRect() {}, translate() {}, rotate() {}, scale() {}, clip() {}, - createLinearGradient: gradient, createRadialGradient: gradient, createConicGradient: gradient, - setLineDash() {}, drawImage() {}, globalAlpha: 1, globalCompositeOperation: 'source-over', - lineWidth: 1, fillStyle: '', strokeStyle: '', shadowBlur: 0, shadowColor: '', - }; - I.setMaterialCanvasFactory(() => ({ width: 0, height: 0, getContext: () => ctx })); - I.clearMaterialCache(true); - const sample = dpr => I.renderMaterialSample({ style: 'cyber', radius: 16, dpr, - identity: '#37bde4', themeColors: { accent: '#a39bf1', surface: '#16191f' } }); - sample(1); const populated = I.materialCacheStats(); - const api = G.create(el, { reducedMotion: () => true }); - api.setData(chain(2)); - api.setThemeColors({ accent: '#3ce072', surface: '#04140a' }); - const themed = I.materialCacheStats(); - sample(1); api.setPalette('ember'); const paletted = I.materialCacheStats(); - sample(1); api.setStyle('solar'); const styled = I.materialCacheStats(); - sample(1); sample(2); const dprChanged = I.materialCacheStats(); - I.setMaterialCanvasFactory(null); - emit({ populated, themed, paletted, styled, dprChanged }); - """ - ) - assert report["populated"]["size"] > 0 - for name in ("themed", "paletted", "styled"): - assert report[name]["size"] == 0, f"{name} material update retained stale sprites" - assert report["dprChanged"]["size"] == 1 - assert report["dprChanged"]["clears"] >= 4 - - -@requires_node -def test_material_fallback_without_conic_gradient_still_paints() -> None: - report = _run_node( - """ - const gradient = () => ({ addColorStop() {} }); - let fills = 0; - const ctx = { - save() {}, restore() {}, beginPath() {}, closePath() {}, arc() {}, stroke() {}, - fill() { fills += 1; }, clearRect() {}, fillRect() {}, translate() {}, rotate() {}, clip() {}, - createLinearGradient: gradient, createRadialGradient: gradient, - lineWidth: 1, fillStyle: '', strokeStyle: '', globalAlpha: 1, shadowBlur: 0, shadowColor: '', - }; - const recipe = I.materialRecipe('cyber', { accent: '#a39bf1', surface: '#16191f' }, 'ocean', '#37bde4'); - I.paintMaterialDirect(ctx, 20, 20, 16, recipe, 'full'); - emit({ fills }); - """ - ) - assert report["fills"] > 0 - - -@requires_node -@pytest.mark.parametrize("style", ["cyber", "galaxy", "solar", "classic"]) -def test_all_metal_styles_keep_the_large_graph_canvas_path_cheap(style: str) -> None: - """Material richness must not turn into a per-node shader workload above the cutoff.""" - report = _run_engine( - GLOW_CANVAS_STUB - + f""" - const api = G.create(el, {{ reducedMotion: () => true }}); - api.setStyle('{style}'); - api.setData(chain(600)); - emit(paintNodes()); - """ - ) - assert report["fills"] > 0 - assert report["gradients"] == 0, f"{style} creates per-node gradients in a large graph" - assert report["blurs"] == 0, f"{style} creates per-node blur in a large graph" - - -def test_legacy_classic_canvas_uses_the_same_nonwhite_material_profiles_as_ledger() -> None: - """Classic's no-flag renderer is distinct from Ledger's engine and must not drift. - - The user can switch between Ledger and `/classic`, while Classic also retains a direct - force-graph path for installations that do not opt into the newer engine. Both copies need - the material profile rather than Classic silently returning to white-centred flat discs. - """ - def material_block(path: Path) -> str: - source = path.read_text(encoding="utf-8") - start = source.index("function graphRgb(") - return source[start:source.index("function graphApplyStyleChrome()", start)] - - static = material_block(DASHBOARD) - classic = material_block(CLASSIC_DASHBOARD) - assert static == classic, "the classic dashboard material painter drifted from its fallback" - assert "function graphMaterialProfile(style,col)" in classic - assert "function graphPaintMaterialSurface(" in classic - assert "function graphMaterialTier(" in classic - assert "function graphMaterialSprite(" in classic - assert "graphMaterialProfile('cyber',col)" in classic - assert "graphMaterialProfile('galaxy',col)" in classic - assert "graphMaterialProfile('solar'" in classic - assert "graphMaterialProfile('classic',col)" in classic - assert "GRAPH_MATERIAL_CACHE_LIMIT=192" in classic - assert "ctx.drawImage(sprite.canvas" in classic - assert "#eafcff" not in classic - assert "rgba(255,255,255" not in classic - assert "graphIridescent(" not in classic - for marker in ( - "family:'iridescent-pvd'", - "family:'anodized-alloy'", - "family:'brushed-copper'", - "family:'satin-gunmetal'", - ): - assert marker in classic - assert marker.replace(":'", ": '") in ASSET.read_text(encoding="utf-8") - # The fallback selects the gradient-free signature recipe before building/painting a - # sprite, so hundreds of nodes keep their material identity without per-node shaders. - paint = classic[ - classic.index("function graphPaintMaterialSurface("): - classic.index("function graphStyleBackground(") - ] - assert "graphMaterialTier(screenRadius,large)" in paint - assert "paintDirect&&tier==='full'&&screenRadius>GRAPH_MATERIAL_RADIUS.full" in paint - assert "directMaterial=node.id===GHILITE||node.rank===0" in classic - full_classic = CLASSIC_DASHBOARD.read_text(encoding="utf-8") - style_node = full_classic[full_classic.index("function graphStyleNode("):full_classic.index("function graphApplyStyleChrome()")] - assert "graphPaintMaterialSurface(ctx,node.x,node.y,r,scale,profile,GPERF.large,directMaterial)" in style_node - assert "graphPaintMaterialSurface(ctx,node.x,node.y,r,scale,profile,GPERF.large)" not in style_node - assert classic.count("if(tier==='signature')") >= 4 - - -def test_legacy_node_geometry_is_bounded_like_ledger_for_all_styles() -> None: - """Classic must not resurrect the degree-squared visual blow-up behind the style switch. - - The material painter is shared across four styles, so a geometry regression here affects - every theme even when the newer Ledger engine is correct. Keep the two legacy copies in - lockstep and pin the compact radius contract: normalized degree emphasis, a 0.8 minimum, - and a size-slider-relative 1.1 maximum. - """ - classic = CLASSIC_DASHBOARD.read_text(encoding="utf-8") - static = DASHBOARD.read_text(encoding="utf-8") - helper_start = classic.index("function graphNodeRadius(") - helper_end = classic.index("const ETYPE_TOKEN", helper_start) - assert static[static.index("function graphNodeRadius("):static.index("const ETYPE_TOKEN", static.index("function graphNodeRadius("))] == classic[helper_start:helper_end] - assert "const maxDegree=Math.max(1,...nodes.map(node=>node.degree||0));" in classic - assert "graphNodeRadius(node,window.GSET.size,(node.degree||0)/maxDegree)" in classic - assert "return Math.max(.8,Math.min(size*1.1,radius));" in classic - assert "Math.sqrt(node.val)" not in classic - assert "Math.sqrt(node.val)" not in static - - - -def test_classic_dashboard_uses_the_every_node_asset_not_the_removed_all_asset() -> None: - """Classic may opt into Every-node, but must not reference the removed asset.""" - source = CLASSIC_DASHBOARD.read_text(encoding="utf-8") - assert "loadAllGraphEngine" in source - assert "ALL_GRAPH_ENGINE_LOADING" in source - assert "EngraphisEveryGraph" in source - assert "engraphis-graph-every.js" in source - assert "EngraphisAllGraph" not in source - assert "engraphis-graph-all.js" not in source - - -def test_classic_graph_controls_have_no_freeze_or_orbit_pause_in_full_mode() -> None: - """Full-mode quality-only: Freeze and orbit-pause controls are hidden; Relation flow remains. - - Classic never enters All mode, so this is a belt-and-braces guard: if the - All-mode concept ever leaks into Classic, the controls must not appear. - """ - source = CLASSIC_DASHBOARD.read_text(encoding="utf-8") - # Relation flow toggle must remain available in Classic. - assert "graph-show-iso" in source or "Show unlinked" in source - - -def test_ledger_recovery_copy_names_reload_data_and_real_filters_only() -> None: - """Recovery UI must say 'Reload data' and name only real, actionable filters.""" - source = PRIMARY_LEDGER.read_text(encoding="utf-8") - assert "Reload data" in source - assert "reload" in source.lower() - # Recovery must not reference phantom filters or placeholder actions. - assert "try something else" not in source.lower() - assert "check your settings" not in source.lower() - - -def test_ledger_renderer_transition_is_transactional_with_candidate_staging() -> None: - """Renderer swaps stage a candidate, await readiness, then atomically commit. - - Failure preserves the prior renderer and mode; success destroys the old one - only after the candidate is live. - """ - source = PRIMARY_LEDGER.read_text(encoding="utf-8") - assert "graph-canvas-candidate" in source - assert "candidateEngine" in source - assert "candidateHost" in source - assert "whenReady" in source - # The old host is retired only after the candidate is confirmed. - assert "graph-canvas-retired" in source - # Failure path restores the prior state. - assert "state.graphEngine.freeze(true)" in source - - -def test_ledger_toggle_labels_are_fixed_with_state_attributes() -> None: - """Toggle buttons keep fixed visible labels; ARIA state carries their value.""" - markup = PRIMARY_INDEX.read_text(encoding="utf-8") - assert 'id="graph-freeze"' in markup - freeze_section = markup.split('id="graph-freeze"', 1)[1][:500] - assert 'role="switch"' in freeze_section - assert 'aria-checked=' in freeze_section - - -def test_force_graph_and_engine_loaders_support_retry_after_failure() -> None: - """A failed asset load must not permanently memoize a rejected promise. - - The retry counter bumps the query string so the next attempt cannot join a - stalled browser request. A successful second load after a first failure must - reach the render loop. - """ - source = PRIMARY_LEDGER.read_text(encoding="utf-8") - loader = source[source.index("function ensureGraphAssets"): - source.index("function showNotice", - source.index("function ensureGraphAssets"))] - # Retry counter advances on failure. - assert "graphAssetsRetry = Math.min(graphAssetsRetry + 1, 10)" in loader - # Stale attempts are released so the next load gets a fresh fetch. - assert "releaseGraphAssetsAttempt" in loader - # The query string incorporates the retry count. - assert "graphAssetSource" in loader or "retry=" in loader - - - -def _community_palettes(source: str) -> dict: - """Parse a ``COMMUNITY_PALS`` literal out of either renderer.""" - # Anchor on the declaration: both files also name the table in prose comments. - match = re.search(r"COMMUNITY_PALS\s*=\s*\{", source) - assert match is not None, "COMMUNITY_PALS is not declared here" - block = source[match.end():source.index("};", match.end())] - return { - name: re.findall(r"#[0-9a-fA-F]{3,8}", body) - for name, body in re.findall(r"(\w+)\s*:\s*\[([^\]]*)\]", block) - } - - -def test_community_colours_match_the_dashboard_and_the_legend_swatches() -> None: - """The cluster legend is painted from CSS, so palette *order* is a contract, not a taste. - - ``graphRenderLegend`` sorts communities by size and gives the largest a - ``.graph-cluster-0`` swatch, while the canvas colours that same community with palette slot - 0. The swatch colours live in ``dashboard.css`` and encode the Cyber palette — the default - style — so a renderer whose slot 0 is a different colour makes the legend describe cluster 1 - with cluster 2's colour, on the default style, for every workspace. - """ - engine = _community_palettes(ASSET.read_text(encoding="utf-8")) - classic = _community_palettes(DASHBOARD.read_text(encoding="utf-8")) - assert engine, "COMMUNITY_PALS could not be parsed out of the engine" - assert engine == classic, "the opt-in renderer paints communities a different colour" - - swatches = dict( - re.findall(r"\.graph-cluster-(\d+)\{background:(#[0-9a-fA-F]{3,8})\}", - CSS.read_text(encoding="utf-8")) - ) - assert swatches, "the cluster legend swatches are missing from the stylesheet" - for index, colour in sorted(swatches.items()): - assert engine["cyber"][int(index)].lower() == colour.lower(), ( - f"legend swatch {index} does not match the canvas colour for that cluster" - ) - - -# ── CSP, styling and lifecycle ────────────────────────────────────────────────────── - - -def test_pane_backgrounds_are_owned_by_css_not_by_the_asset() -> None: - """``style-src-attr 'none'`` forbids writing these onto the element.""" - css = CSS.read_text(encoding="utf-8") - source = ASSET.read_text(encoding="utf-8") - for style in ("galaxy", "solar", "cyber"): - assert f'#graph-net[data-graph-style="{style}"]' in css - assert "data-graph-style" in source - # The gradients must exist in exactly one place, or the two copies drift. - assert "radial-gradient" not in source - assert "linear-gradient" not in source - - -def test_hover_cursor_class_the_asset_toggles_exists_in_css() -> None: - css = CSS.read_text(encoding="utf-8") - source = ASSET.read_text(encoding="utf-8") - assert "engraphis-graph-node-hover" in source - assert ".engraphis-graph-node-hover" in css - - -def test_csp_gate_covers_the_graph_asset() -> None: - from scripts.externalize_dashboard_assets import EXTRA_SCRIPTS, check - - assert ASSET in EXTRA_SCRIPTS, "the graph engine must be inside the CSP drift gate" - check() - - -def test_engine_exposes_a_teardown_and_the_dashboard_drives_it() -> None: - source = ASSET.read_text(encoding="utf-8") - dashboard = DASHBOARD.read_text(encoding="utf-8") - for member in ("api.destroy", "api.pause", "api.resume", "api.resize"): - assert member in source - # force-graph keeps a rAF alive while resumed; leaving the view must park it. - assert "if(v==='graph')graphEngineResume();else graphEnginePause()" in dashboard - assert "GRAPH_ENGINE.destroy()" in dashboard - - -def test_manual_drag_controller_detaches_with_the_graph() -> None: - """Reopening Ledger must not leave stale pointer controllers on the shared pane.""" - source = ASSET.read_text(encoding="utf-8") - assert "let detachManualDrag = null;" in source - assert "el.addEventListener('pointerdown', beginManualDrag, true);" in source - assert "el.removeEventListener('pointerdown', beginManualDrag, true);" in source - assert "window.removeEventListener('pointermove', moveManualDrag, true);" in source - assert "event.type !== 'pointercancel'" in source - direct_click = source[source.index("} else if (event.type !== 'pointercancel') {"):] - direct_click = direct_click[:direct_click.index(" };", 1)] - assert direct_click.index("handleNodeClick(current.node);") < direct_click.index("suppressNodeClick();") - move = source[source.index("const moveManualDrag = event => {"):] - move = move[:move.index(" const beginManualDrag", 1)] - assert "if (!manualDrag.dragged)" in move - assert move.index("if (Math.hypot(dx, dy) < 3)") < move.index("const node = manualDrag.node;") - assert "node.x = node.fx = point.x + manualDrag.offsetX;" in move - assert "node.vx = 0;" not in move - begin = source[source.index("function beginNodeDrag(node) {"): - source.index("function finishNodeDrag(node) {")] - assert "node.vx = 0;" in begin - assert "node.vy = 0;" not in move - assert "node.vy = 0;" in begin - assert "node.fx = undefined;" in source - assert "node.fy = undefined;" in source - assert "activeDragLinks" not in source - assert "other.vx" not in move - assert "other.vy" not in move - teardown = source[source.index("api.destroy = () => {"):] - assert "if (detachManualDrag) { detachManualDrag(); detachManualDrag = null; }" in teardown - - -def test_graph_physics_updates_are_bounded_and_coalesced() -> None: - """Explicit slider changes coalesce while pointer placement has no wake mechanism.""" - source = ASSET.read_text(encoding="utf-8") - vendor = VENDOR.read_text(encoding="utf-8") - primary_vendor = PRIMARY_VENDOR.read_text(encoding="utf-8") - assert "const MIN_NODE_SPEED = 8;" in source - assert "const MAX_NODE_SPEED = 48;" in source - assert "function makeVelocityGuardForce()" in source - assert "fg.d3Force('velocityGuard', velocityGuardForce);" in source - assert ".enableNodeDrag(false)" in source - assert "node.fx = undefined;" in source - assert "node.fy = undefined;" in source - assert "function schedulePhysicsUpdate()" in source - assert "physicsReheatPending" in source - assert "cancelAutoFit();" in source - assert "function prepareReheat()" in source - assert "function supportsSoftAlpha()" in source - assert "function softReheat()" in source - assert "fg.d3AlphaTarget(SETTINGS_ALPHA_TARGET);" in source - assert "fg.resetCountdown();" in source - assert "softReheat();" in source - assert "DRAG_ALPHA_TARGET" not in source - assert "DRAG_SETTLE_DELAY_MS" not in source - assert "d3AlphaTarget" in vendor and "resetCountdown" in vendor - assert "d3AlphaTarget" in primary_vendor and "resetCountdown" in primary_vendor - - -def test_reduced_motion_is_honoured_by_the_opt_in_renderer() -> None: - source = ASSET.read_text(encoding="utf-8") - dashboard = DASHBOARD.read_text(encoding="utf-8") - assert "prefers-reduced-motion: reduce" in source - assert "opts.reducedMotion" in source - assert "reducedMotion:prefersReducedMotion" in dashboard - - -def test_graph_engine_is_syntactically_valid_when_node_is_installed() -> None: - if NODE is None: - pytest.skip("node is not installed") - result = subprocess.run( - [NODE, "--check", str(ASSET)], - cwd=ROOT, - capture_output=True, - text=True, - check=False, - ) - assert result.returncode == 0, result.stderr - - -@requires_node -def test_repo_scope_is_case_insensitive_and_cached_outside_exports() -> None: - report = _run_engine( - """ - const api = G.create(el, { reducedMotion: () => true }); - api.setPreset('compact'); - api.setData({ - nodes: [ - { id: 'match', repo: 'Owner/Project', name: 'Target' }, - { id: 'other', repo: 'Elsewhere', name: 'Other' }, - ], - links: [{ source: 'match', target: 'other' }], - }); - api.setScope({ repo: ' OWNER/PROJECT ' }); - const exported = api.exportData(); - emit({ ids: exported.nodes.map(node => node.id), - stateRepo: api.state().repo, - serialized: JSON.stringify(exported) }); - """ - ) - assert report["ids"] == ["match"] - assert report["stateRepo"] == "owner/project" - assert "_searchText" not in report["serialized"] - - -@requires_node -def test_hidden_labels_skip_large_scene_ranking_work() -> None: - report = _run_engine( - """ - const api = G.create(el, { reducedMotion: () => true }); - api.setPreset('compact'); - api.setData(chain(120)); - api.setSettings({ labels: false }); - const originalSort = Array.prototype.sort; - let sorts = 0; - Array.prototype.sort = function (...args) { sorts += 1; return originalSort.apply(this, args); }; - api.setStyle('solar'); - const hidden = sorts; - api.setSettings({ labels: true }); - const visible = sorts - hidden; - Array.prototype.sort = originalSort; - emit({ hidden, visible }); - """ - ) - assert report["hidden"] == 0 - assert report["visible"] >= 1 - - -def test_pointer_hit_area_rejects_unpositioned_nodes() -> None: - source = ASSET.read_text(encoding="utf-8") - pointer = source[source.index(".nodePointerAreaPaint((node, color, ctx) => {"):] - pointer = pointer[:pointer.index(" })", 1)] - assert "!Number.isFinite(node.x)" in pointer - assert "!Number.isFinite(node.y)" in pointer - assert "Number.isFinite(node.radius)" in pointer +"""Contract checks for the opt-in browser graph engine (``?graph-engine=next``). + +These tests intentionally stay dependency-light: the dashboard's offline CI floor does +not need a browser or a JavaScript package manager just to validate a shipped static +asset. Where Node is available the asset is *executed* rather than pattern-matched, so +the checks assert behaviour (escaping, bridge detection, stack safety, load-order +independence) instead of the presence of source substrings. + +The properties guarded here are the ones whose failure is silent in a browser: + +* the asset must define its global without touching ``ForceGraph``/``document``, so a + blocked or missing vendor bundle degrades instead of white-screening the dashboard; +* every label crossing into force-graph must be escaped, because force-graph's tooltip + is an ``innerHTML`` sink and entity labels come from ingested memories; +* the client-side graph analysis must not recurse per node or run unbounded work; +* the per-style pane backgrounds must stay in CSS, since the production CSP sets + ``style-src-attr 'none'``. +""" + +from __future__ import annotations + +import json +import math +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +STATIC = ROOT / "engraphis" / "static" +ASSET = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph.js" +EVERY_ASSET = ROOT / "engraphis" / "dashboard_assets" / "engraphis-graph-every.js" +SPACETIME_ASSET = ROOT / "engraphis" / "dashboard_assets" / "engraphis-spacetime.js" +LEGACY_ADAPTER = STATIC / "engraphis-graph.js" +INDEX = STATIC / "index.html" +CSS = STATIC / "dashboard.css" +DASHBOARD = STATIC / "dashboard.js" +CLASSIC_DASHBOARD = ROOT / "engraphis" / "classic_assets" / "dashboard.js" +VENDOR = STATIC / "vendor" / "force-graph.min.js" +PRIMARY_LEDGER = ROOT / "engraphis" / "dashboard_assets" / "ledger.js" +PRIMARY_INDEX = ROOT / "engraphis" / "dashboard_assets" / "index.html" +PRIMARY_CSS = ROOT / "engraphis" / "dashboard_assets" / "ledger.css" +PRIMARY_VENDOR = ROOT / "engraphis" / "dashboard_assets" / "vendor" / "force-graph.min.js" + +NODE = shutil.which("node") +requires_node = pytest.mark.skipif(NODE is None, reason="node is not installed") + +#: Evaluates the asset with nothing but a bare ``window`` object in scope. Any top-level +#: use of a browser or vendor global would raise here, which is the point. +PRELUDE = """ +const fs = require('fs'); +const source = fs.readFileSync(process.argv[1], 'utf8'); +const window = {}; +new Function('window', source)(window); +const G = window.EngraphisGraph; +const I = G._internals; +const emit = value => console.log(JSON.stringify(value)); +""" + + +#: Same, plus a recording stand-in for force-graph so ``create()`` can be *driven*. Every +#: accessor is a chainable setter that returns the stored value when called with no arguments — +#: force-graph's own kapsule semantics — so the paint configuration the engine installs can be +#: read back and invoked instead of pattern-matched. ``calls`` counts the invalidations the +#: engine requests, which is the only observable form a "redraw now" takes. ``invocations`` +#: counts the *argument-less* calls, which under kapsule semantics are the commands rather than +#: the setters — ``d3ReheatSimulation()`` is one, and it has no other observable effect here. +ENGINE_PRELUDE = """ +const fs = require('fs'); +const source = fs.readFileSync(process.argv[1], 'utf8'); +const engineWindowListeners = {}; +const window = { + addEventListener(type, callback) { engineWindowListeners[type] = callback; }, + removeEventListener(type) { delete engineWindowListeners[type]; }, +}; +globalThis.requestAnimationFrame = () => {}; +globalThis.cancelAnimationFrame = () => {}; +const store = {}, calls = {}, invocations = {}; +const fg = new Proxy({}, { + get: (_target, prop) => prop === 'screen2GraphCoords' && typeof store.screen2GraphCoords === 'function' + ? store.screen2GraphCoords + : prop === 'd3Force' ? (function(name, force) { + /* d3Force(name) is a getter and d3Force(name, force) is a setter. Modelling that + distinction keeps the behavioural force tests below honest. */ + if (arguments.length === 1) return store.d3Forces && store.d3Forces[name]; + calls.d3Force = (calls.d3Force || 0) + 1; + store.d3Forces = store.d3Forces || {}; + store.d3Forces[name] = force; + return fg; + }) : (...args) => { + if (!args.length) { invocations[prop] = (invocations[prop] || 0) + 1; return store[prop]; } + calls[prop] = (calls[prop] || 0) + 1; + store[prop] = args.length === 1 ? args[0] : args; + return fg; + }, +}); +globalThis.ForceGraph = () => () => fg; +const elListeners = {}; +const canvas = { getBoundingClientRect() { return { left: 0, top: 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() {} }, + addEventListener(type, callback) { elListeners[type] = callback; }, + removeEventListener(type) { delete elListeners[type]; }, + querySelector(selector) { return selector === 'canvas' ? canvas : null; }, +}; +const chain = count => { + const nodes = [], links = []; + for (let i = 0; i <= count; i++) nodes.push({ id: 'n' + i }); + for (let i = 0; i < count; i++) { + links.push({ source: 'n' + i, target: 'n' + (i + 1), layer: 'semantic' }); + } + return { nodes, links }; +}; +new Function('window', source)(window); +const G = window.EngraphisGraph; +const I = G._internals; +const emit = value => console.log(JSON.stringify(value)); +""" + + +def _run_node(script: str, prelude: str = PRELUDE) -> object: + 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]) + + +def _run_engine(script: str) -> object: + return _run_node(script, prelude=ENGINE_PRELUDE) + + +def _run_spacetime_node(script: str) -> object: + """Execute the independently loaded canvas-only spacetime renderer in a tiny DOM.""" + prelude = """ +const fs = require('fs'); +const source = fs.readFileSync(process.argv[1], 'utf8'); +const emit = value => console.log(JSON.stringify(value)); +""" + result = subprocess.run( + [NODE, "-e", prelude + script, str(SPACETIME_ASSET)], + 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. + + force-graph applies inline styles at runtime, so under the production CSP + (``style-src 'self'``) every page load that fetched it reported a violation per attempt — + including the pages that never open the graph. + """ + html = INDEX.read_text(encoding="utf-8") + eager = re.findall(r']+src=["\'](/static/[^"\']+)["\']', html) + assert "/static/vendor/d3.min.js" in eager + assert any( + re.fullmatch(r"/static/dashboard\.js\?v=[A-Za-z0-9._-]+", item) + for item in eager + ) + assert "/static/vendor/force-graph.min.js" not in eager + assert "/static/engraphis-graph.js" not in eager + + +def test_every_node_visibility_response_refreshes_webgl_node_buffers() -> None: + """Worker LOD responses must repaint nodes, not only their edge buffers. + + The Every-node renderer keeps one GPU position buffer per node and represents hidden nodes + in the node metadata buffer. This contract test protects the ordering in the ready-message + handler without requiring a WebGL context in the offline test floor. + """ + source = EVERY_ASSET.read_text(encoding="utf-8") + start = source.index("if (message.type === 'preview' || message.type === 'ready')") + end = source.index("if (message.type === 'progress')", start) + handler = source[start:end] + assert "refreshVisibility(false);" in handler + assert "uploadNodePositions();" in handler + assert "uploadEdges();" in handler + assert handler.index("uploadNodePositions()") < handler.index("uploadEdges()") + + +def test_v1_graph_asset_is_only_a_compatibility_adapter() -> None: + """New renderer code stays on the v2 dashboard surface, not the legacy server.""" + adapter = LEGACY_ADAPTER.read_text(encoding="utf-8") + assert "canonicalAsset: '/v2-assets/engraphis-graph.js'" in adapter + assert "window.EngraphisGraph =" not in adapter + assert "window.EngraphisGraph =" in ASSET.read_text(encoding="utf-8") + + +def test_opt_in_graph_asset_is_lazily_loaded_after_its_dependencies() -> None: + """The load order the removed script tags used to guarantee now lives in graphRender(). + + ``graphRender`` returns early until ForceGraph is defined, so by the time the engine + branch runs its dependency is already in scope. + """ + source = DASHBOARD.read_text(encoding="utf-8") + assert re.search( + r"script\.src='/static/vendor/force-graph\.min\.js\?v=[A-Za-z0-9._-]+'", + source, + ) + assert re.search( + r"script\.src='/v2-assets/engraphis-graph\.js\?v=[A-Za-z0-9._-]+'", + source, + ) + render = source[source.index("function graphRender("):] + render = render[: render.index("\nfunction ")] + force_graph_gate = render.index("typeof ForceGraph==='undefined'") + engine_gate = render.index("if(enginePending)") + classic = render.index("graphRenderEngine(data,fit,reheat)") + assert force_graph_gate < engine_gate < classic + + +def test_classic_dashboard_copies_share_the_canonical_route_gate() -> None: + """Classic must use the canonical renderer, including mounted `/classic` routes.""" + sources = [path.read_text(encoding="utf-8") for path in (DASHBOARD, CLASSIC_DASHBOARD)] + assert sources[0] == sources[1] + start = sources[0].index("function graphEngineEnabled()") + body = sources[0][start:sources[0].index("function graphEngineFallback", start)] + assert "/(^|\\/)classic\\/?$/.test(window.location.pathname)" in body + assert "GRAPH_ENGINE_FAILED" in body + + +def test_engine_node_labels_honor_the_configured_font_at_normal_zoom() -> None: + source = ASSET.read_text(encoding="utf-8") + assert "state.settings.font / scale / 3.4" not in source + assert "state.settings.font / scale" in source + + +#: Executes dashboard.js's real graph-render *routing* decision against a stub DOM. +#: ``graphEngineEnabled``, ``graphEngineFallback``, ``loadForceGraph``, ``loadGraphEngine`` and +#: the routing half of ``graphRender`` are verbatim source slices — nothing is re-implemented. +#: Only the classic renderer body below the routing decision is swapped for a ``CLASSIC()`` +#: marker, so the test can see which renderer a deep link actually reaches. +ROUTING_HARNESS = """ +const fs = require('fs'); +const src = fs.readFileSync(process.argv.slice(1).find(a => a.endsWith('dashboard.js')), 'utf8'); +const scenario = process.argv[process.argv.length - 1]; +const between = (from, to) => src.slice(src.indexOf(from), src.indexOf(to, src.indexOf(from))); +let flags = between('let GRAPH_ENGINE_FAILED=false;', 'function graphEngineEmptyMessage'); +const loaders = between('let FORCE_GRAPH_LOADING=null,FORCE_GRAPH_RETRY=0;', 'function graphRender('); +const CLASSIC_BOUNDARY = '/* Read AFTER the opt-in attempt:'; +const start = src.indexOf('function graphRender('); +const routing = src.slice(start, src.indexOf(CLASSIC_BOUNDARY, start)) + + '\\n CLASSIC();\\n}'; + +const log = { appended: [], warned: [], engine: 0, classic: 0 }; +let pending = null; +const element = { clientWidth: 800, clientHeight: 600, classList: { toggle() {} }, + setAttribute() {}, set textContent(v) {} }; +globalThis.document = { + getElementById: () => element, + querySelectorAll: () => [], + createElement: () => (pending = {}), + head: { appendChild: s => log.appended.push(s.src) }, +}; +const location = scenario === 'classic' + ? { search: '', pathname: '/classic' } + : { search: '?graph-engine=next', pathname: '/' }; +globalThis.window = { location, GSET: { mode: 'compact' }, + console: globalThis.console }; +globalThis.console = { warn: (...a) => log.warned.push(String(a[0])) }; +globalThis.showAs = () => {}; +globalThis.graphSetLayoutStatus = () => {}; +globalThis.graphData = () => ({ nodes: [], links: [] }); +/* Mirrors graphRenderEngine's real first line — `if(!element||typeof EngraphisGraph=== + 'undefined')return false` — because that bail is exactly what a naive lazy-load would turn + into a silent Classic fallback. Asserted against the real source below. */ +globalThis.graphRenderEngine = () => { + if (typeof EngraphisGraph === 'undefined') return false; + if (scenario === 'all-runtime-failed') return false; + log.engine += 1; + return true; +}; +globalThis.CLASSIC = () => { log.classic += 1; }; +globalThis.GRAPH_PRESETS = { compact: {} }; +globalThis.GRAPH_ENGINE = globalThis.GACTIVE_DATA = globalThis.GCOMPONENT_LAYOUT = null; +globalThis.GHILITE = globalThis.GHOVERSET = null; +globalThis.GRAPH_FULL = scenario === 'all-loaded' || scenario === 'all-runtime-failed'; +if (globalThis.GRAPH_FULL) globalThis.EngraphisGraph = { create() {} }; +if (scenario === 'all-runtime-failed') globalThis.EngraphisEveryGraph = { create() {} }; +/* All mode intentionally has no vendor global: its renderer must remain self-contained. */ +if (!globalThis.GRAPH_FULL) globalThis.ForceGraph = function () {}; + +new Function(flags + loaders + routing + '\\nreturn {graphRender};')().graphRender(); +const settled = { engine: log.engine, classic: log.classic }; +const finish = () => setTimeout(() => process.stdout.write(JSON.stringify({ + beforeSettle: settled, engine: log.engine, classic: log.classic, + appended: log.appended, warned: log.warned, +})), 0); +if (scenario === 'all-runtime-failed') { + finish(); +} else if (scenario === 'all-loaded') { + /* loadGraphEngine(true) chains the already-ready core through one microtask before it + requests the optional all-node asset. */ + Promise.resolve().then(() => { + globalThis.EngraphisEveryGraph = { create() {} }; pending.onload(); finish(); + }); +} else { + if (scenario === 'loads' || scenario === 'classic') { + globalThis.EngraphisGraph = { create() {} }; pending.onload(); + } + else { pending.onerror(); } + finish(); +} +""" + + +def _run_routing(scenario: str) -> dict: + result = subprocess.run( + [NODE, "-e", ROUTING_HARNESS, str(DASHBOARD), scenario], + 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_graph_engine_deep_link_reaches_the_next_engine_after_a_lazy_load() -> None: + """``?graph-engine=next`` must not degrade just because its asset is not loaded yet. + + ``graphRenderEngine`` bails when ``EngraphisGraph`` is undefined, and that bail cannot tell + "not fetched yet" from "unavailable". Deferring the script would turn every deep link into + that bail — the user asks for the new engine and silently gets Classic. So graphRender + fetches the asset and waits, then renders. + """ + # Keep the harness's stub honest: it only proves anything while the real function really + # does bail on an undefined global. + source = DASHBOARD.read_text(encoding="utf-8") + engine_path = source[source.index("function graphRenderEngine"):] + assert "typeof EngraphisGraph==='undefined')return false" in engine_path[:400] + + report = _run_routing("loads") + + assert report["appended"] == [ + "/v2-assets/engraphis-graph.js?v=20260815-merge-ready-1" + ] + # It waits rather than rendering something wrong in the meantime. + assert report["beforeSettle"] == {"engine": 0, "classic": 0} + # And it lands on the next engine, never touching the classic renderer. + assert report["engine"] == 1 + assert report["classic"] == 0 + assert report["warned"] == [] + + +@requires_node +def test_classic_route_reaches_the_canonical_engine_without_a_query_flag() -> None: + report = _run_routing("classic") + + assert report["appended"] == [ + "/v2-assets/engraphis-graph.js?v=20260815-merge-ready-1" + ] + assert report["beforeSettle"] == {"engine": 0, "classic": 0} + assert report["engine"] == 1 + assert report["classic"] == 0 + assert report["warned"] == [] + + +@requires_node +def test_show_all_lazily_loads_its_renderer_after_the_main_engine_is_ready() -> None: + """The overview's memoized engine promise must not bypass the later all-node asset.""" + report = _run_routing("all-loaded") + + assert report["appended"] == [ + "/v2-assets/engraphis-graph-every.js?v=20260823-every-19" + ] + assert report["beforeSettle"] == {"engine": 0, "classic": 0} + assert report["engine"] == 1 + assert report["classic"] == 0 + assert report["warned"] == [] + + +@requires_node +def test_show_all_never_reaches_legacy_force_graph_after_a_quality_failure() -> None: + """The complete scene is unsafe for the main-thread fallback, even after a failure latch.""" + report = _run_routing("all-runtime-failed") + + assert report["appended"] == [] + assert report["engine"] == 0 + assert report["classic"] == 0 + +@requires_node +def test_graph_engine_deep_link_degrades_loudly_when_the_asset_cannot_load() -> None: + """A genuine load failure is the only thing that reaches Classic, and it says so.""" + report = _run_routing("fails") + + assert report["engine"] == 0 + assert report["classic"] == 1 + assert report["warned"] == [ + "graph-engine=next failed; falling back to the classic renderer" + ] + + +def test_lazy_graph_engine_load_cannot_raise_an_unhandled_rejection() -> None: + """An unhandled rejection prints a console error — the exact thing this fix removes. + + ``graphRender`` can start the engine fetch on a pass that returns at the ForceGraph gate, + before it attaches its own handler, so the memoized promise carries its own. + """ + source = DASHBOARD.read_text(encoding="utf-8") + loader = source[source.index("function loadGraphEngine(loadAll=false)"):] + loader = loader[: loader.index("\nfunction ")] + assert "GRAPH_ENGINE_LOADING.catch(()=>{})" in loader + # A 200 that never registers the global is a corrupt asset, not a success. + assert "reject(new Error('Graph engine asset loaded without registering EngraphisGraph'))" in loader + assert "ALL_GRAPH_ENGINE_LOADING.catch(()=>{})" in source + assert "graphFull&&typeof EngraphisEveryGraph==='undefined'" in source + + +def test_force_graph_loader_rejects_a_success_without_the_vendor_global() -> None: + """A truncated 200 must not enter the render loop without ``ForceGraph``.""" + source = DASHBOARD.read_text(encoding="utf-8") + loader = source[source.index("function loadForceGraph()"):] + loader = loader[: loader.index("\nlet GRAPH_ENGINE_LOADING")] + assert "typeof ForceGraph==='undefined'" in loader + assert "reject(new Error('Force graph asset loaded without registering ForceGraph'))" in loader + + +@requires_node +def test_graph_asset_defines_its_global_without_touching_its_dependencies() -> None: + """Nothing may run at parse time except pure setup. + + ``PRELUDE`` supplies no ``ForceGraph``, no ``document`` and no ``requestAnimationFrame``. + If the asset reached for any of them at the top level this would throw, and in a browser + the same reach would abort the script and take ``window.EngraphisGraph`` with it. + """ + report = _run_node( + """ + emit({ + create: typeof G.create, + presets: Object.keys(G.PRESETS).sort(), + styles: Object.keys(G.STYLE_LAYERS).sort(), + }); + """ + ) + assert report["create"] == "function" + assert "communities" in report["presets"] + assert report["styles"] == ["classic", "cyber", "galaxy", "solar"] + + +@requires_node +def test_create_fails_loudly_when_force_graph_is_unavailable() -> None: + """A blocked vendor bundle must raise, not half-initialise a dead canvas.""" + report = _run_node( + """ + let message = null; + try { G.create({ getAttribute() { return null; } }, {}); } + catch (error) { message = error.message; } + emit({ message }); + """ + ) + assert report["message"] == "force-graph not loaded" + + +@requires_node +def test_node_geometry_stays_compact_for_small_overviews_and_is_style_neutral() -> None: + """Material style changes must not turn a compact overview into oversized discs. + + A seven-node workspace is intentionally common in the Ledger overview. Its normalized + degree metric used to produce a dense-graph radius, and ``zoomToFit`` magnified that radius + until every node filled a large part of the canvas. The radius helper now shares the + bounded scale used by Classic and does not know about visual style. + """ + report = _run_node( + """ + emit({ + leaf: I.graphNodeRadius({ degree: 0 }, 3, 0), + hub: I.graphNodeRadius({ degree: 6 }, 3, 1), + cluster: I.graphNodeRadius({ cluster: true, members: 64 }, 3, 1), + styles: ['classic', 'cyber', 'galaxy', 'solar'].map(() => I.graphNodeRadius({ degree: 6 }, 3, 1)), + }); + """ + ) + assert report["leaf"] >= 0.8 + assert report["hub"] < 4 + assert report["cluster"] < 7 + assert len(set(report["styles"])) == 1 + assert "if (sun) r *= 1.7" not in ASSET.read_text(encoding="utf-8") + assert "if(sun)r*=1.7;" not in CLASSIC_DASHBOARD.read_text(encoding="utf-8") + assert "if(sun)r*=1.7;" not in DASHBOARD.read_text(encoding="utf-8") + + +@requires_node +def test_galaxy_evidence_mass_is_sanitized_and_authoritative_for_radius() -> None: + report = _run_node( + """ + const nodes = [ + { id: 'fallback', degree: 5 }, + { id: 'light', degree: 1, gravity_mass: 2, visual_radius: 9 }, + { id: 'heavy', degree: 2, gravity_mass: 8, visual_radius: 3 }, + { id: 'ghost', degree: 99, gravity_mass: 0, visual_radius: 12, ghost: true }, + ]; + I.sanitizeEvidenceMetrics(nodes, 5); + const ordered = nodes.filter(n => !n.ghost).sort((a, b) => a.gravity_mass - b.gravity_mass); + const clusterSmall = I.evidenceNodeRadius({ cluster: true, gravity_mass: 4 }, 3); + const clusterLarge = I.evidenceNodeRadius({ cluster: true, gravity_mass: 16 }, 3); + emit({ + nodes, + monotonic: ordered.every((n, i) => !i || n.visual_radius >= ordered[i - 1].visual_radius), + scaled: I.evidenceNodeRadius(nodes[0], 6) / I.evidenceNodeRadius(nodes[0], 3), + clusterRatio: clusterLarge / clusterSmall, + fallbackAgain: I.fallbackGravityMass(5, 5), + }); + """ + ) + by_id = {node["id"]: node for node in report["nodes"]} + assert by_id["fallback"]["gravity_mass"] == report["fallbackAgain"] == 16 + def radius(mass: float) -> float: + return 1.2 * (1.5 + 2.0 * mass ** (2.0 / 3.0)) + assert by_id["fallback"]["visual_radius"] == pytest.approx(radius(16)) + assert by_id["light"]["visual_radius"] == pytest.approx(radius(2)) + assert by_id["heavy"]["visual_radius"] == pytest.approx(radius(8)) + assert by_id["ghost"]["gravity_mass"] == 0 + assert report["monotonic"] is True + assert report["scaled"] == pytest.approx(2) + assert report["clusterRatio"] == pytest.approx(radius(16) / radius(4)) + + +@requires_node +def test_global_black_hole_paint_emphasis_does_not_change_physical_radius() -> None: + report = _run_node( + """ + const ordinary = { id: 'ordinary', gravity_mass: 8, visual_radius: 9 }; + const community = { ...ordinary, id: 'community', anchor_role: 'community' }; + const global = { ...ordinary, id: 'global', anchor_role: 'global' }; + const sizes = [1, 3, 12]; + emit({ sizes: sizes.map(size => ({ + size, + ordinary: I.evidenceNodeRadius(ordinary, size), + community: I.evidenceNodeRadius(community, size), + global: I.evidenceNodeRadius(global, size), + })), masses: [ordinary.gravity_mass, community.gravity_mass, global.gravity_mass] }); + """ + ) + for sample in report["sizes"]: + assert sample["community"] == pytest.approx(sample["ordinary"]) + assert sample["global"] == pytest.approx(sample["ordinary"]) + assert report["masses"] == [8, 8, 8] + source = ASSET.read_text(encoding="utf-8") + assignment = source[source.index("data.nodes.forEach(n => {"): + source.index("const labelCap", source.index("data.nodes.forEach(n => {"))] + assert "n.radius = galaxyMode" in assignment + adornment = source[source.index("function paintGalaxyAnchorAdornment"): + source.index("function styleNode", source.index("function paintGalaxyAnchorAdornment"))] + assert "finitePositive(node.radius" in adornment + assert "GALAXY_BLACK_HOLE_PAINT_SCALE" in adornment + + +def test_galaxy_does_not_promote_aggregate_bridges_to_drawable_links() -> None: + source = ASSET.read_text(encoding="utf-8") + assert "raw.community_bridges.forEach(bridge =>" not in source + assert "connector_kind: 'community_bridge'" not in source + assert "state.settings.mode === 'galaxy' && raw.community_bridges.length" not in source + + +@requires_node +def test_softened_galaxy_gravity_obeys_mass_distance_and_momentum_invariants() -> None: + report = _run_node( + """ + const run = (distance, sourceMass, sourceCommunity = 'system') => { + const nodes = [ + { id: 'target', x: 0, y: 0, vx: 0, vy: 0, gravity_mass: 2, community_id: 'system' }, + { id: 'source', x: distance, y: 0, vx: 0, vy: 0, gravity_mass: sourceMass, community_id: sourceCommunity }, + ]; + I.applyGalaxyGravity(nodes, { gravity: 4, softening: 0.0001, alpha: 1 }); + return nodes; + }; + const near = run(10, 4), far = run(20, 4), doubled = run(10, 8); + const coincident = [ + { id: 'a', x: 0, y: 0, gravity_mass: 2, community_id: 'same' }, + { id: 'b', x: 0, y: 0, gravity_mass: 3, community_id: 'same' }, + ]; + I.applyGalaxyGravity(coincident, { gravity: 4, softening: 8, alpha: 1 }); + const isolated = run(10, 4, 'other'); + emit({ + inverseSquare: far[0].vx / near[0].vx, + linearMass: doubled[0].vx / near[0].vx, + momentum: 2 * near[0].vx + 4 * near[1].vx, + coincidentFinite: coincident.every(n => Number.isFinite(n.vx) && Number.isFinite(n.vy)), + isolated: isolated.map(n => [n.vx, n.vy]), + }); + """ + ) + assert report["inverseSquare"] == pytest.approx(0.25, rel=2e-4) + assert report["linearMass"] == pytest.approx(2) + assert report["momentum"] == pytest.approx(0, abs=1e-12) + assert report["coincidentFinite"] is True + assert report["isolated"] == [[0, 0], [0, 0]] + + +@requires_node +def test_galaxy_central_well_contracts_systems_monotonically_and_preserves_momentum() -> None: + report = _run_node( + """ + const fixture = () => [ + { id: 'l1', x: -170, y: 0, vx: 0, vy: 0, gravity_mass: 2, community_id: 'left' }, + { id: 'l2', x: -150, y: 0, vx: 0, vy: 0, gravity_mass: 3, community_id: 'left' }, + { id: 'right', x: 180, y: 0, vx: 0, vy: 0, gravity_mass: 5, community_id: 'right' }, + { id: 'top', x: 0, y: 210, vx: 0, vy: 0, gravity_mass: 4, community_id: 'top' }, + ]; + const distance = nodes => { + const centers = I.communityCenters(nodes); + const a = centers.get('left'), b = centers.get('right'), c = centers.get('top'); + return Math.hypot(a.x - b.x, a.y - b.y) + + Math.hypot(a.x - c.x, a.y - c.y) + + Math.hypot(b.x - c.x, b.y - c.y); + }; + const advance = gravity => { + const nodes = fixture(); + I.applyGalaxyCentralGravity(nodes, { + gravity, softening: 40, alpha: 1, accelerationCap: 1000, + }); + nodes.forEach(node => { node.x += node.vx; node.y += node.vy; }); + return { nodes, span: distance(nodes) }; + }; + const initial = distance(fixture()), low = advance(24), high = advance(72); + const coincident = [ + { id: 'a', x: 0, y: 0, gravity_mass: 2, community_id: 'a' }, + { id: 'b', x: 0, y: 0, gravity_mass: 3, community_id: 'b' }, + ]; + const stats = I.applyGalaxyCentralGravity(coincident, { + gravity: 100, softening: 40, alpha: 1, + }); + const capped = [ + { id: 'light', x: -1, y: 0, vx: 0, vy: 0, gravity_mass: 2, community_id: 'light' }, + { id: 'heavy', x: 1, y: 0, vx: 0, vy: 0, gravity_mass: 8, community_id: 'heavy' }, + ]; + const cappedStats = I.applyGalaxyCentralGravity(capped, { + gravity: 10000, softening: 0.1, alpha: 1, accelerationCap: 0.4, + }); + emit({ + initial, low: low.span, high: high.span, + momentum: [ + high.nodes.reduce((sum, node) => sum + node.gravity_mass * node.vx, 0), + high.nodes.reduce((sum, node) => sum + node.gravity_mass * node.vy, 0), + ], + rigidSystem: [ + high.nodes[0].vx - high.nodes[1].vx, + high.nodes[0].vy - high.nodes[1].vy, + ], + coincidentFinite: coincident.every(node => Number.isFinite(node.vx) && Number.isFinite(node.vy)), + systems: stats.systems, + capped: capped.map(node => node.vx), + cappedMomentum: capped.reduce( + (sum, node) => sum + node.gravity_mass * node.vx, 0 + ), + cappedPairs: cappedStats.applied, + }); + """ + ) + assert report["initial"] > report["low"] > report["high"] + assert report["momentum"] == pytest.approx([0, 0], abs=1e-12) + assert report["rigidSystem"] == pytest.approx([0, 0], abs=1e-12) + assert report["coincidentFinite"] is True + assert report["systems"] == 2 + assert report["capped"][0] == pytest.approx(0.4) + assert report["capped"][1] == pytest.approx(-0.1) + assert report["cappedMomentum"] == pytest.approx(0, abs=1e-12) + assert report["cappedPairs"] == 1 + source = ASSET.read_text(encoding="utf-8") + assert "function galaxyGravityConstant(setting)" in source + assert "function galaxySmoothstep(value)" in source + assert "const boost = 1 + 0.25 * galaxySmoothstep(value / 48)" in source + assert "function applyGalaxyCentralGravity(nodes, options)" in source + assert "GALAXY_CENTER_SCALE" not in source + central = source[source.index("function applyGalaxyCentralGravity"): + source.index("function applyCommunityBridgeGravity")] + assert "driftX" not in central + + +@requires_node +def test_unlinked_solar_systems_exert_bounded_mass_aware_near_field_gravity() -> None: + report = _run_node( + """ + const fixture = distance => [ + { id: 'black-hole', x: 0, y: 0, vx: 0, vy: 0, gravity_mass: 50, + community_id: 'core', anchor_role: 'global' }, + { id: 'left-star', x: 100, y: 0, vx: 0, vy: 0, gravity_mass: 8, + community_id: 'left' }, + { id: 'left-planet', x: 104, y: 2, vx: 0, vy: 0, gravity_mass: 2, + community_id: 'left' }, + { id: 'right-star', x: 100 + distance, y: 0, vx: 0, vy: 0, gravity_mass: 4, + community_id: 'right' }, + ]; + const run = distance => { + const nodes = fixture(distance); + const stats = I.applyGalaxyMutualSystemGravity(nodes, { + gravity: 48, strengthFraction: 0.12, softening: 1, + accelerationCap: 0, exactLimit: 64, + }); + return { nodes, stats }; + }; + const near = run(40), far = run(100); + const large = [{ id: 'core', x: 0, y: 0, vx: 0, vy: 0, gravity_mass: 100, + community_id: 'core', anchor_role: 'global' }]; + for (let index = 0; index < 100; index++) large.push({ + id: 's' + index, + x: 100 + (index % 10) * 20, y: -90 + Math.floor(index / 10) * 20, + gravity_mass: 1 + index % 7, community_id: 'system-' + index, + }); + const largeStats = I.applyGalaxyMutualSystemGravity(large, { + gravity: 48, strengthFraction: 0.12, softening: 40, + accelerationCap: 10, exactLimit: 64, theta: 0.85, + }); + emit({ + nearAcceleration: Math.hypot(near.nodes[1].vx, near.nodes[1].vy), + farAcceleration: Math.hypot(far.nodes[1].vx, far.nodes[1].vy), + blackHole: [near.nodes[0].vx, near.nodes[0].vy], + rigid: [near.nodes[1].vx - near.nodes[2].vx, + near.nodes[1].vy - near.nodes[2].vy], + momentum: near.nodes.slice(1).reduce((sum, node) => ({ + x: sum.x + node.gravity_mass * node.vx, + y: sum.y + node.gravity_mass * node.vy, + }), { x: 0, y: 0 }), + nearStats: near.stats, + largeStats, + finite: large.every(node => Number.isFinite(node.vx) && Number.isFinite(node.vy)), + }); + """ + ) + assert report["nearAcceleration"] > report["farAcceleration"] > 0 + assert report["blackHole"] == [0, 0] + assert report["rigid"] == pytest.approx([0, 0], abs=1e-12) + assert [report["momentum"]["x"], report["momentum"]["y"]] == pytest.approx( + [0, 0], abs=1e-12 + ) + assert report["nearStats"]["systems"] == 2 + assert report["nearStats"]["interactions"] == 1 + assert report["largeStats"]["approximations"] > 0 + assert report["largeStats"]["traversals"] < 100 * 100 + assert report["finite"] is True + + +@requires_node +def test_gravity_slider_response_has_exact_endpoints_and_scales_every_physics_layer() -> None: + report = _run_node( + """ + const ratio = (high, low) => high / low; + const pairAcceleration = gravity => { + const nodes = [ + { id: 'a', community_id: 'one', gravity_mass: 4, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'b', community_id: 'one', gravity_mass: 1, x: 30, y: 0, vx: 0, vy: 0 }, + ]; + I.applyGalaxyGravity(nodes, { gravity, softening: 12, alpha: 1 }); + return Math.abs(nodes[0].vx); + }; + const haloAcceleration = gravity => { + const nodes = [ + { id: 'star', anchor_role: 'community', community_id: 'one', + gravity_mass: 4, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'planet', community_id: 'one', gravity_mass: 1, + x: 30, y: 0, vx: 0, vy: 0 }, + ]; + I.applyGalaxySystemHaloGravity(nodes, { + gravity, softening: 12, smoothFraction: 0.85, accelerationCap: 100, + }); + return Math.abs(nodes[1].vx - nodes[0].vx); + }; + const centralAcceleration = gravity => { + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 8, x: 0, y: 0 }, + { id: 'system', community_id: 'outer', gravity_mass: 2, x: 120, y: 0 }, + ]; + return Math.abs(I.galaxyBlackHoleField(nodes, { + gravity, softening: 40, accelerationCap: 100, + }).systems[0].ax); + }; + const bridgeAcceleration = gravity => { + const nodes = [ + { id: 'a', community_id: 'left', gravity_mass: 4, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'b', community_id: 'right', gravity_mass: 1, x: 80, y: 0, vx: 0, vy: 0 }, + ]; + I.applyCommunityBridgeGravity(nodes, [{ + source_community: 'left', target_community: 'right', physics_strength: 0.8, + }], { gravity, softening: 30, alpha: 1 }); + return Math.abs(nodes[0].vx); + }; + const localSeedSpeedSquared = gravity => { + const nodes = [ + { id: 'star', anchor_role: 'community', community_id: 'one', + gravity_mass: 4, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'planet', community_id: 'one', gravity_mass: 1, + x: 30, y: 0, vx: 0, vy: 0 }, + ]; + I.seedGalaxyOrbits(nodes, 9, gravity, 12, false, 0.15); + const speed = Math.hypot(nodes[1].vx - nodes[0].vx, + nodes[1].vy - nodes[0].vy); + return speed * speed; + }; + const systemSeedSpeedSquared = gravity => { + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 8, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'system', anchor_role: 'community', community_id: 'outer', + gravity_mass: 2, x: 120, y: 0, vx: 0, vy: 0 }, + ]; + I.seedGalaxySystemOrbits(nodes, 9, gravity, 40, false); + const speed = Math.hypot(nodes[1].vx - nodes[0].vx, + nodes[1].vy - nodes[0].vy); + return speed * speed; + }; + const settings = [0, 1, 12, 24, 48, 72, 100, 200, 400]; + const response = settings.map(I.galaxyGravityConstant); + const legacy = setting => setting * (772 + 11 * setting) / 2600; + // This is the release-stable calibration restored after the unsafe speed-up. + const priorCalibration = setting => { + const value = Math.max(0, Math.min(400, Number(setting) || 0)); + const base = value * (772 + 11 * value) / 2600; + const smoothstep = raw => { + const t = Math.max(0, Math.min(1, raw)); + return t * t * (3 - 2 * t); + }; + const boost = 1 + 0.25 * smoothstep(value / 48) + + 0.25 * smoothstep((value - 48) / 52); + const highEndGain = 1 + 0.5 * smoothstep((value - 200) / 200 * 1.5); + return base * boost * 4 * highEndGain * 2.0; + }; + const fullRange = Array.from({ length: 401 }, (_, setting) => setting); + const centralCap = (gravity, explicit) => { + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 1000, x: 0, y: 0 }, + { id: 'near', community_id: 'outer', gravity_mass: 1000, x: 1, y: 0 }, + ]; + const options = { gravity, softening: 0.1 }; + if (explicit !== undefined) options.accelerationCap = explicit; + const item = I.galaxyBlackHoleField(nodes, options).systems[0]; + return Math.hypot(item.ax, item.ay); + }; + const compatibilityCentralCap = gravity => { + const nodes = [ + { id: 'left', community_id: 'left', gravity_mass: 1000, + x: -0.5, y: 0, vx: 0, vy: 0 }, + { id: 'right', community_id: 'right', gravity_mass: 1000, + x: 0.5, y: 0, vx: 0, vy: 0 }, + ]; + I.applyGalaxyCentralGravity(nodes, { gravity, softening: 0.1 }); + return Math.max(...nodes.map(node => Math.hypot(node.vx, node.vy))); + }; + const localHaloCap = gravity => { + const nodes = [ + { id: 'star', anchor_role: 'community', community_id: 'one', + gravity_mass: 1000, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'near', community_id: 'one', gravity_mass: 1000, + x: 0.01, y: 0, vx: 0, vy: 0 }, + ]; + I.applyGalaxySystemHaloGravity(nodes, { + gravity, softening: 0.1, smoothFraction: 0.85, + }); + return Math.max(...nodes.map(node => Math.hypot(node.vx, node.vy))); + }; + emit({ + response, + endpoints: [I.galaxyGravityConstant(48), I.galaxyGravityConstant(100), + I.galaxyGravityConstant(200), I.galaxyGravityConstant(400)], + split: { + blackHole: [I.galaxyBlackHoleGravityConstant(48), + I.galaxyBlackHoleGravityConstant(100), + I.galaxyBlackHoleGravityConstant(200), + I.galaxyBlackHoleGravityConstant(400)], + local: [I.galaxyLocalGravityConstant(48), + I.galaxyLocalGravityConstant(100), + I.galaxyLocalGravityConstant(200), + I.galaxyLocalGravityConstant(400)], + }, + clamps: [I.galaxyGravityConstant(-1), I.galaxyGravityConstant(401), + I.galaxyGravityConstant(Infinity), I.galaxyGravityConstant(NaN)], + layoutCompactness: [0, 48, 200, 400].map(I.galaxyLayoutCompactness), + caps: [centralCap(48), centralCap(100), centralCap(100, 1)], + compatibilityCaps: [compatibilityCentralCap(48), compatibilityCentralCap(100)], + localCaps: [localHaloCap(48), localHaloCap(100)], + neverWeaker: fullRange.every(setting => + I.galaxyGravityConstant(setting) >= legacy(setting) - 1e-12), + matchesStableCalibration: fullRange.every(setting => Math.abs( + I.galaxyGravityConstant(setting) - priorCalibration(setting) + ) <= 1e-10), + priorEndpoints: [48, 100, 200, 400].map(priorCalibration), + fullRangeMonotone: fullRange.slice(1).every((setting, index) => + I.galaxyGravityConstant(setting) > I.galaxyGravityConstant(index)), + ratios: { + pair: ratio(pairAcceleration(100), pairAcceleration(48)), + halo: ratio(haloAcceleration(100), haloAcceleration(48)), + central: ratio(centralAcceleration(100), centralAcceleration(48)), + bridge: ratio(bridgeAcceleration(100), bridgeAcceleration(48)), + localSeed: ratio(localSeedSpeedSquared(100), localSeedSpeedSquared(48)), + systemSeed: ratio(systemSeedSpeedSquared(100), systemSeedSpeedSquared(48)), + }, + }); + """ + ) + assert report["endpoints"][:2] == [240, 864] + assert report["endpoints"][2] == pytest.approx(2743.3846153846152) + assert report["endpoints"][3] == pytest.approx(14322.461538461538) + assert report["split"]["blackHole"] == pytest.approx( + [480, 1728, 5486.7692307692305, 28644.923076923076] + ) + assert report["split"]["local"] == pytest.approx( + [240, 864, 2743.3846153846152, 14322.461538461538] + ) + assert report["split"]["local"] == [ + value * 0.5 for value in report["split"]["blackHole"] + ] + assert report["clamps"] == pytest.approx([0, 14322.461538461538, 0, 0]) + assert report["layoutCompactness"] == pytest.approx([1.75, 1.5616, 0.965, 0.18]) + assert all( + right < left + for left, right in zip(report["layoutCompactness"], report["layoutCompactness"][1:]) + ) + assert report["caps"] == pytest.approx([50, 180, 1]) + assert report["compatibilityCaps"] == pytest.approx([50, 180]) + assert report["localCaps"] == pytest.approx([25, 90]) + assert report["response"][0] == 0 + assert all( + right > left + for left, right in zip(report["response"], report["response"][1:]) + ) + assert report["neverWeaker"] is True + assert report["matchesStableCalibration"] is True + assert report["endpoints"] == pytest.approx(report["priorEndpoints"]) + assert report["fullRangeMonotone"] is True + assert all(value == pytest.approx(3.6, rel=1e-12) for value in report["ratios"].values()) + source = ASSET.read_text(encoding="utf-8") + assert "const GALAXY_FAR_FIELD_ENVELOPE_SCALE = 2;" in source + assert "const GALAXY_GRAVITY_MAXIMUM = 400;" in source + assert "const GALAXY_GRAVITY_MAX_STRENGTH_GAIN = 1.5;" in source + assert "const GALAXY_GRAVITY_RESPONSE_RATE_MULTIPLIER = 1.5;" in source + + +@requires_node +def test_galaxy_gravity_slider_controls_galactic_field_not_local_orbits() -> None: + report = _run_node( + """ + const localTrial = gravity => { + const nodes = [ + { id: 'star', anchor_role: 'community', community_id: 'solar', + gravity_mass: 8, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', + gravity_mass: 1, x: 30, y: 0, vx: 0, vy: 0 }, + ]; + I.applyGalaxySystemAnchorGravity(nodes, { + gravity, localGravitySetting: 48, softening: 12, alpha: 1, + }); + return [nodes[0].vx, nodes[0].vy, nodes[1].vx, nodes[1].vy]; + }; + const galacticTrial = gravity => { + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 20, x: 0, y: 0 }, + { id: 'system', community_id: 'solar', gravity_mass: 2, + x: 120, y: 0 }, + ]; + const report = I.galaxyBlackHoleField(nodes, { gravity, softening: 32 }); + return report.systems.length ? Math.hypot(report.systems[0].ax, report.systems[0].ay) : 0; + }; + emit({ + localAtZero: localTrial(0), + localAtTwoHundred: localTrial(200), + galacticAtZero: galacticTrial(0), + galacticAtTwoHundred: galacticTrial(200), + convergenceAtZero: I.galaxyInwardConvergenceFactor(60, 0), + convergenceAtTwoHundred: I.galaxyInwardConvergenceFactor(60, 200), + }); + """ + ) + 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 + 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) + # Convergence is disabled (rate=0) for stable orbits; factor is 1 at all gravity settings. + assert report["convergenceAtTwoHundred"] == pytest.approx(report["convergenceAtZero"]) + + +@requires_node +def test_orbital_speed_increases_use_a_bounded_response_with_less_expansion() -> None: + report = _run_node( + """ + const settings = [0, 100, 200, 400]; + const localTrial = setting => { + const nodes = [ + { id: 'star', anchor_role: 'community', community_id: 'solar', + system_anchor_id: 'star', gravity_mass: 4, radius: 5, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', + orbit_tier: 1, gravity_mass: 1, radius: 2, + x: 30, y: 0, vx: 0, vy: 0 }, + ]; + I.seedGalaxyOrbits(nodes, 19, 48, 12, false, { orbitalSpeed: setting }); + return { + radius: Math.hypot(nodes[1].x - nodes[0].x, nodes[1].y - nodes[0].y), + speed: Math.hypot(nodes[1].vx - nodes[0].vx, + nodes[1].vy - nodes[0].vy), + }; + }; + const globalTrial = setting => { + 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: 0, vy: 0 }, + ]; + I.seedGalaxySystemOrbits(nodes, 19, 48, 40, false, { orbitalSpeed: setting }); + return Math.hypot(nodes[1].vx - nodes[0].vx, + nodes[1].vy - nodes[0].vy); + }; + const liveTrial = setting => { + 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: 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.applyGalaxyOrbitalSpeedControl(nodes, { + gravity: 48, softening: 32, centralSoftening: 40, + orbitalSpeed: setting, layoutSeed: 19, + }); + return { + global: Math.hypot(nodes[1].vx, nodes[1].vy), + local: Math.hypot(nodes[2].vx - nodes[1].vx, + nodes[2].vy - nodes[1].vy), + }; + }; + emit({ + multipliers: settings.map(I.galaxyOrbitalSpeedMultiplier), + radii: settings.map(setting => localTrial(setting).radius), + localSpeeds: settings.map(setting => localTrial(setting).speed), + globalSpeeds: settings.map(globalTrial), + live: settings.map(liveTrial), + }); + """ + ) + assert report["multipliers"] == pytest.approx([0.25, 1, 2, 4]) + assert report["radii"][0] == pytest.approx(report["radii"][1]) + assert report["radii"][1] < report["radii"][2] < report["radii"][3] + assert report["radii"][1] == pytest.approx(30) + assert report["radii"][2] == pytest.approx(35) + assert report["radii"][3] == pytest.approx(45) + assert report["multipliers"][2] - 1 == pytest.approx(1.0 * (2 - 1)) + assert report["multipliers"][3] - 1 == pytest.approx(1.0 * (4 - 1)) + assert report["radii"][3] - report["radii"][1] == pytest.approx(45 - 30) + assert report["localSpeeds"] == sorted(report["localSpeeds"]) + assert report["globalSpeeds"] == sorted(report["globalSpeeds"]) + assert [item["global"] for item in report["live"]] == sorted( + item["global"] for item in report["live"] + ) + assert [item["local"] for item in report["live"]] == sorted( + item["local"] for item in report["live"] + ) + + +@requires_node +def test_default_orbital_speed_preserves_cached_star_relative_direction() -> None: + """The shipped 100% clock must keep local control live after motion is established.""" + 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: 0 }, + { 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: 0 }, + ]; + const options = { + gravity: 48, softening: 32, centralSoftening: 40, + localGravitySetting: 48, orbitalSpeed: 100, + layoutSeed: 19, timestep: .032, + }; + I.seedGalaxyOrbits(nodes, 19, 48, 32, false, options); + I.seedGalaxySystemOrbits(nodes, 19, 48, 40, false, options); + const star = nodes[1], planet = nodes[2]; + const tangent = () => { + const dx = planet.x - star.x, dy = planet.y - star.y; + const radius = Math.hypot(dx, dy); + const relativeVx = planet.vx - star.vx; + const relativeVy = planet.vy - star.vy; + return (-dy * relativeVx + dx * relativeVy) / radius; + }; + const starPhase = () => [star.x, star.y, star.vx, star.vy]; + const radius = () => Math.hypot(planet.x - star.x, planet.y - star.y); + const starBefore = starPhase(); + const first = I.applyGalaxyOrbitalSpeedControl(nodes, options); + const initialTangent = tangent(); + const initialRadius = radius(); + const cachedDirection = planet.__galaxySpeedControlPhase.direction; + const relativeVx = planet.vx - star.vx; + const relativeVy = planet.vy - star.vy; + planet.vx = star.vx - relativeVx; + planet.vy = star.vy - relativeVy; + const reversedTangent = tangent(); + const second = I.applyGalaxyOrbitalSpeedControl(nodes, options); + emit({ + first, second, initialTangent, reversedTangent, + repairedTangent: tangent(), cachedDirection, + initialRadius, repairedRadius: radius(), + stellarSpeedGain: Math.sqrt(I.galaxyStellarGravityConstant(48) / 750), + starBefore, starAfter: starPhase(), + }); + """ + ) + assert report["first"]["systems"] == 0 + assert report["second"]["systems"] == 0 + assert report["first"]["localSatellites"] == 1 + assert report["second"]["localSatellites"] == 1 + assert report["cachedDirection"] == pytest.approx( + math.copysign(1, report["initialTangent"]) + ) + assert math.copysign(1, report["reversedTangent"]) == -report["cachedDirection"] + assert math.copysign(1, report["repairedTangent"]) == report["cachedDirection"] + assert abs(report["repairedTangent"]) > 1e-5 + assert report["repairedRadius"] == pytest.approx(report["initialRadius"]) + assert report["stellarSpeedGain"] == pytest.approx(1.8384776310850235) + assert report["starAfter"] == pytest.approx(report["starBefore"]) + + +@requires_node +def test_default_clock_keeps_planets_and_moons_orbiting_their_immediate_parent() -> None: + """Nested children rotate continuously in the moving frame of their larger parent.""" + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', orbit_tier: 0, gravity_mass: 20, 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: 10, radius: 6, + x: 140, y: 0, vx: 0, vy: 0 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', + orbit_tier: 1, orbit_radius: 42, gravity_mass: 5, radius: 4, + x: 182, y: 0, vx: 0, vy: 0 }, + { id: 'planet-b', community_id: 'solar', system_anchor_id: 'star', + orbit_tier: 1, orbit_radius: 70, gravity_mass: 3, radius: 3, + x: 140, y: 70, vx: 0, vy: 0 }, + { id: 'moon-a', community_id: 'solar', system_anchor_id: 'planet', + orbit_tier: 2, orbit_radius: 16, gravity_mass: 1, radius: 2, + x: 198, y: 0, vx: 0, vy: 0 }, + { id: 'moon-b', community_id: 'solar', system_anchor_id: 'planet', + orbit_tier: 2, orbit_radius: 25, gravity_mass: 1, radius: 2, + x: 182, y: 25, vx: 0, vy: 0 }, + ]; + const options = { + gravity: 48, softening: 32, centralSoftening: 40, + localGravitySetting: 48, orbitalSpeed: 100, + layoutSeed: 817, timestep: .032, + }; + I.seedGalaxyOrbits(nodes, 817, 48, 32, false, options); + I.seedGalaxySystemOrbits(nodes, 817, 48, 40, false, options); + const byId = new Map(nodes.map(node => [String(node.id), node])); + const children = nodes.filter(node => Number(node.orbit_tier) > 0); + const angle = node => { + const parent = byId.get(String(node.system_anchor_id)); + return Math.atan2(node.y - parent.y, node.x - parent.x); + }; + const radius = node => { + const parent = byId.get(String(node.system_anchor_id)); + return Math.hypot(node.x - parent.x, node.y - parent.y); + }; + const previous = new Map(children.map(node => [node.id, angle(node)])); + const travel = new Map(children.map(node => [node.id, 0])); + const direction = new Map(); + let maximumRadiusError = 0; + for (let step = 0; step < 240; step++) { + I.applyGalaxyOrbitalSpeedControl(nodes, options); + children.forEach(node => { + const next = angle(node); + const delta = Math.atan2(Math.sin(next - previous.get(node.id)), + Math.cos(next - previous.get(node.id))); + previous.set(node.id, next); + travel.set(node.id, travel.get(node.id) + delta); + const sign = Math.sign(delta); + if (sign) { + if (!direction.has(node.id)) direction.set(node.id, sign); + else if (direction.get(node.id) !== sign) throw new Error('orbit reversed'); + } + maximumRadiusError = Math.max(maximumRadiusError, + Math.abs(radius(node) - node.orbit_radius)); + }); + } + const lanes = I.galaxyOrbitLaneGeometry(nodes); + emit({ + travel: Object.fromEntries(travel), + directions: Object.fromEntries(direction), + maximumRadiusError, + parents: Object.fromEntries(children.map(node => [node.id, node.system_anchor_id])), + laneAnchors: lanes.map(lane => lane.anchorId).sort(), + laneRadii: lanes.map(lane => lane.radius).sort((a, b) => a - b), + moonSpeedGain: Math.sqrt(I.galaxySystemGravityConstant( + byId.get('planet'), 48, 48, true + ) / I.galaxyFallbackStellarGravityConstant(48)), + moonRole: I.galaxyOrbitalLinkRole({ + source: byId.get('planet'), target: byId.get('moon-a'), + }), + }); + """ + ) + assert report["parents"] == { + "planet": "star", + "planet-b": "star", + "moon-a": "planet", + "moon-b": "planet", + } + assert all(abs(value) > 0.05 for value in report["travel"].values()) + assert set(report["directions"]) == set(report["parents"]) + assert report["maximumRadiusError"] < 1e-8 + assert report["laneAnchors"] == ["planet", "planet", "star", "star"] + assert report["laneRadii"] == pytest.approx([16, 25, 42, 70]) + assert report["moonSpeedGain"] == pytest.approx(1.3) + assert report["moonRole"] == "radial" + + +@requires_node +def test_live_solar_system_uses_authored_concentric_star_relative_lanes() -> None: + """Every authored planet stays on a clean lane about the one declared star.""" + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', orbit_tier: 0, 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, orbit_radius: 0, + gravity_mass: 8, radius: 5, x: 120, y: 0, vx: 0, vy: 0 }, + ...[18, 30, 44, 60].map((orbit, index) => ({ + id: 'planet-' + index, community_id: 'solar', system_anchor_id: 'star', + orbit_tier: index + 1, orbit_radius: orbit, gravity_mass: 1, + radius: 2, x: 121 + index, y: 1 + index, vx: 0, vy: 0, + })), + ]; + const options = { + gravity: 48, softening: 32, centralSoftening: 40, + localGravitySetting: 48, orbitalSpeed: 100, + layoutSeed: 2026, timestep: .032, + }; + I.seedGalaxyOrbits(nodes, 2026, 48, 32, false, options); + I.seedGalaxySystemOrbits(nodes, 2026, 48, 40, false, options); + const star = nodes[1], planets = nodes.slice(2); + const previous = new Map(planets.map(node => [node.id, + Math.atan2(node.y - star.y, node.x - star.x)])); + const travel = new Map(planets.map(node => [node.id, 0])); + const direction = new Map(); + let maximumRadiusError = 0, minimumLaneGap = Infinity; + for (let step = 0; step < 180; step++) { + I.applyGalaxyOrbitalSpeedControl(nodes, options); + const radii = []; + planets.forEach(node => { + const dx = node.x - star.x, dy = node.y - star.y; + const radius = Math.hypot(dx, dy); + const angle = Math.atan2(dy, dx); + const delta = Math.atan2(Math.sin(angle - previous.get(node.id)), + Math.cos(angle - previous.get(node.id))); + previous.set(node.id, angle); + travel.set(node.id, travel.get(node.id) + delta); + const sign = Math.sign(delta); + if (sign) { + if (!direction.has(node.id)) direction.set(node.id, sign); + else if (direction.get(node.id) !== sign) throw new Error('orbit reversed'); + } + maximumRadiusError = Math.max(maximumRadiusError, + Math.abs(radius - node.orbit_radius)); + radii.push({ radius, node }); + }); + radii.sort((left, right) => left.radius - right.radius); + for (let index = 1; index < radii.length; index++) { + minimumLaneGap = Math.min(minimumLaneGap, + radii[index].radius - radii[index - 1].radius + - radii[index].node.radius - radii[index - 1].node.radius); + } + } + const geometry = I.galaxyOrbitLaneGeometry(nodes); + const strokes = []; + const context = { + save() {}, restore() {}, beginPath() {}, stroke() { strokes.push(this.lastArc); }, + arc(x, y, radius) { this.lastArc = { x, y, radius }; }, + set lineWidth(value) { this._lineWidth = value; }, + set strokeStyle(value) { this._strokeStyle = value; }, + }; + const painted = I.paintGalaxyOrbitLanes(context, nodes, 1, '#9d7bff'); + const visibleStarIds = I.galaxyStarAnchorIds(geometry); + emit({ + maximumRadiusError, minimumLaneGap, painted, geometry, + strokes, travel: [...travel.values()], directions: [...direction.values()], + parents: planets.map(node => node.system_anchor_id), + tiers: planets.map(node => node.orbit_tier), + radialRole: I.galaxyOrbitalLinkRole({ source: star, target: planets[0] }), + internalRole: I.galaxyOrbitalLinkRole({ source: planets[0], target: planets[1] }), + adornment: { + star: I.galaxyAnchorAdornmentEligible(star, visibleStarIds), + singleton: I.galaxyAnchorAdornmentEligible({ + id: 'singleton', anchor_role: 'community', community_id: 'alone', + }, visibleStarIds), + global: I.galaxyAnchorAdornmentEligible(nodes[0], visibleStarIds), + planet: I.galaxyAnchorAdornmentEligible(planets[0], visibleStarIds), + twoConnected: I.galaxyStarAnchorIds([ + { anchorId: 'two', members: 2 }, + ]).has('two'), + threeConnected: I.galaxyStarAnchorIds([ + { anchorId: 'three', members: 3 }, + ]).has('three'), + }, + }); + """ + ) + assert report["maximumRadiusError"] < 1e-8 + assert report["minimumLaneGap"] >= 8 - 1e-8 + assert report["painted"] == 4 + assert [lane["radius"] for lane in report["geometry"]] == pytest.approx( + [18, 30, 44, 60] + ) + assert [stroke["radius"] for stroke in report["strokes"]] == pytest.approx( + [18, 30, 44, 60] + ) + assert all(abs(value) > 0.01 for value in report["travel"]) + assert len(report["directions"]) == 4 + assert report["parents"] == ["star"] * 4 + assert report["tiers"] == [1, 2, 3, 4] + assert report["radialRole"] == "radial" + assert report["internalRole"] == "internal" + assert report["adornment"] == { + "star": True, + "singleton": False, + "global": True, + "planet": False, + "twoConnected": False, + "threeConnected": True, + } + + +@requires_node +def test_orbital_speed_scales_live_carrier_and_kinematic_phase_rates() -> None: + report = _run_node( + """ + const fixture = () => [ + { 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: 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 phaseDelta = (from, to) => Math.atan2( + Math.sin(to - from), Math.cos(to - from)); + const kinematicTrial = orbitalSpeed => { + const nodes = fixture(); + let systemTravel = 0, localTravel = 0; + for (let step = 0; step < 24; step += 1) { + const beforeSystem = Math.atan2(nodes[1].y, nodes[1].x); + const beforeLocal = Math.atan2(nodes[2].y - nodes[1].y, + nodes[2].x - nodes[1].x); + I.advanceGalaxyKinematicOrbits(nodes, { + gravity: 48, softening: 32, centralSoftening: 40, localSoftening: 12, + orbitalSpeed, layoutSeed: 19, timestep: .032, + }); + systemTravel += Math.abs(phaseDelta(beforeSystem, + Math.atan2(nodes[1].y, nodes[1].x))); + localTravel += Math.abs(phaseDelta(beforeLocal, + Math.atan2(nodes[2].y - nodes[1].y, nodes[2].x - nodes[1].x))); + } + return { systemTravel, localTravel }; + }; + const liveCarrierTrial = orbitalSpeed => { + const nodes = fixture(); + Object.defineProperty(nodes[1], '__galaxyCarrierLaneRadius', { + value: 120, writable: true, configurable: true, enumerable: false, + }); + Object.defineProperty(nodes[1], '__galaxyCarrierLaneAngle', { + value: 0, writable: true, configurable: true, enumerable: false, + }); + I.supportGalaxyCarrierOrbits(nodes, { + gravity: 48, softening: 32, centralSoftening: 40, + orbitalSpeed, layoutSeed: 19, timestep: .032, + }); + return Math.abs(Math.atan2(nodes[1].y, nodes[1].x)); + }; + const naturalKinematic = kinematicTrial(100); + const fastKinematic = kinematicTrial(400); + const naturalCarrier = liveCarrierTrial(100); + const fastCarrier = liveCarrierTrial(400); + emit({ naturalKinematic, fastKinematic, naturalCarrier, fastCarrier, + kinematicSystemRatio: fastKinematic.systemTravel / naturalKinematic.systemTravel, + kinematicLocalRatio: fastKinematic.localTravel / naturalKinematic.localTravel, + carrierRatio: fastCarrier / naturalCarrier }); + """ + ) + assert report["naturalKinematic"]["systemTravel"] > 0 + assert report["naturalKinematic"]["localTravel"] > 0 + assert report["kinematicSystemRatio"] > 1.8 + assert report["kinematicLocalRatio"] > 2.5 + assert report["naturalCarrier"] > 0 + assert report["carrierRatio"] == pytest.approx(4.0, rel=0.02) + + +@requires_node +def test_four_hundred_percent_clock_keeps_release_sized_solar_systems_inside_reserved_lanes() -> None: + """The maximum clock may expand and accelerate 60 systems, never scatter their members.""" + report = _run_node( + """ + const nodes = [{ id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', gravity_mass: 64, radius: 9, + x: 0, y: 0, vx: 0, vy: 0 }]; + for (let system = 0; system < 60; system++) { + const systemId = 'system-' + system, starId = systemId + '-star'; + const phase = system * 2.399963229728653; + const carrierRadius = 120 + system * 4; + const starX = Math.cos(phase) * carrierRadius; + const starY = Math.sin(phase) * carrierRadius; + nodes.push({ id: starId, anchor_role: 'community', community_id: systemId, + system_anchor_id: starId, gravity_mass: 8 + system % 5, radius: 5.5, + x: starX, y: starY, vx: 0, vy: 0 }); + for (let member = 1; member <= 8; member++) { + const orbitRadius = 18 + member * 4; + const localPhase = phase + member * 2.399963229728653; + nodes.push({ id: systemId + '-planet-' + member, community_id: systemId, + system_anchor_id: starId, orbit_tier: member, orbit_radius: orbitRadius, + gravity_mass: 1 + (member % 3) * .25, radius: 2.5, + x: starX + Math.cos(localPhase) * orbitRadius, + y: starY + Math.sin(localPhase) * orbitRadius, vx: 0, vy: 0 }); + } + } + const setting = 400; + I.establishGalaxyCarrierLanes(nodes, { gap: 4, layoutSeed: 817 }); + I.seedGalaxyOrbits(nodes, 817, 48, 32, false, { + orbitalSpeed: setting, localGravitySetting: 48, + }); + I.seedGalaxySystemOrbits(nodes, 817, 48, 48, false, { + orbitalSpeed: setting, + }); + const options = { + layoutSeed: 817, gravity: 48, softening: 32, centralSoftening: 48, + localSoftening: 32, localGravitySetting: 48, orbitalSpeed: setting, + timestep: .032, wallClockSeconds: 1 / 30, velocityDecay: .00005, + speedLimit: 48, exactLimit: 64, theta: .85, + includeBridges: false, includeMutualSystems: true, + mutualSystemGravityFraction: .12, mutualSystemSoftening: 80, + includeRelations: false, includeRelationSprings: false, + includeOrbitalSeparation: false, includeSystemPacking: false, + includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, + includeFarFieldConfinement: true, farFieldEnvelopeScale: 1.75, + farFieldMinimumRadius: 96, farFieldSoftFraction: .82, + localRelativeSpeedLimit: 48, + }; + const byId = new Map(nodes.map(node => [String(node.id), node])); + const members = nodes.filter(node => node.system_anchor_id + && String(node.system_anchor_id) !== String(node.id) + && String(node.system_anchor_id) !== 'black-hole'); + const carriers = nodes.filter(node => node.anchor_role === 'community'); + const previousCarrierAngles = new Map(carriers.map(node => [node.id, + Math.atan2(node.y, node.x)])); + const previousLocalAngles = new Map(members.map(node => { + const parent = byId.get(String(node.system_anchor_id)); + return [node.id, Math.atan2(node.y - parent.y, node.x - parent.x)]; + })); + const carrierTravel = new Map(carriers.map(node => [node.id, 0])); + const localTravel = new Map(members.map(node => [node.id, 0])); + const delta = (next, previous) => Math.atan2(Math.sin(next - previous), + Math.cos(next - previous)); + let maximumBoundaryRatio = 0, minimumSystemClearance = Infinity; + let maximumSettledCorrection = 0; + for (let step = 0; step < 180; step++) { + I.integrateGalaxyLeapfrog(nodes, [], [], options); + const control = I.applyGalaxyOrbitalSpeedControl(nodes, options); + if (step > 12) maximumSettledCorrection = Math.max(maximumSettledCorrection, + control.maximumPositionCorrection); + carriers.forEach(node => { + const angle = Math.atan2(node.y, node.x), previous = previousCarrierAngles.get(node.id); + carrierTravel.set(node.id, carrierTravel.get(node.id) + delta(angle, previous)); + previousCarrierAngles.set(node.id, angle); + }); + members.forEach(node => { + const parent = byId.get(String(node.system_anchor_id)); + const radius = Math.hypot(node.x - parent.x, node.y - parent.y); + const maximum = node.__galaxyOrbitBaseRadius + * I.galaxyOrbitalRadiusMultiplier(setting) * 1.08; + maximumBoundaryRatio = Math.max(maximumBoundaryRatio, radius / maximum); + const angle = Math.atan2(node.y - parent.y, node.x - parent.x); + const previous = previousLocalAngles.get(node.id); + localTravel.set(node.id, localTravel.get(node.id) + delta(angle, previous)); + previousLocalAngles.set(node.id, angle); + }); + if (step % 15 === 0 || step === 179) { + const systems = I.galaxySystemEnvelopes(nodes, { + respectFixedCoordinates: false, + }).filter(system => system.anchor.anchor_role === 'community'); + for (let left = 0; left < systems.length; left++) { + for (let right = left + 1; right < systems.length; right++) { + minimumSystemClearance = Math.min(minimumSystemClearance, + Math.hypot(systems[left].x - systems[right].x, + systems[left].y - systems[right].y) + - systems[left].radius - systems[right].radius); + } + } + } + } + emit({ nodeCount: nodes.length, memberCount: members.length, + multiplier: I.galaxyOrbitalSpeedMultiplier(setting), + radiusMultiplier: I.galaxyOrbitalRadiusMultiplier(setting), + maximumBoundaryRatio, minimumSystemClearance, maximumSettledCorrection, + minimumCarrierTravel: Math.min(...[...carrierTravel.values()].map(Math.abs)), + minimumLocalTravel: Math.min(...[...localTravel.values()].map(Math.abs)), + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)) }); + """ + ) + assert report["nodeCount"] == 541 + assert report["memberCount"] == 480 + assert report["finite"] is True + assert report["multiplier"] == pytest.approx(4.0) + assert report["radiusMultiplier"] == pytest.approx(1.5) + assert report["maximumBoundaryRatio"] <= 1 + 1e-9 + assert report["minimumSystemClearance"] >= -1e-8 + assert report["minimumCarrierTravel"] > 0.1 + assert report["minimumLocalTravel"] > 0.1 + assert report["maximumSettledCorrection"] < 4 + + +@requires_node +def test_explicit_black_hole_child_gets_slider_controlled_orbital_lane() -> None: + report = _run_node( + """ + const fixture = () => [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 64, radius: 8, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'connected', community_id: 'cross-core', + system_anchor_id: 'black-hole', gravity_mass: 3, + radius: 3, x: 52, y: 0, vx: 0, vy: 0 }, + { id: 'star', anchor_role: 'community', community_id: 'solar', + system_anchor_id: 'star', gravity_mass: 8, radius: 5, + x: 120, y: 0, vx: 0, vy: 0 }, + ]; + const trial = orbitalSpeed => { + const nodes = fixture(); + I.seedGalaxyOrbits(nodes, 77, 48, 32, false, { orbitalSpeed }); + let travel = 0; + for (let step = 0; step < 30; step += 1) { + const before = Math.atan2(nodes[1].y, nodes[1].x); + I.supportGalaxyCarrierOrbits(nodes, { + gravity: 48, softening: 32, centralSoftening: 40, + orbitalSpeed, layoutSeed: 77, timestep: .032, + }); + const after = Math.atan2(nodes[1].y, nodes[1].x); + travel += Math.abs(Math.atan2(Math.sin(after - before), Math.cos(after - before))); + } + return { travel, child: nodes[1], grouped: I.galaxyOrbitGroups(nodes).get('black-hole') }; + }; + const slow = trial(100), fast = trial(400); + emit({ slow: { travel: slow.travel, child: slow.child, + grouped: slow.grouped && slow.grouped.nodes.map(node => node.id) }, + fast: { travel: fast.travel, child: fast.child, + grouped: fast.grouped && fast.grouped.nodes.map(node => node.id) }, + ratio: fast.travel / slow.travel }); + """ + ) + assert report["slow"]["travel"] > 0 + assert report["fast"]["travel"] > report["slow"]["travel"] + assert report["ratio"] == pytest.approx(4.0, rel=0.03) + assert report["slow"]["grouped"] == ["black-hole", "connected"] + assert report["fast"]["grouped"] == ["black-hole", "connected"] + + +@requires_node +def test_relation_to_black_hole_does_not_override_server_authored_hierarchy() -> None: + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', gravity_mass: 64, radius: 8, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'related-star', anchor_role: 'community', community_id: 'solar', + system_anchor_id: 'related-star', gravity_mass: 8, radius: 5, + x: 72, y: 0, vx: 0, vy: 0 }, + ]; + const links = [{ source: 'black-hole', target: 'related-star', relation: 'orbits' }]; + emit({ + linkCount: links.length, + core: I.galaxyOrbitGroups(nodes).get('black-hole').nodes.map(node => node.id), + solar: I.galaxyOrbitGroups(nodes).get('related-star').nodes.map(node => node.id), + }); + """ + ) + assert report == { + "linkCount": 1, + "core": ["black-hole"], + "solar": ["related-star"], + } + + +@requires_node +def test_explicit_black_hole_parent_keeps_a_complete_solar_system_in_the_core_frame() -> None: + """The server-authored parent chain, not a relation label, defines orbital hierarchy.""" + report = _run_node( + """ + const make = () => [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 64, radius: 9, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'linked-star', anchor_role: 'community', community_id: 'solar', + system_anchor_id: 'black-hole', gravity_mass: 8, radius: 5, + x: 72, y: 0, vx: 0, vy: 0 }, + { id: 'linked-planet', community_id: 'solar', + system_anchor_id: 'linked-star', gravity_mass: 1, radius: 2.5, + x: 88, y: 0, vx: 0, vy: 0 }, + { id: 'free-star', anchor_role: 'community', community_id: 'free', + system_anchor_id: 'free-star', gravity_mass: 8, radius: 5, + x: -96, y: 0, vx: 0, vy: 0 }, + { id: 'free-planet', community_id: 'free', + system_anchor_id: 'free-star', gravity_mass: 1, radius: 2.5, + x: -112, y: 0, vx: 0, vy: 0 }, + ]; + const delta = (next, previous) => Math.atan2(Math.sin(next - previous), + Math.cos(next - previous)); + const run = kinematic => { + const nodes = make(); + const options = { + layoutSeed: 1901, gravity: 48, softening: 32, centralSoftening: 40, + localSoftening: 40, orbitalSpeed: 48, timestep: .032, + includeMutualSystems: false, includeRelations: false, + includeOrbitalSeparation: false, includeSystemPacking: false, + includeBlackHoleExclusion: false, includeFarFieldConfinement: false, + includeCollisions: false, speedLimit: 48, localRelativeSpeedLimit: 48, + }; + I.seedGalaxyOrbits(nodes, 1901, 48, 32, false, options); + I.seedGalaxySystemOrbits(nodes, 1901, 48, 40, false, options); + const linked = nodes[1], free = nodes[3]; + let linkedTravel = 0, freeTravel = 0; + for (let step = 0; step < 120; step++) { + const linkedBefore = Math.atan2(linked.y, linked.x); + const freeBefore = Math.atan2(free.y, free.x); + if (kinematic) I.advanceGalaxyKinematicOrbits(nodes, options); + else { + I.integrateGalaxyLeapfrog(nodes, [], [], options); + I.applyGalaxyOrbitalSpeedControl(nodes, options); + } + linkedTravel += Math.abs(delta(Math.atan2(linked.y, linked.x), linkedBefore)); + freeTravel += Math.abs(delta(Math.atan2(free.y, free.x), freeBefore)); + } + return { + linkedTravel, freeTravel, + blackHoleGroup: I.galaxyOrbitGroups(nodes).get('black-hole') + .nodes.map(node => node.id), + solarGroup: I.galaxyOrbitGroups(nodes).get('linked-star')?.nodes + .map(node => node.id) || [], + markedAsBlackHoleChild: nodes[1].__galaxyBlackHoleChild === true, + localDistance: Math.hypot(nodes[2].x - linked.x, nodes[2].y - linked.y), + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)), + }; + }; + emit({ live: run(false), kinematic: run(true) }); + """ + ) + for mode in ("live", "kinematic"): + result = report[mode] + assert result["finite"] is True + assert result["linkedTravel"] > 0.1, result + assert result["freeTravel"] > 0.1, result + assert result["localDistance"] > 10, result + assert set(result["blackHoleGroup"]) == { + "black-hole", "linked-star", "linked-planet", + } + assert result["solarGroup"] == [] + assert result["markedAsBlackHoleChild"] is False + + +@requires_node +def test_explicit_black_hole_parent_moves_community_anchors_and_their_planets() -> None: + report = _run_node( + """ + const fixture = () => [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', gravity_mass: 64, radius: 9, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'community-child', anchor_role: 'community', community_id: 'solar', + system_anchor_id: 'black-hole', gravity_mass: 8, radius: 5, + x: 72, y: 0, vx: 0, vy: 0 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'community-child', + orbit_tier: 1, gravity_mass: 1, radius: 2, + x: 88, y: 0, vx: 0, vy: 0 }, + ]; + const trial = orbitalSpeed => { + const nodes = fixture(); + I.seedGalaxyOrbits(nodes, 81, 48, 32, false, { orbitalSpeed }); + let travel = 0; + for (let step = 0; step < 30; step += 1) { + const before = Math.atan2(nodes[1].y, nodes[1].x); + I.supportGalaxyCarrierOrbits(nodes, { + gravity: 48, softening: 32, centralSoftening: 40, + orbitalSpeed, layoutSeed: 81, timestep: .032, + }); + const after = Math.atan2(nodes[1].y, nodes[1].x); + travel += Math.abs(Math.atan2(Math.sin(after - before), Math.cos(after - before))); + } + return { travel, grouped: I.galaxyOrbitGroups(nodes).get('black-hole'), + localDistance: Math.hypot(nodes[2].x - nodes[1].x, nodes[2].y - nodes[1].y) }; + }; + const kinematicTrial = orbitalSpeed => { + const nodes = fixture(); + I.seedGalaxyOrbits(nodes, 81, 48, 32, false, { orbitalSpeed }); + let travel = 0; + for (let step = 0; step < 30; step += 1) { + const before = Math.atan2(nodes[1].y, nodes[1].x); + I.advanceGalaxyKinematicOrbits(nodes, { + gravity: 48, softening: 32, centralSoftening: 40, + orbitalSpeed, layoutSeed: 81, timestep: .032, + }); + const after = Math.atan2(nodes[1].y, nodes[1].x); + travel += Math.abs(Math.atan2(Math.sin(after - before), Math.cos(after - before))); + } + return { travel, grouped: I.galaxyOrbitGroups(nodes).get('black-hole'), + localDistance: Math.hypot(nodes[2].x - nodes[1].x, nodes[2].y - nodes[1].y) }; + }; + const slow = trial(100), fast = trial(400); + const slowKinematic = kinematicTrial(100), fastKinematic = kinematicTrial(400); + emit({ slow: { travel: slow.travel, + grouped: slow.grouped && slow.grouped.nodes.map(node => node.id), + localDistance: slow.localDistance }, + fast: { travel: fast.travel, + grouped: fast.grouped && fast.grouped.nodes.map(node => node.id), + localDistance: fast.localDistance }, + slowKinematic: { travel: slowKinematic.travel, + grouped: slowKinematic.grouped && slowKinematic.grouped.nodes.map(node => node.id), + localDistance: slowKinematic.localDistance }, + fastKinematic: { travel: fastKinematic.travel, + grouped: fastKinematic.grouped && fastKinematic.grouped.nodes.map(node => node.id), + localDistance: fastKinematic.localDistance }, + ratio: fast.travel / slow.travel, + kinematicRatio: fastKinematic.travel / slowKinematic.travel }); + """ + ) + assert report["slow"]["travel"] > 0 + assert report["fast"]["travel"] > report["slow"]["travel"] + assert report["ratio"] == pytest.approx(4.0, rel=0.03) + assert report["slow"]["grouped"] == ["black-hole", "community-child", "planet"] + assert report["fast"]["grouped"] == ["black-hole", "community-child", "planet"] + assert report["slow"]["localDistance"] > 14 + # The fast endpoint is allowed to widen the local orbit modestly; it must not detach the + # planet from the same moving community system or collapse the local band. + assert report["fast"]["localDistance"] > report["slow"]["localDistance"] + assert report["fast"]["localDistance"] < 25 + assert report["slowKinematic"]["travel"] > 0 + assert report["fastKinematic"]["travel"] > report["slowKinematic"]["travel"] + assert report["kinematicRatio"] > 1.8 + assert report["slowKinematic"]["grouped"] == ["black-hole", "community-child", "planet"] + assert report["fastKinematic"]["grouped"] == ["black-hole", "community-child", "planet"] + assert report["fastKinematic"]["localDistance"] > report["slowKinematic"]["localDistance"] + + +@requires_node +def test_carrier_support_adopts_post_contact_phase_without_snapback() -> None: + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 64, radius: 8, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'child', community_id: 'core', system_anchor_id: 'black-hole', + gravity_mass: 2, radius: 3, x: 50 * Math.cos(.4), y: 50 * Math.sin(.4), + vx: 0, vy: 0 }, + ]; + Object.defineProperty(nodes[1], '__galaxyCoreLaneRadius', { + value: 50, writable: true, configurable: true, enumerable: false, + }); + Object.defineProperty(nodes[1], '__galaxyCoreLaneAngle', { + value: 0, writable: true, configurable: true, enumerable: false, + }); + const before = Math.atan2(nodes[1].y, nodes[1].x); + I.supportGalaxyCarrierOrbits(nodes, { + gravity: 48, softening: 32, centralSoftening: 40, + orbitalSpeed: 100, layoutSeed: 11, timestep: .032, + }); + const after = Math.atan2(nodes[1].y, nodes[1].x); + emit({ before, after, step: after - before, + laneAngle: nodes[1].__galaxyCoreLaneAngle }); + """ + ) + assert report["before"] == pytest.approx(0.4, abs=1e-12) + assert report["after"] == pytest.approx(report["before"], abs=0.1) + assert report["after"] > 0.3 + assert abs(report["step"]) < 0.1 + assert report["laneAngle"] == pytest.approx(report["after"], abs=1e-12) + + +@requires_node +def test_managed_carrier_ring_preserves_phase_spacing_after_force_kicks() -> None: + """Admitted systems on one ring must co-rotate instead of adopting divergent force phase.""" + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', gravity_mass: 64, radius: 8, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'star-a', anchor_role: 'community', community_id: 'a', + system_anchor_id: 'star-a', gravity_mass: 8, radius: 5, + x: 80, y: 0, vx: 0, vy: 0 }, + { id: 'planet-a', community_id: 'a', system_anchor_id: 'star-a', + orbit_radius: 18, gravity_mass: 1, radius: 2, + x: 98, y: 0, vx: 0, vy: 0 }, + { id: 'star-b', anchor_role: 'community', community_id: 'b', + system_anchor_id: 'star-b', gravity_mass: 8, radius: 5, + x: -80, y: 0, vx: 0, vy: 0 }, + { id: 'planet-b', community_id: 'b', system_anchor_id: 'star-b', + orbit_radius: 18, gravity_mass: 1, radius: 2, + x: -98, y: 0, vx: 0, vy: 0 }, + ]; + I.establishGalaxyCarrierLanes(nodes, { gap: 4, layoutSeed: 41 }); + const stars = [nodes[1], nodes[3]]; + const initial = stars.map(node => ({ radius: node.__galaxyCarrierLaneRadius, + angle: node.__galaxyCarrierLaneAngle, managed: node.__galaxyCarrierLaneManaged })); + const rotateGroup = (star, planet, offset) => { + const localX = planet.x - star.x, localY = planet.y - star.y; + const radius = star.__galaxyCarrierLaneRadius; + const targetAngle = star.__galaxyCarrierLaneAngle + offset; + star.x = Math.cos(targetAngle) * radius; + star.y = Math.sin(targetAngle) * radius; + planet.x = star.x + localX; planet.y = star.y + localY; + }; + rotateGroup(nodes[1], nodes[2], .55); + rotateGroup(nodes[3], nodes[4], -.37); + I.supportGalaxyCarrierOrbits(nodes, { + gravity: 48, softening: 32, centralSoftening: 40, + orbitalSpeed: 100, layoutSeed: 41, timestep: .032, + authoritativeCarrierPosition: true, + }); + const after = stars.map(node => ({ radius: Math.hypot(node.x, node.y), + angle: Math.atan2(node.y, node.x), laneAngle: node.__galaxyCarrierLaneAngle })); + const delta = (left, right) => Math.atan2(Math.sin(right - left), + Math.cos(right - left)); + const field = I.galaxyBlackHoleField(nodes, { + gravity: 48, softening: 32, centralSoftening: 40, + }); + emit({ initial, after, + carrierSpeedGain: I.galaxyAuthoredCarrierTargetSpeed( + field, initial[0].radius, 100 + ) / I.galaxyCarrierTargetSpeed(field, initial[0].radius, 100), + initialSpacing: delta(initial[0].angle, initial[1].angle), + finalSpacing: delta(after[0].angle, after[1].angle), + localDistances: [Math.hypot(nodes[2].x - nodes[1].x, nodes[2].y - nodes[1].y), + Math.hypot(nodes[4].x - nodes[3].x, nodes[4].y - nodes[3].y)] }); + """ + ) + assert all(item["managed"] is True for item in report["initial"]) + assert report["initial"][0]["radius"] == pytest.approx( + report["initial"][1]["radius"], abs=1e-12 + ) + assert math.sin(report["finalSpacing"]) == pytest.approx( + math.sin(report["initialSpacing"]), abs=1e-12 + ) + assert math.cos(report["finalSpacing"]) == pytest.approx( + math.cos(report["initialSpacing"]), abs=1e-12 + ) + assert report["carrierSpeedGain"] == pytest.approx(1.3) + assert all(distance == pytest.approx(18, abs=1e-12) for distance in report["localDistances"]) + + +@requires_node +def test_live_carrier_support_rotates_without_a_preseeded_lane_cache() -> None: + """Filtered/reloaded live scenes must still visibly orbit instead of only gaining velocity.""" + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 64, radius: 8, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'star', anchor_role: 'community', community_id: 'solar', + system_anchor_id: 'star', gravity_mass: 8, radius: 5, + x: 120, y: 0, vx: 0, vy: 0 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', + gravity_mass: 1, radius: 2, x: 135, y: 0, vx: 0, vy: 0 }, + ]; + const options = { + gravity: 48, softening: 32, centralSoftening: 40, + orbitalSpeed: 100, layoutSeed: 19, timestep: .032, + authoritativeCarrierPosition: true, + }; + const before = Math.atan2(nodes[1].y, nodes[1].x); + I.supportGalaxyCarrierOrbits(nodes, options); + const first = { + angle: Math.atan2(nodes[1].y, nodes[1].x), + radius: Math.hypot(nodes[1].x, nodes[1].y), + localDistance: Math.hypot(nodes[2].x - nodes[1].x, nodes[2].y - nodes[1].y), + }; + /* Simulate a force kick after the cache was admitted. The next support pass must + restore the original painted lane, not expand it to follow that escaped position. */ + nodes[1].x += 80; + nodes[2].x += 80; + I.supportGalaxyCarrierOrbits(nodes, options); + emit({ + before, first, + second: { + angle: Math.atan2(nodes[1].y, nodes[1].x), + radius: Math.hypot(nodes[1].x, nodes[1].y), + localDistance: Math.hypot(nodes[2].x - nodes[1].x, nodes[2].y - nodes[1].y), + }, + cachedRadius: nodes[1].__galaxyCarrierLaneRadius, + }); + """ + ) + assert report["first"]["angle"] != pytest.approx(report["before"], abs=1e-12) + assert report["first"]["radius"] == pytest.approx(120, abs=1e-9) + assert report["second"]["radius"] == pytest.approx(report["cachedRadius"], abs=1e-9) + assert report["second"]["radius"] == pytest.approx(120, abs=1e-9) + assert report["second"]["localDistance"] == pytest.approx(report["first"]["localDistance"], abs=1e-9) + + +@requires_node +def test_system_velocity_guard_preserves_black_hole_carrier_before_local_motion() -> None: + report = _run_node( + """ + const nodes = [ + { id: 'star', anchor_role: 'community', community_id: 'solar', + gravity_mass: 8, x: 120, y: 0, vx: 0, vy: 18 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', + gravity_mass: 1, x: 135, y: 0, vx: 0, vy: -30 }, + ]; + const beforeCarrier = { vx: nodes[0].vx, vy: nodes[0].vy }; + const guard = I.stabilizeGalaxySystemVelocities(nodes, { + limit: 48, absoluteLimit: 50, + }); + emit({ beforeCarrier, afterCarrier: { vx: nodes[0].vx, vy: nodes[0].vy }, + planetSpeed: Math.hypot(nodes[1].vx, nodes[1].vy), + localSpeed: Math.hypot(nodes[1].vx - nodes[0].vx, + nodes[1].vy - nodes[0].vy), guard }); + """ + ) + assert report["afterCarrier"] == pytest.approx(report["beforeCarrier"], abs=1e-12) + assert report["planetSpeed"] <= 50 + 1e-12 + assert report["localSpeed"] <= 32 + 1e-12 + assert report["guard"]["systems"] == 1 + + +@requires_node +def test_black_hole_field_is_twice_local_gravity_and_uses_only_anchor_mass() -> None: + report = _run_node( + """ + const local = [ + { id: 'star', community_id: 'solar', gravity_mass: 8, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'planet', community_id: 'solar', gravity_mass: 1, + x: 120, y: 0, vx: 0, vy: 0 }, + ]; + I.applyGalaxyGravity(local, { gravity: 48, softening: 40, alpha: 1 }); + const central = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 8, x: 0, y: 0 }, + { id: 'outer', community_id: 'outer', gravity_mass: 1, x: 120, y: 0 }, + ]; + const centralField = I.galaxyBlackHoleField(central, { + gravity: 48, softening: 40, haloScale: 1e9, accelerationCap: 1e9, + }); + const withBulge = I.galaxyBlackHoleField([ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 8, x: 0, y: 0 }, + { id: 'bulge', community_id: 'core', gravity_mass: 100, x: 5, y: 0 }, + { id: 'outer', community_id: 'outer', gravity_mass: 1, x: 120, y: 0 }, + ], { gravity: 48, softening: 40, accelerationCap: 1e9 }); + emit({ + constants: [I.galaxyBlackHoleGravityConstant(48), + I.galaxyLocalGravityConstant(48)], + accelerationRatio: Math.abs(centralField.systems[0].ax / local[1].vx), + masses: [withBulge.coreMass, withBulge.haloMass, withBulge.totalMass], + }); + """ + ) + assert report["constants"] == [480, 240] + assert report["accelerationRatio"] == pytest.approx(2, rel=1e-12) + assert report["masses"] == [8, 101, 109] + + +@requires_node +def test_spacetime_field_tuning_is_softened_precessing_and_preserves_local_frames() -> None: + """Advanced black-hole controls alter one softened carrier field, never a planet's frame. + + The near-horizon pass must add a finite Lense--Thirring-like tangent and expose a smooth + visual warp. An external solar system receives that carrier delta as a unit, which is the + important physical invariant: its planets keep orbiting their star while the whole system + precesses around the black hole. The decay pass is intentionally tangential-only and must + likewise leave the star-relative velocity unchanged. + """ + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 64, radius: 10, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'star', anchor_role: 'community', community_id: 'solar', + system_anchor_id: 'star', gravity_mass: 8, radius: 4, + x: 26, y: 0, vx: 0, vy: 3.2 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', + gravity_mass: 1, radius: 2, x: 32, y: 0, vx: -1.1, vy: 4.6 }, + ]; + const local = () => ({ + vx: nodes[2].vx - nodes[1].vx, + vy: nodes[2].vy - nodes[1].vy, + }); + const baseline = I.galaxyBlackHoleField(nodes, { + gravity: 48, softening: 40, gravitationalConstant: 1, blackHoleMass: 1, + accelerationCap: 1e9, + }); + const tuned = I.galaxyBlackHoleField(nodes, { + gravity: 48, softening: 40, gravitationalConstant: 2, blackHoleMass: 3, + accelerationCap: 1e9, + }); + const before = local(); + const spacetime = I.applyGalaxySpacetimeAcceleration(nodes, { + gravity: 48, softening: 40, gravitationalConstant: 2, blackHoleMass: 3, + blackHoleExclusionPadding: 2.5, frameDraggingFraction: .04, + frameDraggingMaxAcceleration: .5, eventHorizonInwardAcceleration: .35, + }); + const afterDrag = local(); + const decay = I.applyGalaxyEventHorizonDecay(nodes, { + timestep: .032, eventHorizonDecayRate: .25, + }); + const afterDecay = local(); + emit({ baseline: { core: baseline.coreMass, gravity: baseline.gravitationalConstant }, + tuned: { core: tuned.coreMass, gravity: tuned.gravitationalConstant }, + before, afterDrag, afterDecay, spacetime, decay, + warp: [nodes[1].__galaxySpacetimeWarp, nodes[2].__galaxySpacetimeWarp], + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), + }); + """ + ) + assert report["finite"] is True + assert report["tuned"]["core"] == pytest.approx(report["baseline"]["core"] * 3) + assert report["tuned"]["gravity"] == pytest.approx(report["baseline"]["gravity"] * 2 * 3) + assert report["spacetime"]["systems"] == 1 + assert report["spacetime"]["warpedNodes"] == 2 + assert report["spacetime"]["maximumWarp"] > 0 + assert report["spacetime"]["maximumFrameDragAcceleration"] > 0 + assert report["spacetime"]["maximumHorizonAcceleration"] > 0 + assert max(report["warp"]) > 0 + # Carrier-only perturbations are identical for every body in the system. + assert report["afterDrag"] == pytest.approx(report["before"], abs=1e-12) + assert report["decay"]["systems"] == 1 + assert report["decay"]["maximumVelocityRemoved"] > 0 + assert report["afterDecay"] == pytest.approx(report["before"], abs=1e-12) + + +@requires_node +def test_black_hole_mass_adds_ten_percent_core_gravity_per_tenth_multiplier() -> None: + report = _run_node( + """ + const make = () => [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 80, radius: 10, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'outer-star', anchor_role: 'community', community_id: 'outer', + system_anchor_id: 'outer-star', gravity_mass: 8, radius: 5, + x: 180, y: 0, vx: 0, vy: 0 }, + ]; + const sample = blackHoleMass => { + const field = I.galaxyBlackHoleField(make(), { + gravity: 48, gravitationalConstant: 1, blackHoleMass, + softening: 40, haloScale: 1e9, accelerationCap: 1e9, + }); + return { + coreMass: field.coreMass, + coreGravity: field.coreMass * field.gravitationalConstant, + haloMass: field.haloMass, + gravitationalConstant: field.gravitationalConstant, + }; + }; + emit({ baseline: sample(1), plusTen: sample(1.1), plusTwenty: sample(1.2) }); + """ + ) + + baseline = report["baseline"] + assert report["plusTen"]["coreGravity"] == pytest.approx( + baseline["coreGravity"] * 1.1 * 1.1 + ) + assert report["plusTwenty"]["coreGravity"] == pytest.approx( + baseline["coreGravity"] * 1.2 * 1.2 + ) + for sample in report.values(): + assert sample["haloMass"] == baseline["haloMass"] + # gravitationalConstant now scales linearly with blackHoleMassMultiplier + # (the on-disk engine removed the sqrt in favour of a linear path so the + # slider visibly multiplies the central pull). + assert report["plusTen"]["gravitationalConstant"] == pytest.approx( + baseline["gravitationalConstant"] * 1.1 + ) + assert report["plusTwenty"]["gravitationalConstant"] == pytest.approx( + baseline["gravitationalConstant"] * 1.2 + ) + + +@requires_node +def test_hierarchical_center_and_star_g_have_exact_velocity_superposition() -> None: + """G_center moves the star carrier; G_star only changes the planet's local tangent.""" + report = _run_node( + """ + const make = () => [ + { id: 'arbitrary-singularity-orbit-root', anchor_role: 'global', community_id: 'core', + gravity_mass: 64, radius: 9, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'Users', anchor_role: 'community', community_id: 'users', system_anchor_id: 'Users', + gravity_mass: 10, radius: 5, x: 168, y: 24, vx: 0, vy: 0 }, + { id: 'Pre-PR', community_id: 'users', system_anchor_id: 'Users', orbit_tier: 1, + gravity_mass: 1, radius: 2.5, x: 198, y: 24, vx: 0, vy: 0 }, + ]; + const run = (centerG, starG) => { + const nodes = make(), star = nodes[1], planet = nodes[2]; + I.seedGalaxyOrbits(nodes, 118, 48, 32, false, + { gravitationalConstant: centerG, localGravitationalConstant: starG }); + I.seedGalaxySystemOrbits(nodes, 118, 48, 40, false, + { gravitationalConstant: centerG, localGravitationalConstant: starG }); + const local = { vx: planet.vx - star.vx, vy: planet.vy - star.vy }; + const dx = planet.x - star.x, dy = planet.y - star.y; + return { carrier: { vx: star.vx, vy: star.vy }, local, + sumError: Math.hypot(planet.vx - (star.vx + local.vx), + planet.vy - (star.vy + local.vy)), + tangent: dx * local.vy - dy * local.vx, + radial: dx * local.vx + dy * local.vy, + localSpeed: Math.hypot(local.vx, local.vy), + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), + }; + }; + const explicitRoleWins = I.galaxyGlobalAnchor([ + { id: 'arbitrary-singularity-orbit-root', anchor_role: 'global', gravity_mass: 1, x: 0, y: 0 }, + { id: 'Coding-Dev-Tools', gravity_mass: 999, x: 1, y: 0 }, + ]).id; + const massFallbackWins = I.galaxyGlobalAnchor([ + { id: 'small-ordinary', gravity_mass: 4, x: 0, y: 0 }, + { id: 'largest-ordinary', gravity_mass: 12, x: 1, y: 0 }, + ]).id; + emit({ base: run(1, 1), centerOnly: run(2, 1), starOnly: run(1, 2), + explicitRoleWins, massFallbackWins }); + """ + ) + for sample in (report["base"], report["centerOnly"], report["starOnly"]): + assert sample["finite"] is True + assert sample["sumError"] < 1e-12 + assert abs(sample["tangent"]) > 1e-5 + assert abs(sample["radial"]) < 1e-8 + # A center-only change changes the black-hole carrier, while a star-only change leaves it. + assert report["centerOnly"]["carrier"] != pytest.approx(report["base"]["carrier"], abs=1e-8) + assert report["starOnly"]["carrier"] == pytest.approx(report["base"]["carrier"], abs=1e-10) + assert report["centerOnly"]["localSpeed"] == pytest.approx(report["base"]["localSpeed"], rel=1e-10) + assert report["starOnly"]["localSpeed"] > report["base"]["localSpeed"] * 1.35 + assert report["explicitRoleWins"] == "arbitrary-singularity-orbit-root" + assert report["massFallbackWins"] == "largest-ordinary" + + +@requires_node +def test_arbitrary_global_label_and_community_stars_keep_nested_orbits() -> None: + """An arbitrary central label supports the same Users/Pre-PR nested hierarchy.""" + report = _run_node( + """ + const nodes = [ + { id: 'workspace-orbit-root', anchor_role: 'global', community_id: 'core', + gravity_mass: 80, radius: 10, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'Users', anchor_role: 'community', community_id: 'users', system_anchor_id: 'Users', + gravity_mass: 10, radius: 5, x: 160, y: 20, vx: 0, vy: 0 }, + { id: 'users-planet', community_id: 'users', system_anchor_id: 'Users', orbit_tier: 1, + gravity_mass: 1, radius: 2, x: 188, y: 20, vx: 0, vy: 0 }, + { id: 'Pre-PR', anchor_role: 'community', community_id: 'pre-pr', system_anchor_id: 'Pre-PR', + gravity_mass: 9, radius: 5, x: -142, y: 34, vx: 0, vy: 0 }, + { id: 'pre-pr-planet', community_id: 'pre-pr', system_anchor_id: 'Pre-PR', orbit_tier: 1, + gravity_mass: 1, radius: 2, x: -116, y: 34, vx: 0, vy: 0 }, + ]; + I.seedGalaxyOrbits(nodes, 71, 48, 32, false, + { gravitationalConstant: 1, localGravitationalConstant: 1 }); + I.seedGalaxySystemOrbits(nodes, 71, 48, 40, false, + { gravitationalConstant: 1, localGravitationalConstant: 1 }); + const byId = new Map(nodes.map(node => [node.id, node])); + const local = (starId, planetId) => { + const star = byId.get(starId), planet = byId.get(planetId); + const dx = planet.x - star.x, dy = planet.y - star.y; + const vx = planet.vx - star.vx, vy = planet.vy - star.vy; + return { anchor: star.system_anchor_id, + tangent: dx * vy - dy * vx, radial: dx * vx + dy * vy }; + }; + emit({ global: I.galaxyGlobalAnchor(nodes).id, + users: local('Users', 'users-planet'), prePr: local('Pre-PR', 'pre-pr-planet') }); + """ + ) + assert report["global"] == "workspace-orbit-root" + for system, star_id in ((report["users"], "Users"), (report["prePr"], "Pre-PR")): + assert system["anchor"] == star_id + assert abs(system["tangent"]) > 1e-5 + assert abs(system["radial"]) < 1e-8 + + +@requires_node +def test_horizon_warp_is_carrier_only_and_never_adds_planet_black_hole_physics() -> None: + """Near-horizon effects translate a complete solar system without a per-planet tide.""" + report = _run_node( + """ + const make = radius => [ + { id: 'custom-heavy-center-δ', anchor_role: 'global', community_id: 'core', + gravity_mass: 64, radius: 10, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'star', anchor_role: 'community', community_id: 'solar', system_anchor_id: 'star', + gravity_mass: 9, radius: 4, x: radius, y: 0, vx: 0, vy: 2 }, + { id: 'radial-planet', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 1, + gravity_mass: 1, radius: 2, x: radius + 12, y: 0, vx: 0, vy: 3 }, + { id: 'tangent-planet', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 2, + gravity_mass: 1, radius: 2, x: radius, y: 12, vx: -1, vy: 2 }, + ]; + const sample = radius => { + const nodes = make(radius); + const stats = I.applyGalaxySpacetimeAcceleration(nodes, { + gravity: 48, gravitationalConstant: 1, blackHoleMass: 1, softening: 16, + blackHoleExclusionPadding: 2.5, tidalStrengthFraction: .18, + tidalAccelerationCap: .16, frameDraggingFraction: .018, + }); + const changes = nodes.map(node => stats.accelerations.get(node) || { ax: 0, ay: 0 }); + return { stats, changes, warp: nodes.slice(1).map(node => node.__galaxySpacetimeWarp), + finite: nodes.every(node => [node.x,node.y,node.vx,node.vy].every(Number.isFinite)) }; + }; + emit({ near: sample(22), far: sample(180) }); + """ + ) + near, far = report["near"], report["far"] + assert near["finite"] is far["finite"] is True + assert near["stats"]["tidalSystems"] == near["stats"]["tidalPlanets"] == 0 + assert near["stats"]["maximumTidalAcceleration"] == 0 + # Every descendant inherits exactly the star's black-hole-frame acceleration. + assert abs(near["changes"][1]["ax"]) + abs(near["changes"][1]["ay"]) > 0 + assert near["changes"][2] == pytest.approx(near["changes"][1], abs=1e-12) + assert near["changes"][3] == pytest.approx(near["changes"][1], abs=1e-12) + assert max(near["warp"]) > 0 + assert far["stats"]["tidalSystems"] == far["stats"]["tidalPlanets"] == 0 + assert far["stats"]["maximumTidalAcceleration"] == 0 + assert max(far["warp"]) == 0 + + +@requires_node +def test_slingshot_capture_preserves_authored_star_and_high_speed_release_escapes() -> None: + """Sub-escape drag releases enter a star orbit; genuine escape releases stay untouched.""" + report = _run_node( + """ + const nodes = [ + { id: 'custom-heavy-center-ζ', anchor_role: 'global', community_id: 'core', + gravity_mass: 64, radius: 9, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'Users', anchor_role: 'community', community_id: 'users', system_anchor_id: 'Users', + gravity_mass: 10, radius: 5, x: 80, y: 0, vx: 2, vy: -1 }, + { id: 'users-planet', community_id: 'users', system_anchor_id: 'Users', orbit_tier: 1, + gravity_mass: 1, radius: 2, x: 105, y: 0, vx: 0, vy: 0 }, + ]; + const planet = nodes[2], before = { anchor: planet.system_anchor_id, community: planet.community_id }; + const options = { gravity: 48, localGravitationalConstant: 1, softening: 16, + layoutSeed: 19, captureRadius: 120 }; + const captured = I.galaxySlingshotCapture(planet, nodes, { vx: 2, vy: -1 }, options); + const escaped = I.galaxySlingshotCapture(planet, nodes, { vx: 100, vy: -1 }, options); + emit({ captured, escaped, before, after: { anchor: planet.system_anchor_id, + community: planet.community_id }, finite: [captured, escaped].every(value => + [value.vx, value.vy, value.circularSpeed, value.escapeSpeed].every(Number.isFinite)) }); + """ + ) + assert report["finite"] is True + assert report["before"] == report["after"] == {"anchor": "Users", "community": "users"} + captured, escaped = report["captured"], report["escaped"] + assert captured["eligible"] is True and captured["captured"] is True and captured["escaped"] is False + assert captured["reason"] == "authored-anchor" and captured["starId"] == "Users" + assert captured["radius"] == pytest.approx(25) + assert 0 < captured["circularSpeed"] < captured["escapeSpeed"] + assert escaped["eligible"] is True and escaped["captured"] is False and escaped["escaped"] is True + assert escaped["reason"] == "escape-velocity" + assert [escaped["vx"], escaped["vy"]] == pytest.approx([100, -1]) + + +@requires_node +def test_spacetime_canvas_warps_the_grid_and_bounds_trails_without_dom_nodes() -> None: + """The visual layer is one bounded canvas, not a hidden second graph implementation.""" + report = _run_spacetime_node( + """ + const calls = { arcs: 0, ellipses: 0, lines: 0, gradients: 0, linearGradients: 0 }; + const gradient = { addColorStop() {} }; + const ctx = { + setTransform() {}, clearRect() {}, save() {}, restore() {}, beginPath() {}, + moveTo() { calls.lines++; }, lineTo() { calls.lines++; }, stroke() {}, fill() {}, + arc() { calls.arcs++; }, ellipse() { calls.ellipses++; }, + createRadialGradient() { calls.gradients++; return gradient; }, + createLinearGradient() { calls.linearGradients++; return gradient; }, + set globalCompositeOperation(value) {}, set lineWidth(value) {}, + set strokeStyle(value) {}, set fillStyle(value) {}, + }; + const frames = []; + globalThis.requestAnimationFrame = callback => { frames.push(callback); return frames.length; }; + globalThis.cancelAnimationFrame = () => {}; + let reduceMotion = false; + globalThis.matchMedia = () => ({ matches: reduceMotion }); + globalThis.window = { devicePixelRatio: 1 }; + const documentListeners = {}; + globalThis.document = { hidden: false, + addEventListener(type, callback) { documentListeners[type] = callback; }, + removeEventListener(type) { delete documentListeners[type]; }, + createElement() { return { + width: 0, height: 0, className: '', setAttribute() {}, remove() {}, + getContext() { return ctx; }, + }; } }; + const listeners = {}; + const container = { + clientWidth: 900, clientHeight: 600, children: [], + appendChild(node) { this.children.push(node); }, + addEventListener(type, callback) { listeners[type] = callback; }, + removeEventListener(type) { delete listeners[type]; }, + }; + const snapshot = count => ({ + center: { x: 0, y: 0, radius: 11 }, + nodes: Array.from({ length: count }, (_, index) => ({ + id: 'node-' + index, x: 32 + index, y: index % 19, + vx: 1 + index / 10, vy: .5, radius: 2, + })), + systemAnchors: Array.from({ length: 30 }, (_, index) => ({ + id: 'star-' + index, x: 50 + index * 18, y: index % 4 * 12, + radius: 4, mass: 40 - index, orbitRadius: 26, + })), + viewport: { x: 450, y: 300, zoom: 1 }, + }); + let current = snapshot(180); + const engine = { + getPhysicsSnapshot: () => current, + graphToScreen: (x, y) => ({ x: x + 450, y: y + 300 }), + }; + new Function('window', source)(window); + const overlay = window.EngraphisSpacetime.create(container, engine); + overlay.setEnabled(true); + frames.shift()(40); // samples the 160 fastest bodies + frames.shift()(80); // paints their trails + const small = { ...calls, canvasCount: container.children.length }; + reduceMotion = true; + frames.shift()(96); // local wells stay visible; trails do not repaint under reduced motion + const reduced = { ...calls, queued: frames.length }; + current = snapshot(601); + reduceMotion = false; + frames.shift()(120); + const dense = { ...calls }; + current = { ...snapshot(180), paused: true }; + frames.shift()(160); // final static paint, then no idle orbit overlay rAF + const paused = { queued: frames.length, ellipses: calls.ellipses }; + overlay.destroy(); + emit({ small, reduced, dense, paused, childrenAfterDestroy: container.children.length, + listenerDetached: !listeners.engraphisgraphphysicschange, + visibilityDetached: !documentListeners.visibilitychange }); + """ + ) + assert report["small"]["canvasCount"] == 1 + assert report["small"]["arcs"] > 0 and report["small"]["lines"] > 0 + # Both sampled frames paint the 24 highest-mass local stars, with two guide rings each. + assert report["small"]["ellipses"] == 24 * 2 * 2 + # Reduced motion removes velocity blur, not the static local solar-system guide rings. + assert report["reduced"]["ellipses"] == report["small"]["ellipses"] + 24 * 2 + # One capped canvas pass renders at most the 160 selected velocity trails; a >600-node + # graph clears them rather than paying a linear trail cost in the next paint. + assert 0 < report["small"]["linearGradients"] <= 160 + assert report["dense"]["linearGradients"] == report["small"]["linearGradients"] + assert report["paused"]["queued"] == 0 + assert report["listenerDetached"] is True + assert report["visibilityDetached"] is True + + +@requires_node +def test_advanced_spacetime_controls_pause_live_orbits_and_drag_release_is_bounded() -> None: + """The public controls drive one observable physics state, including slingshot release.""" + report = _run_engine( + """ + let released = null; + const api = G.create(el, { onSlingshotRelease: value => { released = value; } }); + api.setData({ nodes: [ + { id: 'custom-heavy-center-kappa', anchor_role: 'global', community_id: 'core', gravity_mass: 32, + radius: 8, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'Coding-Dev-Tools', community_id: 'decoy', gravity_mass: 999, + radius: 5, x: -140, y: 0, vx: 0, vy: 0 }, + { id: 'Users', anchor_role: 'community', community_id: 'users', system_anchor_id: 'Users', + gravity_mass: 9, radius: 5, x: 92, y: 0, vx: 0, vy: 0 }, + { id: 'users-planet', community_id: 'users', system_anchor_id: 'Users', orbit_tier: 1, + gravity_mass: 1, radius: 2, x: 118, y: 0, vx: 0, vy: 0 }, + { id: 'dragged', community_id: 'outer', gravity_mass: 2, + radius: 4, x: 60, y: 0, vx: 0, vy: 0 }, + ], edges: [] }); + api.setSettings({ gravitationalConstant: 1.75, blackHoleMass: 3.5, + localGravitationalConstant: 2.25, damping: .4, springStiffness: 2.25, orbitPaused: true }); + const paused = { state: JSON.parse(JSON.stringify(api.state().settings)), diagnostics: api.physicsDiagnostics(), + snapshot: api.getPhysicsSnapshot() }; + api.setSettings({ G_star: 1.4, orbitPaused: false }); + const node = store.graphData.nodes.find(item => item.id === 'dragged'); + store.screen2GraphCoords = (x, y) => ({ x, y }); + const event = (x, y, time) => ({ button: 0, isPrimary: true, pointerId: 7, + clientX: x, clientY: y, timeStamp: time, + preventDefault() {}, stopPropagation() {} }); + elListeners.pointerdown(event(node.x, node.y, 1)); + engineWindowListeners.pointermove(event(node.x + 6, node.y, 10)); + engineWindowListeners.pointermove(event(node.x + 18, node.y, 34)); + engineWindowListeners.pointerup(event(node.x + 18, node.y, 35)); + emit({ paused, live: api.physicsDiagnostics(), released, + snapshot: api.getPhysicsSnapshot(), node: { vx: node.vx, vy: node.vy, fx: node.fx, fy: node.fy } }); + """ + ) + state = report["paused"]["state"] + diagnostics = report["paused"]["diagnostics"] + assert state["gravitationalConstant"] == pytest.approx(1.75) + assert state["blackHoleMass"] == pytest.approx(3.5) + assert state["localGravitationalConstant"] == pytest.approx(2.25) + assert state["damping"] == pytest.approx(0.4) + assert state["springStiffness"] == pytest.approx(2.25) + assert state["orbitPaused"] is True + assert diagnostics["orbitPaused"] is True and diagnostics["active"] is False + assert diagnostics["G_center"] == pytest.approx(1.75) + assert diagnostics["G_star"] == pytest.approx(2.25) + assert report["paused"]["snapshot"]["paused"] is True + assert report["paused"]["snapshot"]["center"]["id"] == "custom-heavy-center-kappa" + anchors = report["paused"]["snapshot"]["systemAnchors"] + assert len(anchors) == 1 + assert {key: anchors[0][key] for key in ("id", "x", "y", "mass", "memberCount", + "systemOrbitRadius", "galacticOrbitRadius", "communityId")} == { + "id": "Users", "x": 92, "y": 0, "mass": 9, "memberCount": 2, + "systemOrbitRadius": 26, "galacticOrbitRadius": 92, "communityId": "users", + } + assert anchors[0]["radius"] > 0 + snapshot_users = next(node for node in report["paused"]["snapshot"]["nodes"] + if node["id"] == "Users") + snapshot_planet = next(node for node in report["paused"]["snapshot"]["nodes"] + if node["id"] == "users-planet") + assert snapshot_users["isSystemAnchor"] is True and snapshot_users["anchorRole"] == "community" + assert snapshot_planet["systemAnchorId"] == "Users" and snapshot_planet["orbitTier"] == 1 + assert report["live"]["orbitPaused"] is False + assert report["live"]["G_star"] == pytest.approx(1.4) + assert report["released"]["id"] == "dragged" + assert 0 < report["released"]["speed"] <= 24 + assert report["node"].get("fx") is report["node"].get("fy") is None + assert [report["node"]["vx"], report["node"]["vy"]] == pytest.approx( + [report["released"]["vx"], report["released"]["vy"]] + ) + assert report["snapshot"]["slingshot"] == report["released"] + + +@requires_node +def test_gravity_zero_leaves_the_galactic_field_weak_and_stellar_floor_intact() -> None: + """Zero weakens the galaxy-wide field without removing local stellar orbit support.""" + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', orbit_tier: 0, gravity_mass: 20, radius: 10, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'core-planet', community_id: 'core', system_anchor_id: 'black-hole', + orbit_tier: 1, gravity_mass: 1, radius: 3, + x: 45, y: 0, vx: 0, vy: 0 }, + { id: 'star', anchor_role: 'community', community_id: 'solar', + system_anchor_id: 'star', orbit_tier: 0, gravity_mass: 8, 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: 3, + x: 150, y: 0, vx: 0, vy: 0 }, + ]; + I.seedGalaxyOrbits(nodes, 404, 0, 38.4, false); + I.seedGalaxySystemOrbits(nodes, 404, 0, 48, false); + const [blackHole, corePlanet, star, planet] = nodes; + const systemCenter = () => ({ + x: (star.x * 8 + planet.x) / 9, + y: (star.y * 8 + planet.y) / 9, + vx: (star.vx * 8 + planet.vx) / 9, + vy: (star.vy * 8 + planet.vy) / 9, + }); + const relative = () => ({ + x: planet.x - star.x, y: planet.y - star.y, + vx: planet.vx - star.vx, vy: planet.vy - star.vy, + }); + const before = { center: systemCenter(), relative: relative(), + blackHole: [blackHole.x, blackHole.y, blackHole.vx, blackHole.vy], + corePlanet: [corePlanet.x, corePlanet.y, corePlanet.vx, corePlanet.vy] }; + let previousAngle = Math.atan2(before.relative.y, before.relative.x); + let previousGlobalAngle = Math.atan2(before.center.y, before.center.x); + let angularTravel = 0, globalAngularTravel = 0, + minimumRadius = Infinity, maximumRadius = 0, tick; + for (let step = 0; step < 180; step += 1) { + tick = I.integrateGalaxyLeapfrog(nodes, [], [], { + gravity: 0, softening: 38.4, centralSoftening: 48, + includeMutualSystems: false, includeRelations: false, + includeOrbitalSeparation: false, skipSystemAnchorPairs: true, + systemAnchorExclusionPadding: 1.5, systemAnchorRepulsionAcceleration: 0, + includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, + includeFarFieldConfinement: false, inwardConvergence: false, + localRelativeSpeedLimit: 48, timestep: 0.032, wallClockSeconds: 1 / 30, + velocityDecay: 0.0001, speedLimit: 48, includeCollisions: false, + }); + const phase = relative(), radius = Math.hypot(phase.x, phase.y); + const angle = Math.atan2(phase.y, phase.x); + angularTravel += Math.atan2(Math.sin(angle - previousAngle), + Math.cos(angle - previousAngle)); + previousAngle = angle; + const center = systemCenter(); + const globalAngle = Math.atan2(center.y, center.x); + globalAngularTravel += Math.atan2(Math.sin(globalAngle - previousGlobalAngle), + Math.cos(globalAngle - previousGlobalAngle)); + previousGlobalAngle = globalAngle; + minimumRadius = Math.min(minimumRadius, radius); + maximumRadius = Math.max(maximumRadius, radius); + } + emit({ + floorSetting: I.galaxyStellarGravityFloorSetting, + mappedSettings: [0, 47, 48, 100, Infinity, NaN] + .map(I.galaxyStellarGravitySetting), + constants: { + blackHole: I.galaxyBlackHoleGravityConstant(0, true), + compatibilityLocal: I.galaxyLocalGravityConstant(0), + stellar: I.galaxyStellarGravityConstant(0), + defaultStellar: I.galaxyStellarGravityConstant(48), + }, + before, after: { center: systemCenter(), relative: relative(), + blackHole: [blackHole.x, blackHole.y, blackHole.vx, blackHole.vy], + corePlanet: [corePlanet.x, corePlanet.y, corePlanet.vx, corePlanet.vy] }, + angularTravel, globalAngularTravel, minimumRadius, maximumRadius, + telemetry: tick.systemGravity, + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)), + }); + """ + ) + assert report["finite"] is True + assert report["floorSetting"] == 48 + assert report["mappedSettings"] == [48, 48, 48, 100, 48, 48] + assert report["constants"] == { + "blackHole": pytest.approx(172.13538461538462), + "compatibilityLocal": 0, + "stellar": 2535.0, + "defaultStellar": 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) + 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 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) + assert report["telemetry"]["eligibleStellarAnchors"] == 1 + assert report["telemetry"]["fallbackAnchors"] == 0 + assert report["telemetry"]["globalAnchors"] == 1 + assert report["telemetry"]["stellarFloorActive"] is True + + +@requires_node +def test_visible_history_ghosts_are_massless_black_hole_test_particles() -> None: + """History must visibly orbit without becoming an invisible extra gravity source.""" + report = _run_node( + """ + const make = ghost => { + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', orbit_tier: 0, gravity_mass: 32, radius: 9, + 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: 8, radius: 5, + x: 126, y: 0, vx: 0, vy: 0 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', + orbit_tier: 1, gravity_mass: 1, radius: 3, + x: 150, y: 18, vx: 0, vy: 0 }, + ]; + if (ghost) nodes.push({ id: 'history', community_id: 'archive', ghost: true, + gravity_mass: 0, radius: 3, x: -108, y: 104, vx: 0, vy: 0, + system_anchor_id: 'black-hole', orbit_tier: 1 }); + return nodes; + }; + const baseline = make(false), haunted = make(true), options = { + gravity: 48, softening: 32, centralSoftening: 40, + includeMutualSystems: true, includeRelations: false, includeBridges: false, + includeOrbitalSeparation: false, skipSystemAnchorPairs: true, + systemAnchorExclusionPadding: 1.5, includeBlackHoleExclusion: true, + blackHoleExclusionPadding: 2.5, includeFarFieldConfinement: true, + farFieldEnvelopeScale: 1.75, farFieldMinimumRadius: 96, + farFieldSoftFraction: .82, farFieldAcceleration: 12, farFieldMaxAcceleration: 16, + localRelativeSpeedLimit: 48, timestep: .032, wallClockSeconds: 1 / 30, + inwardConvergence: true, velocityDecay: .00005, speedLimit: 48, + includeCollisions: false, layoutSeed: 808, + }; + I.seedGalaxyOrbits(baseline, 808, 48, 32, false); + I.seedGalaxySystemOrbits(baseline, 808, 48, 40, false); + I.seedGalaxyOrbits(haunted, 808, 48, 32, false); + I.seedGalaxySystemOrbits(haunted, 808, 48, 40, false); + const ghost = haunted.find(node => node.id === 'history'); + const angle = () => Math.atan2(ghost.y, ghost.x); + let previous = angle(), travel = 0, moved = 0, advanced = 0; + for (let step = 0; step < 180; step += 1) { + I.integrateGalaxyLeapfrog(baseline, [], [], options); + I.integrateGalaxyLeapfrog(haunted, [], [], options); + const orbit = I.integrateGalaxyGhostOrbits(haunted, options); + advanced += orbit.advanced; + const next = angle(); + const delta = Math.atan2(Math.sin(next - previous), Math.cos(next - previous)); + travel += delta; + if (Math.abs(delta) > 1e-8) moved++; + previous = next; + } + const live = nodes => nodes.filter(node => !node.ghost).map(node => + [node.x, node.y, node.vx, node.vy]); + emit({ baseline: live(baseline), haunted: live(haunted), ghost: { + mass: ghost.gravity_mass, x: ghost.x, y: ghost.y, vx: ghost.vx, vy: ghost.vy, + seeded: ghost.__galaxyGhostOrbitSeeded === true, + }, travel, moved, advanced, + finite: haunted.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); + """ + ) + assert report["finite"] is True + assert report["ghost"]["mass"] == 0 + assert report["ghost"]["seeded"] is True + assert report["advanced"] == 180 + assert report["moved"] == 180 + assert abs(report["travel"]) > 0.05 + # Test particles may be painted and moved, but cannot alter the live system's phase space. + assert len(report["haunted"]) == len(report["baseline"]) + for haunted, baseline in zip(report["haunted"], report["baseline"]): + assert haunted == pytest.approx(baseline, abs=1e-10) + + +@requires_node +def test_core_pair_reduction_is_complementary_momentum_safe_and_seed_exact() -> None: + report = _run_node( + """ + const system = (prefix, community, role = 'community') => [ + { id: prefix + '-star', anchor_role: role, community_id: community, + gravity_mass: 4, x: 0, y: 0, vx: 0, vy: 0 }, + { id: prefix + '-planet', community_id: community, + gravity_mass: 1, x: 30, y: 0, vx: 0, vy: 0 }, + ]; + const regularPair = system('regular-pair', 'regular'); + const corePair = system('core-pair', 'core'); + const pairs = [...regularPair, ...corePair]; + I.applyGalaxyGravity(pairs, { + effectiveGravity: I.galaxyGravityConstant(48), + pairFraction: 0.15, + corePairFraction: 0.1125, + coreCommunity: 'core', + softening: 12, + }); + const pairAcceleration = [Math.abs(regularPair[0].vx), Math.abs(corePair[0].vx)]; + const pairMomentum = [regularPair, corePair].map(members => members.reduce( + (sum, node) => sum + node.gravity_mass * node.vx, 0 + )); + + const regularHalo = system('regular-halo', 'regular'); + const coreHalo = system('core-halo', 'core'); + I.applyGalaxySystemHaloGravity([...regularHalo, ...coreHalo], { + gravity: 48, + smoothFraction: 0.85, + coreSmoothFraction: 0.8875, + coreCommunity: 'core', + softening: 12, + accelerationCap: 100, + }); + const relativeX = members => members[1].vx - members[0].vx; + const haloAcceleration = [Math.abs(relativeX(regularHalo)), + Math.abs(relativeX(coreHalo))]; + const haloMomentum = [regularHalo, coreHalo].map(members => members.reduce( + (sum, node) => sum + node.gravity_mass * node.vx, 0 + )); + + const regularCombined = system('regular-combined', 'regular'); + const coreCombined = system('core-combined', 'core'); + const combined = [...regularCombined, ...coreCombined]; + I.applyGalaxyGravity(combined, { + effectiveGravity: I.galaxyGravityConstant(48), pairFraction: 0.15, corePairFraction: 0.1125, + coreCommunity: 'core', softening: 12, + }); + I.applyGalaxySystemHaloGravity(combined, { + gravity: 48, smoothFraction: 0.85, coreSmoothFraction: 0.8875, + coreCommunity: 'core', softening: 12, accelerationCap: 100, + }); + + const seededCore = system('seeded', 'core', 'global'); + seededCore[0].system_anchor_id = 'seeded-star'; + seededCore[1].system_anchor_id = 'seeded-star'; + I.seedGalaxyOrbits(seededCore, 17, 48, 12, false, 0.15, 0.75); + const seededAcceleration = I.galaxyAccelerations(seededCore, [], [], { + gravity: 48, softening: 12, central: false, + eventHorizonInwardAcceleration: 0, frameDraggingFraction: 0, + systemAnchorRepulsionAcceleration: 0, + localPairFraction: 0.15, corePairMultiplier: 0.75, + }); + const relativeSpeed = Math.hypot( + seededCore[1].vx - seededCore[0].vx, + seededCore[1].vy - seededCore[0].vy + ); + const seededRadius = Math.hypot( + seededCore[1].x - seededCore[0].x, + seededCore[1].y - seededCore[0].y, + ); + const radialAcceleration = -( + seededAcceleration.get(seededCore[1]).ax + - seededAcceleration.get(seededCore[0]).ax + ); + + const coincident = [ + { id: 'global', anchor_role: 'global', community_id: 'core', + gravity_mass: 4, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'same', community_id: 'core', gravity_mass: 1, + x: 0, y: 0, vx: 0, vy: 0 }, + ]; + const finiteAcceleration = I.galaxyAccelerations(coincident, [], [], { + gravity: 100, softening: 0.1, central: false, + localPairFraction: 0.15, corePairMultiplier: 0.75, + }); + const halfStep = [{ id: 'half', community_id: 'single', gravity_mass: 1, + x: 3, y: -2, vx: 2, vy: -4 }]; + const oldStep = halfStep.map(node => ({ ...node })); + I.integrateGalaxyLeapfrog(halfStep, [], [], { + gravity: 0, central: false, timestep: 0.021328125, + velocityDecay: 0, speedLimit: 100, includeCollisions: false, + }); + I.integrateGalaxyLeapfrog(oldStep, [], [], { + gravity: 0, central: false, timestep: 0.03046875, + velocityDecay: 0, speedLimit: 100, includeCollisions: false, + }); + emit({ + pairAcceleration, + pairMomentum, + haloAcceleration, + haloMomentum, + combined: [Math.abs(relativeX(regularCombined)), + Math.abs(relativeX(coreCombined))], + seedLaw: [relativeSpeed * relativeSpeed / seededRadius, radialAcceleration], + seededRadius, + driftRatio: [(halfStep[0].x - 3) / (oldStep[0].x - 3), + (halfStep[0].y + 2) / (oldStep[0].y + 2)], + finite: [...finiteAcceleration.values()].every(value => + Number.isFinite(value.ax) && Number.isFinite(value.ay)), + }); + """ + ) + assert report["pairAcceleration"][1] / report["pairAcceleration"][0] == pytest.approx(0.75) + assert report["haloAcceleration"][1] / report["haloAcceleration"][0] == pytest.approx( + 0.8875 / 0.85 + ) + assert report["combined"][1] == pytest.approx(report["combined"][0], rel=1e-12) + assert report["pairMomentum"] == pytest.approx([0, 0], abs=1e-12) + assert report["haloMomentum"] == pytest.approx([0, 0], abs=1e-12) + # Core admission now places children at the contact boundary (compact lanes) rather + # than expanding them beyond the warp band. The seeded radius equals the contact + # distance, which is at least the authored 30-unit separation. + assert report["seededRadius"] >= 30 + assert report["seedLaw"][0] == pytest.approx(report["seedLaw"][1], rel=1e-12) + assert report["driftRatio"] == pytest.approx([0.7, 0.7]) + assert report["finite"] is True + assert "const GALAXY_GRAVITY_RESPONSE_RATE_MULTIPLIER = 1.5;" in ASSET.read_text(encoding="utf-8") + assert "const GALAXY_FIXED_TIMESTEP = 0.032;" in ASSET.read_text(encoding="utf-8") + + +@requires_node +def test_legacy_system_halo_and_anchor_integrator_preserve_free_system_com() -> None: + report = _run_node( + """ + const free = [ + { id: 'star', system_anchor_id: 'star', anchor_role: 'community', + community_id: 'free', gravity_mass: 8, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'inner', system_anchor_id: 'star', orbit_tier: 1, + community_id: 'free', gravity_mass: 2, x: 16, y: 0, vx: 0, vy: 0 }, + { id: 'outer', system_anchor_id: 'star', orbit_tier: 2, + community_id: 'free', gravity_mass: 1, x: 28, y: 0, vx: 0, vy: 0 }, + ]; + const stats = I.applyGalaxySystemHaloGravity(free, { + gravity: 100, softening: 12, smoothFraction: 0.85, + }); + const momentum = free.reduce((sum, node) => sum + + node.gravity_mass * node.vx, 0); + const firstOrder = free.slice(1).map(node => node.__galaxyOrbitOrder.tier); + free[1].x = 80; free[2].x = 10; + free.forEach(node => { node.vx = 0; node.vy = 0; }); + I.applyGalaxySystemHaloGravity(free, { + gravity: 100, softening: 12, smoothFraction: 0.85, + }); + + const freePair = [ + { id: 'a', anchor_role: 'community', community_id: 'pair', + gravity_mass: 8, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'b', community_id: 'pair', gravity_mass: 1, + x: 24, y: 0, vx: 0, vy: 0 }, + ]; + const freeAcceleration = I.galaxyAccelerations(freePair, [], [], { + gravity: 100, softening: 12, central: false, localPairFraction: 0.15, + }); + const freeRelative = freeAcceleration.get(freePair[1]).ax + - freeAcceleration.get(freePair[0]).ax; + // The live local field is star-only in the star frame; the system-wide recoil is a + // common translation, not an extra planet mass in this relative acceleration. + const expectedFree = -I.galaxyFallbackStellarGravityConstant(100) * 8 * 24 + / Math.pow(24 * 24 + 12 * 12, 1.5); + + const pinnedPair = freePair.map((node, index) => ({ ...node, + id: index ? 'planet' : 'black-hole', + anchor_role: index ? 'none' : 'global', + system_anchor_id: 'black-hole', + vx: 0, vy: 0, + })); + const pinnedAcceleration = I.galaxyAccelerations(pinnedPair, [], [], { + gravity: 100, softening: 12, central: false, localPairFraction: 0.15, + eventHorizonInwardAcceleration: 0, frameDraggingFraction: 0, + systemAnchorRepulsionAcceleration: 0, + }); + /* A direct global child is integrated by the same complete black-hole field that seeds + its carrier orbit. The direct legacy-halo calls above retain their old contract. */ + const expectedPinned = -I.galaxyBlackHoleGravityConstant(100, true) * 8 * 24 + / Math.pow(24 * 24 + 12 * 12, 1.5); + const seededPair = freePair.map(node => ({ ...node, vx: 0, vy: 0 })); + I.seedGalaxyOrbits(seededPair, 72, 100, 12, false, 0.15); + const seededAcceleration = I.galaxyAccelerations(seededPair, [], [], { + gravity: 100, softening: 12, central: false, localPairFraction: 0.15, + // This legacy two-body law intentionally excludes the new near-surface pressure; + // the seed uses the pure dominant-star circular field, as covered separately. + systemAnchorRepulsionAcceleration: 0, + }); + const relativeVelocity = Math.hypot( + seededPair[1].vx - seededPair[0].vx, + seededPair[1].vy - seededPair[0].vy + ); + const seededRadialAcceleration = -( + seededAcceleration.get(seededPair[1]).ax + - seededAcceleration.get(seededPair[0]).ax + ); + const degenerate = [ + { id: 'solo', community_id: 'one', gravity_mass: 2, x: 0, y: 0 }, + { id: 'ghost', community_id: 'one', ghost: true, + gravity_mass: 2, x: 0, y: 0 }, + { id: 'tie-a', community_id: 'tie', gravity_mass: 2, x: 5, y: 5 }, + { id: 'tie-b', community_id: 'tie', gravity_mass: 2, x: 5, y: 5 }, + ]; + I.applyGalaxySystemHaloGravity(degenerate, { + gravity: 100, softening: 12, smoothFraction: 0.85, + }); + const pathological = [ + { id: 'massive', anchor_role: 'community', community_id: 'huge', + gravity_mass: 1000, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'near', community_id: 'huge', gravity_mass: 1000, + x: 0.01, y: 0, vx: 0, vy: 0 }, + ]; + I.applyGalaxySystemHaloGravity(pathological, { + gravity: 10000, softening: 0.1, smoothFraction: 0.85, + }); + emit({ stats, momentum, firstOrder, + frozenOrder: free.slice(1).map(node => node.__galaxyOrbitOrder.tier), + freeRelative, expectedFree, + pinned: [pinnedAcceleration.get(pinnedPair[0]), + pinnedAcceleration.get(pinnedPair[1])], + expectedPinned, + seedLaw: [relativeVelocity * relativeVelocity / 24, + seededRadialAcceleration], + capped: pathological.map(node => Math.hypot(node.vx, node.vy)), + cappedMomentum: pathological.reduce((sum, node) => sum + + node.gravity_mass * node.vx, 0), + finite: degenerate.every(node => node.ghost || [node.vx, node.vy] + .every(value => value === undefined || Number.isFinite(value))), + }); + """ + ) + assert report["stats"] == {"communities": 1, "satellites": 2} + assert report["momentum"] == pytest.approx(0, abs=1e-12) + assert report["firstOrder"] == report["frozenOrder"] == [1, 2] + assert report["freeRelative"] == pytest.approx(report["expectedFree"], rel=1e-12) + assert report["pinned"][0] == {"ax": 0, "ay": 0} + assert report["pinned"][1]["ax"] == pytest.approx(report["expectedPinned"], rel=1e-12) + assert report["pinned"][1]["ay"] == pytest.approx(0, abs=1e-12) + assert report["seedLaw"][0] == pytest.approx(report["seedLaw"][1], rel=1e-12) + assert max(report["capped"]) == pytest.approx(1491.9230769230769) + assert report["cappedMomentum"] == pytest.approx(0, abs=1e-9) + assert report["finite"] is True + + +@requires_node +def test_black_hole_composite_field_is_mass_aware_differential_and_linear_cost() -> None: + report = _run_node( + """ + const fixture = coreScale => [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 8 * coreScale, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'bulge', anchor_role: 'community', community_id: 'core', + gravity_mass: 2 * coreScale, x: 8, y: 0, vx: 0, vy: 0 }, + { id: 'inner-a', community_id: 'inner', gravity_mass: 3, + x: 78, y: 0, vx: 0, vy: 0 }, + { id: 'inner-b', community_id: 'inner', gravity_mass: 2, + x: 84, y: 2, vx: 0, vy: 0 }, + { id: 'outer', community_id: 'outer', gravity_mass: 1, + x: 240, y: 0, vx: 0, vy: 0 }, + ]; + const weakNodes = fixture(1), strongNodes = fixture(2); + const weak = I.galaxyBlackHoleField(weakNodes, { + gravity: 48, softening: 36, accelerationCap: 100, + }); + const strong = I.galaxyBlackHoleField(strongNodes, { + gravity: 48, softening: 36, accelerationCap: 100, + }); + I.applyGalaxyBlackHoleGravity(weakNodes, { + gravity: 48, softening: 36, accelerationCap: 100, + }); + const inner = weak.systems.find(item => item.center.id === 'inner'); + const outer = weak.systems.find(item => item.center.id === 'outer'); + const strongInner = strong.systems.find(item => item.center.id === 'inner'); + const many = Array.from({ length: 600 }, (_, index) => ({ + id: index ? 'n' + index : 'bh', + anchor_role: index ? 'none' : 'global', + community_id: 'c' + index, + gravity_mass: 1 + index % 7, + x: index ? Math.cos(index * 2.399) * (40 + Math.sqrt(index) * 9) : 0, + y: index ? Math.sin(index * 2.399) * (40 + Math.sqrt(index) * 9) : 0, + })); + const manyField = I.galaxyBlackHoleField(many, { + gravity: 48, softening: 36, + }); + emit({ + anchor: weak.anchor.id, + masses: [weak.coreMass, weak.haloMass], + traversals: weak.traversals, + differential: [inner.omega, outer.omega], + massRatio: Math.hypot(strongInner.ax, strongInner.ay) + / Math.hypot(inner.ax, inner.ay), + inward: weakNodes.filter(node => node.community_id !== 'core') + .map(node => node.x * node.vx + node.y * node.vy), + rigidInner: [weakNodes[2].vx - weakNodes[3].vx, + weakNodes[2].vy - weakNodes[3].vy], + many: { traversals: manyField.traversals, systems: manyField.systems.length }, + }); + """ + ) + assert report["anchor"] == "black-hole" + assert report["masses"] == [8, 8] + assert report["traversals"] == 4 + assert report["differential"][0] > report["differential"][1] > 0 + assert report["massRatio"] > 1.5 + assert all(dot < 0 for dot in report["inward"]) + assert report["rigidInner"] == pytest.approx([0, 0], abs=1e-12) + assert report["many"]["traversals"] == 600 + assert report["many"]["systems"] == 599 + + +@requires_node +def test_cored_log_halo_has_flat_outer_rotation_and_caps_each_carrier_independently() -> None: + """The shared carrier law is flat outside the halo core and never globally downscales.""" + report = _run_node( + """ + const model = { + gravitationalConstant: 1, + coreMass: 0, + haloMass: Math.SQRT2 * 100, + coreSoftening: 10, + haloScale: 100, + accelerationCap: 1e9, + }; + const samples = [500, 1000, 2000].map(radius => { + const curve = I.galaxyCarrierOrbitCurve(model, radius); + return { radius, speed: curve.circularSpeed, omega: curve.omega }; + }); + const atScale = I.galaxyCarrierOrbitCurve(model, 100); + const neutralTarget = I.galaxyCarrierTargetSpeed(model, 1000, 100); + const capped = I.galaxyCarrierOrbitCurve({ ...model, accelerationCap: .001 }, 20); + const uncapped = I.galaxyCarrierOrbitCurve(model, 2000); + emit({ samples, atScale, neutralTarget, capped, uncapped }); + """ + ) + speeds = [sample["speed"] for sample in report["samples"]] + omegas = [sample["omega"] for sample in report["samples"]] + assert max(speeds) / min(speeds) < 1.02 + assert omegas[0] > omegas[1] > omegas[2] > 0 + # v0²=1 and r=a gives v²=.5, exactly matching the old Plummer speed at the handoff. + assert report["atScale"]["circularSpeed"] == pytest.approx(math.sqrt(.5), rel=1e-12) + # Neutral presentation speed is the actual circular speed, with no hidden visual boost. + assert report["neutralTarget"] == pytest.approx(speeds[1], rel=1e-12) + assert report["capped"]["acceleration"] == pytest.approx(.001, rel=1e-12) + # A cap sampled for one inner carrier does not scale an unrelated outer carrier. + assert report["uncapped"]["capScale"] == 1 + + +@requires_node +def test_direct_black_hole_star_is_one_rigid_carrier_with_local_descendant_physics() -> None: + """A directly linked star owns its planets; only that complete frame orbits the black hole.""" + report = _run_node( + """ + const make = () => [ + { id: 'bh', anchor_role: 'global', community_id: 'core', gravity_mass: 64, + radius: 10, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'star', anchor_role: 'community', community_id: 'solar', + system_anchor_id: 'bh', gravity_mass: 9, radius: 4, + x: 90, y: 0, vx: 0, vy: 0 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', + gravity_mass: 1, radius: 2, x: 102, y: 0, vx: 0, vy: 0 }, + { id: 'moon', community_id: 'solar', system_anchor_id: 'planet', + gravity_mass: .2, radius: 1, x: 106, y: 0, vx: 0, vy: 0 }, + // A same-community BH sibling is a separate carrier, never another child of `star`. + { id: 'peer', community_id: 'solar', system_anchor_id: 'bh', + gravity_mass: 2, radius: 2, x: -80, y: 0, vx: 0, vy: 0 }, + ]; + const galactic = make(); + const field = I.galaxyBlackHoleField(galactic, { + gravity: 48, softening: 32, accelerationCap: 1e9, + }); + I.applyGalaxyBlackHoleGravity(galactic, { + gravity: 48, softening: 32, accelerationCap: 1e9, + }); + const seeded = make().filter(node => node.id !== 'peer'); + I.seedGalaxySystemOrbits(seeded, 311, 48, 32, false); + const local = make(); + I.applyGalaxySystemAnchorGravity(local, { + gravity: 48, softening: 8, accelerationCap: 1e9, + }); + emit({ + systems: field.systems.map(item => ({ id: item.id, core: item.core, + carrier: item.carrier.id, members: item.nodes.map(node => node.id) })), + galactic: galactic.map(node => [node.vx, node.vy]), + seededSingleCommunity: seeded.map(node => [node.vx, node.vy]), + local: local.map(node => [node.vx, node.vy]), + }); + """ + ) + assert report["systems"] == [ + {"id": "star", "core": True, "carrier": "star", + "members": ["star", "planet", "moon"]}, + {"id": "peer", "core": True, "carrier": "peer", "members": ["peer"]}, + ] + carrier_delta = report["galactic"][1] + assert math.hypot(*carrier_delta) > 0 + assert report["galactic"][2] == pytest.approx(carrier_delta, abs=1e-12) + assert report["galactic"][3] == pytest.approx(carrier_delta, abs=1e-12) + assert math.hypot(*report["galactic"][4]) > 0 + assert math.hypot(*report["seededSingleCommunity"][1]) > 0 + assert report["seededSingleCommunity"][2] == pytest.approx( + report["seededSingleCommunity"][1], abs=1e-12 + ) + assert report["seededSingleCommunity"][3] == pytest.approx( + report["seededSingleCommunity"][1], abs=1e-12 + ) + # The star gets no second local black-hole pull; planet and moon use immediate parents. + assert report["local"][1] == pytest.approx([0, 0], abs=1e-12) + assert math.hypot(*report["local"][2]) > 0 + assert math.hypot(*report["local"][3]) > 0 + assert report["local"][4] == pytest.approx([0, 0], abs=1e-12) + + +@requires_node +def test_direct_black_hole_solar_system_gets_its_own_packed_carrier_envelope() -> None: + """Admission uses the runtime carrier hierarchy instead of folding the star into the hole.""" + report = _run_node( + """ + const nodes = [ + { id: 'bh', anchor_role: 'global', community_id: 'core', gravity_mass: 64, + radius: 10, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'direct-star', anchor_role: 'community', community_id: 'core', + system_anchor_id: 'bh', gravity_mass: 9, radius: 5, + x: 120, y: 0, vx: 2, vy: 1 }, + { id: 'direct-planet', community_id: 'core', system_anchor_id: 'direct-star', + gravity_mass: 1, radius: 2, x: 138, y: 4, vx: 2, vy: 2 }, + { id: 'outer-star', anchor_role: 'community', community_id: 'outer', + system_anchor_id: 'outer-star', gravity_mass: 8, radius: 5, + x: 120, y: 0, vx: -1, vy: 0 }, + { id: 'outer-planet', community_id: 'outer', system_anchor_id: 'outer-star', + gravity_mass: 1, radius: 2, x: 140, y: 0, vx: -1, vy: 1 }, + ]; + const byId = id => nodes.find(node => node.id === id); + const directStar = byId('direct-star'), directPlanet = byId('direct-planet'); + const beforeLocal = [directPlanet.x - directStar.x, directPlanet.y - directStar.y, + directPlanet.vx - directStar.vx, directPlanet.vy - directStar.vy]; + const before = I.galaxySystemEnvelopes(nodes).map(system => ({ + id: system.id, anchor: system.anchor.id, members: system.nodes.map(node => node.id), + })).sort((left, right) => left.id.localeCompare(right.id)); + const admission = I.establishGalaxyCarrierLanes(nodes, { gap: 8, layoutSeed: 413 }); + const after = I.galaxySystemEnvelopes(nodes).map(system => ({ + id: system.id, anchor: system.anchor.id, members: system.nodes.map(node => node.id), + })).sort((left, right) => left.id.localeCompare(right.id)); + const afterLocal = [directPlanet.x - directStar.x, directPlanet.y - directStar.y, + directPlanet.vx - directStar.vx, directPlanet.vy - directStar.vy]; + emit({ before, after, admission, beforeLocal, afterLocal, + blackHole: [nodes[0].x, nodes[0].y, nodes[0].vx, nodes[0].vy], + directLane: directStar.__galaxyCarrierLaneRadius, + outerLane: byId('outer-star').__galaxyCarrierLaneRadius }); + """ + ) + expected = [ + {"id": "bh", "anchor": "bh", "members": ["bh"]}, + {"id": "direct-star", "anchor": "direct-star", + "members": ["direct-star", "direct-planet"]}, + {"id": "outer-star", "anchor": "outer-star", + "members": ["outer-star", "outer-planet"]}, + ] + assert report["before"] == expected + assert report["after"] == expected + assert report["admission"]["assigned"] == 2 + assert report["admission"]["moved"] == 2 + assert report["directLane"] > 0 + assert report["outerLane"] > 0 + assert report["blackHole"] == [0, 0, 0, 0] + assert report["afterLocal"] == pytest.approx(report["beforeLocal"], abs=1e-12) + + +@requires_node +def test_envelopes_without_an_explicit_black_hole_keep_compatibility_systems_intact() -> None: + """A dominant fallback star is not a black hole and must retain its planet envelope.""" + report = _run_node( + """ + const nodes = [ + { id: 'hub', anchor_role: 'community', community_id: 'solar', gravity_mass: 8, + radius: 5, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'planet', community_id: 'solar', gravity_mass: 1, + radius: 2, x: 20, y: 0, vx: 0, vy: 1 }, + { id: 'other', anchor_role: 'community', community_id: 'other', gravity_mass: 4, + radius: 4, x: 80, y: 0, vx: 0, vy: 0 }, + ]; + emit(I.galaxySystemEnvelopes(nodes).map(system => ({ + id: system.id, members: system.nodes.map(node => node.id), + })).sort((left, right) => left.id.localeCompare(right.id))); + """ + ) + assert report == [ + {"id": "hub", "members": ["hub", "planet"]}, + {"id": "other", "members": ["other"]}, + ] + + +@requires_node +def test_global_anchor_stays_exactly_centered_without_packing_the_disk() -> None: + report = _run_node( + """ + const nodes = [ + ['black-hole', 16, 'core', 0, 0, 'global'], + ['bulge', 4, 'core', 12, 3, 'community'], + ['inner-star', 5, 'inner', 80, 0, 'community'], + ['inner-planet', 2, 'inner', 92, 4, 'none'], + ['outer-star', 4, 'outer', 240, 0, 'community'], + ['outer-planet', 1, 'outer', 252, -3, 'none'], + ].map(([id, gravity_mass, community_id, x, y, anchor_role]) => ({ + id, gravity_mass, community_id, x, y, vx: 0, vy: 0, + radius: 4, anchor_role, + })); + I.seedGalaxyOrbits(nodes, 19, 100, 8, false); + I.seedGalaxySystemOrbits(nodes, 19, 100, 40, false); + let exact = true; + for (let step = 0; step < 90; step++) { + I.integrateGalaxyLeapfrog(nodes, [], [], { + gravity: 100, softening: 8, centralSoftening: 40, + timestep: 0.75, velocityDecay: 0.0005, speedLimit: 48, + collisionPadding: 1.5, collisionStrength: 0.7, collisionIterations: 2, + }); + const anchor = nodes[0]; + exact = exact && anchor.x === 0 && anchor.y === 0 + && anchor.vx === 0 && anchor.vy === 0; + } + const centers = [...I.communityCenters(nodes).values()]; + let minimumSystemDistance = Infinity; + for (let left = 0; left < centers.length; left++) for ( + let right = left + 1; right < centers.length; right++ + ) minimumSystemDistance = Math.min(minimumSystemDistance, + Math.hypot(centers[left].x - centers[right].x, + centers[left].y - centers[right].y)); + emit({ exact, finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)), minimumSystemDistance }); + """ + ) + assert report["exact"] is True + assert report["finite"] is True + assert report["minimumSystemDistance"] > 40 + + +@requires_node +def test_actual_shaped_multi_member_galaxy_stays_bound_for_1800_steps() -> None: + report = _run_node( + """ + const nodes = [{ + id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 24, visual_radius: 10, radius: 10, + galactic_radius: 0, x: 0, y: 0, vx: 0, vy: 0, + }]; + const links = []; + for (let system = 1; system <= 24; system++) { + const galacticRadius = 140 + system * 16; + const phase = system * 2.399963229728653; + const centerX = Math.cos(phase) * galacticRadius; + const centerY = Math.sin(phase) * galacticRadius * 0.82; + for (let member = 0; member < 6; member++) { + const localRadius = member === 0 ? 0 : 12 + member * 5; + const localPhase = phase + member * 1.2566370614; + nodes.push({ + id: `s${system}-n${member}`, + anchor_role: member === 0 ? 'community' : 'none', + community_id: `system-${system}`, + gravity_mass: member === 0 ? 5 + system % 4 : 1 + (member % 3) * 0.5, + visual_radius: member === 0 ? 5 : 2 + member % 2, + radius: member === 0 ? 5 : 2 + member % 2, + galactic_radius: galacticRadius, + galactic_phase: phase, + x: centerX + Math.cos(localPhase) * localRadius, + y: centerY + Math.sin(localPhase) * localRadius, + vx: 0, vy: 0, + }); + if (member > 0) links.push({ + source: `s${system}-n0`, target: `s${system}-n${member}`, + rest_length: localRadius, spring_strength: 0.08, + }); + } + } + I.seedGalaxyOrbits(nodes, 91027, 100, 32, false, 0.15); + I.seedGalaxySystemOrbits(nodes, 91027, 100, 40, false); + const percentile = (values, fraction) => { + const sorted = values.slice().sort((a, b) => a - b); + return sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * fraction))]; + }; + const snapshot = () => { + const centers = [...I.communityCenters(nodes).values()] + .filter(center => center.id !== 'core'); + const systemRadii = centers.map(center => Math.hypot(center.x, center.y)); + const nodeRadii = nodes.slice(1).map(node => Math.hypot(node.x, node.y)); + return { + median: percentile(systemRadii, 0.5), + p95: percentile(systemRadii, 0.95), + maxNode: Math.max(...nodeRadii), + }; + }; + const orbitalEnergy = () => { + const field = I.galaxyBlackHoleField(nodes, { gravity: 100, softening: 40 }); + const g = I.galaxyGravityConstant(100); + return field.systems.reduce((sum, item) => { + let vx = 0, vy = 0; + item.center.nodes.forEach(node => { + vx += node.gravity_mass * node.vx; + vy += node.gravity_mass * node.vy; + }); + vx /= item.center.mass; vy /= item.center.mass; + const kinetic = 0.5 * item.center.mass * (vx * vx + vy * vy); + const potential = -item.center.mass * g * ( + field.coreMass / Math.sqrt(item.radius * item.radius + 40 * 40) + + field.haloMass / Math.sqrt( + item.radius * item.radius + field.haloScale * field.haloScale + ) + ); + return sum + kinetic + potential; + }, 0); + }; + const initial = snapshot(); + const initialEnergy = orbitalEnergy(); + let minimumMedian = initial.median, maximumP95 = initial.p95; + let maximumNode = initial.maxNode, minimumEnergy = initialEnergy; + let maximumEnergy = initialEnergy, exactCenter = true, speedCaps = 0; + const angleStep = (next, previous) => Math.atan2( + Math.sin(next - previous), Math.cos(next - previous) + ); + const globalAngles = new Map([...I.communityCenters(nodes).values()] + .filter(center => center.id !== 'core') + .map(center => [center.id, Math.atan2(center.y, center.x)])); + const localAngles = new Map(nodes.slice(1).filter(node => node.anchor_role !== 'community') + .map(node => { + const star = nodes.find(candidate => candidate.community_id === node.community_id + && candidate.anchor_role === 'community'); + return [node.id, Math.atan2(node.y - star.y, node.x - star.x)]; + })); + let globalTravel = 0, localTravel = 0, minimumStarClearance = Infinity; + let starContacts = 0; + for (let step = 0; step < 1800; step++) { + const tick = I.integrateGalaxyLeapfrog(nodes, links, [], { + gravity: 100, softening: 32, centralSoftening: 40, + timestep: 0.021328125, velocityDecay: 0.0001, speedLimit: 48, + localPairFraction: 0.15, corePairMultiplier: 0.75, + includeBridges: false, includeMutualSystems: true, + mutualSystemGravityFraction: 0.12, mutualSystemSoftening: 80, + includeRelations: true, includeRelationSprings: false, + skipSystemAnchorRelations: true, relationStrengthMultiplier: 2, + relationForceCap: 1.6, relationAccelerationCap: 3.2, + relationConstraintRate: 24, relationConstraintMaxCorrection: 12, + relationPadding: 1.5, + includeOrbitalSeparation: true, orbitalSeparationPadding: 1.5, + orbitalSeparationStrength: 0.8, crossCommunitySeparationPadding: 1.5, + crossCommunitySeparationStrength: 0.144, + orbitalSeparationMaxCorrection: 4, orbitalSeparationMaxVelocityCorrection: 8, + preserveLocalTangentialVelocity: true, skipSystemAnchorPairs: true, + systemAnchorExclusionPadding: 1.5, + includeCollisions: false, + includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, + includeFarFieldConfinement: true, farFieldEnvelopeScale: 1.25, + farFieldMinimumRadius: 96, farFieldSoftFraction: 0.82, + farFieldAcceleration: 12, farFieldMaxAcceleration: 16, + inwardConvergence: true, wallClockSeconds: 1 / 30, + }); + if (tick.speedCapped) speedCaps++; + starContacts += tick.systemAnchorExclusion.contacts; + I.communityCenters(nodes).forEach(center => { + if (center.id === 'core') return; + const angle = Math.atan2(center.y, center.x); + globalTravel += Math.abs(angleStep(angle, globalAngles.get(center.id))); + globalAngles.set(center.id, angle); + }); + localAngles.forEach((previous, id) => { + const node = nodes.find(candidate => candidate.id === id); + const star = nodes.find(candidate => candidate.community_id === node.community_id + && candidate.anchor_role === 'community'); + const angle = Math.atan2(node.y - star.y, node.x - star.x); + localTravel += Math.abs(angleStep(angle, previous)); + localAngles.set(id, angle); + minimumStarClearance = Math.min(minimumStarClearance, + Math.hypot(node.x - star.x, node.y - star.y) - node.radius - star.radius - 1.5); + }); + const sample = snapshot(); + minimumMedian = Math.min(minimumMedian, sample.median); + maximumP95 = Math.max(maximumP95, sample.p95); + maximumNode = Math.max(maximumNode, sample.maxNode); + const energy = orbitalEnergy(); + minimumEnergy = Math.min(minimumEnergy, energy); + maximumEnergy = Math.max(maximumEnergy, energy); + const anchor = nodes[0]; + exactCenter = exactCenter && anchor.x === 0 && anchor.y === 0 + && anchor.vx === 0 && anchor.vy === 0; + } + let overlaps = 0, minimumSeparation = Infinity, minimumSystemDiameter = Infinity; + const bySystem = new Map(); + nodes.slice(1).forEach(node => { + if (!bySystem.has(node.community_id)) bySystem.set(node.community_id, []); + bySystem.get(node.community_id).push(node); + }); + bySystem.forEach(members => { + let diameter = 0; + for (let left = 0; left < members.length; left++) for ( + let right = left + 1; right < members.length; right++ + ) { + const separation = Math.hypot(members[left].x - members[right].x, + members[left].y - members[right].y); + minimumSeparation = Math.min(minimumSeparation, separation); + diameter = Math.max(diameter, separation); + if (separation < members[left].radius + members[right].radius) overlaps++; + } + minimumSystemDiameter = Math.min(minimumSystemDiameter, diameter); + }); + emit({ initial, final: snapshot(), minimumMedian, maximumP95, maximumNode, + energyDrift: (maximumEnergy - minimumEnergy) / Math.abs(initialEnergy), + exactCenter, speedCaps, overlaps, minimumSeparation, minimumSystemDiameter, + globalTravel, localTravel, minimumStarClearance, starContacts, + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)) }); + """ + ) + assert report["finite"] is True + assert report["exactCenter"] is True + # Gravity 100 is more than twice the live default. Its emergency guard may engage for a + # bounded minority of stress ticks (the default-48 fixture below remains cap-free), but it + # must not become the system's steady state or replace the asserted orbital travel. + assert report["speedCaps"] < 1800 * 0.3 + # The controlled projection deliberately permits painted envelopes to overlap as it draws + # every orbit inward. Collision impulses remain off here because they can create the + # outward/ejection response this mode forbids; the systems must still retain real extent. + assert report["overlaps"] <= 18 + assert report["minimumSeparation"] > 0.1 + assert report["minimumSystemDiameter"] > 15 + # This large 144-satellite scene may begin already surface-safe, so a contact count is not + # an invariant. The final 24-pass solver must nevertheless never reopen painted overlap. + assert report["minimumStarClearance"] >= -1e-9 + assert report["globalTravel"] > 1 + assert report["localTravel"] > 1 + assert report["minimumMedian"] > report["initial"]["median"] * 0.05 + assert report["maximumP95"] < report["initial"]["p95"] * 1.45 + assert report["maximumNode"] < report["initial"]["maxNode"] * 1.45 + + +@requires_node +def test_stronger_gravity_keeps_a_300_node_galaxy_on_the_controlled_inward_track() -> None: + report = _run_node( + """ + const nodes = [{ id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 24, radius: 10, x: 0, y: 0, vx: 0, vy: 0 }]; + for (let system = 1; system <= 50; system++) { + const members = system === 50 ? 5 : 6; + const radius = 105 + system * 5.5; + const phase = system * 2.399963229728653; + for (let member = 0; member < members; member++) { + const localRadius = member === 0 ? 0 : 8 + member * 3.5; + const localPhase = phase + member * 1.2566370614; + nodes.push({ + id: `s${system}-n${member}`, + anchor_role: member === 0 ? 'community' : 'none', + community_id: `s${system}`, + gravity_mass: member === 0 ? 5 + system % 4 : 1 + (member % 3) * 0.5, + radius: member === 0 ? 5 : 2, + x: Math.cos(phase) * radius + Math.cos(localPhase) * localRadius, + y: Math.sin(phase) * radius * 0.82 + Math.sin(localPhase) * localRadius, + vx: 0, vy: 0, + }); + } + } + I.seedGalaxyOrbits(nodes, 91027, 100, 32, false, 0.15, 0.75); + I.seedGalaxySystemOrbits(nodes, 91027, 100, 40, false); + const systemSnapshot = () => new Map([...I.communityCenters(nodes).values()] + .filter(center => center.id !== 'core') + .map(center => [center.id, Math.hypot(center.x, center.y)])); + const initial = systemSnapshot(); + let previous = new Map(initial), monotone = true, speedCaps = 0, maxSpeed = 0; + for (let step = 0; step < 1800; step++) { + const tick = I.integrateGalaxyLeapfrog(nodes, [], [], { + gravity: 100, softening: 32, centralSoftening: 40, timestep: 0.032, + velocityDecay: 0.0001, speedLimit: 48, localPairFraction: 0.15, + corePairMultiplier: 0.75, includeBridges: false, includeRelations: false, + includeCollisions: false, inwardConvergence: true, wallClockSeconds: 1 / 30, + }); + speedCaps += tick.speedCapped ? 1 : 0; + systemSnapshot().forEach((radius, id) => { + monotone = monotone && radius <= previous.get(id) + 1e-8; + previous.set(id, radius); + }); + nodes.slice(1).forEach(node => { + maxSpeed = Math.max(maxSpeed, Math.hypot(node.vx, node.vy)); + }); + } + const ratios = [...previous.entries()].map(([id, radius]) => radius / initial.get(id)) + .sort((left, right) => left - right); + emit({ + nodes: nodes.length, monotone, speedCaps, maxSpeed, + ratioMin: ratios[0], ratioMedian: ratios[Math.floor(ratios.length / 2)], + ratioMax: ratios[ratios.length - 1], + expectedTrack: I.galaxyInwardConvergenceFactor(60, 100), + anchor: [nodes[0].x, nodes[0].y, nodes[0].vx, nodes[0].vy], + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)), + }); + """ + ) + assert report["nodes"] == 300 + # Convergence is disabled (rate=0); orbits remain stable under physics alone. + # Radii oscillate naturally around their seeded values — no forced inward track. + expected_track = report["expectedTrack"] + assert expected_track == pytest.approx(1) + # The established emergency cap remains 48. At this >2x-default stress field, inner + # encounters may touch it for a bounded minority of ticks without owning the simulation. + assert report["speedCaps"] < 1800 * 0.3 + assert report["maxSpeed"] <= 48 + 1e-10 + # Stable orbits: median ratio near 1.0, bounded drift within +/-15%. The former + # monotone-inward contract was the bug — 25%/minute convergence collapsed every + # system into the black hole regardless of orbital velocity balance. + assert report["ratioMedian"] == pytest.approx(1.0, abs=0.15) + assert report["ratioMax"] <= 1.15 + assert report["ratioMin"] > 0.78 + assert report["anchor"] == pytest.approx([0, 0, 0, 0], abs=1e-12) + assert report["finite"] is True + + +@requires_node +def test_501_active_bodies_keep_bounded_dual_scale_orbits_with_spacetime_enabled() -> None: + """The live force path remains stable at the requested 500+ active-body scale. + + This deliberately stays below the 1,000-body live ceiling and above the Barnes--Hut exact + threshold. It rejects a quiet fallback, per-node local-frame corruption, or an unstable + near-horizon field without embedding a machine-dependent wall-clock assertion in CI. + """ + report = _run_node( + """ + const nodes = [{ id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 64, radius: 9, x: 0, y: 0, vx: 0, vy: 0 }], links = []; + for (let system = 0; system < 100; system++) { + const id = 's' + system, starId = id + '-star'; + const globalAngle = system * 2.399963229728653; + const globalRadius = 112 + (system % 25) * 10; + const cx = Math.cos(globalAngle) * globalRadius; + const cy = Math.sin(globalAngle) * globalRadius * .82; + nodes.push({ id: starId, anchor_role: 'community', community_id: id, + system_anchor_id: starId, orbit_tier: 0, gravity_mass: 8, radius: 5, + x: cx, y: cy, vx: 0, vy: 0 }); + for (let planet = 1; planet <= 4; planet++) { + const radius = 14 + planet * 5, phase = globalAngle + planet * 1.57079632679; + const planetId = id + '-p' + planet; + nodes.push({ id: planetId, community_id: id, system_anchor_id: starId, + orbit_tier: planet, gravity_mass: 1, radius: 2.5, + x: cx + Math.cos(phase) * radius, y: cy + Math.sin(phase) * radius, + vx: 0, vy: 0 }); + links.push({ source: starId, target: planetId, relation: 'orbits', + rest_length: radius, spring_strength: .08 }); + } + } + const delta = (next, previous) => Math.atan2(Math.sin(next - previous), + Math.cos(next - previous)); + const byId = id => nodes.find(node => node.id === id); + I.seedGalaxyOrbits(nodes, 51001, 48, 32, false); + I.seedGalaxySystemOrbits(nodes, 51001, 48, 40, false); + const starts = new Map(['s0', 's31', 's74'].map(id => { + const star = byId(id + '-star'), planet = byId(id + '-p1'); + return [id, { global: Math.atan2(star.y, star.x), + local: Math.atan2(planet.y - star.y, planet.x - star.x) }]; + })); + let maxSpeed = 0, speedCaps = 0, maxWarp = 0; + const options = { + gravity: 48, gravitationalConstant: 1, blackHoleMass: 1, + softening: 32, centralSoftening: 40, timestep: .032, wallClockSeconds: 1 / 30, + velocityDecay: .00005, speedLimit: 48, localRelativeSpeedLimit: 48, + includeMutualSystems: true, mutualSystemGravityFraction: .12, + mutualSystemSoftening: 80, exactLimit: 64, theta: .85, + includeRelations: true, includeRelationSprings: false, + skipSystemAnchorRelations: true, skipOrbitalSystemRelations: true, + includeOrbitalSeparation: true, orbitalSeparationPadding: 8, + orbitalSeparationStrength: .5, orbitalSeparationMaxCorrection: 4, + orbitalSeparationMaxVelocityCorrection: 8, + preserveLocalTangentialVelocity: true, preserveSystemRadii: true, + skipSystemAnchorPairs: true, systemAnchorExclusionPadding: 1.5, + includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, + includeFarFieldConfinement: true, farFieldEnvelopeScale: 1.75, + farFieldMinimumRadius: 96, farFieldSoftFraction: .82, + farFieldAcceleration: 12, farFieldMaxAcceleration: 16, + includeSpacetime: true, frameDraggingFraction: .018, + frameDraggingMaxAcceleration: .22, eventHorizonDecayRate: .12, + eventHorizonInwardAcceleration: .28, includeCollisions: false, + }; + for (let step = 0; step < 90; step++) { + const tick = I.integrateGalaxyLeapfrog(nodes, links, [], options); + maxSpeed = Math.max(maxSpeed, tick.maximumSpeed); + speedCaps += tick.speedCapped ? 1 : 0; + maxWarp = Math.max(maxWarp, tick.spacetime.maximumWarp); + } + const travel = [...starts.entries()].map(([id, start]) => { + const star = byId(id + '-star'), planet = byId(id + '-p1'); + return { global: delta(Math.atan2(star.y, star.x), start.global), + local: delta(Math.atan2(planet.y - star.y, planet.x - star.x), start.local) }; + }); + emit({ nodes: nodes.length, links: links.length, maxSpeed, speedCaps, maxWarp, travel, + anchor: [nodes[0].x, nodes[0].y, nodes[0].vx, nodes[0].vy], + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), + }); + """ + ) + assert report["nodes"] == 501 and report["links"] == 400 + assert report["finite"] is True + assert report["anchor"] == pytest.approx([0, 0, 0, 0], abs=1e-12) + assert report["maxSpeed"] <= 48 + assert report["speedCaps"] == 0 + # The selected systems prove both hierarchy levels remain live under the 500-node field. + assert all(abs(track["global"]) > .02 and abs(track["local"]) > .08 + for track in report["travel"]) + + +@requires_node +def test_black_hole_adornment_is_bounded_and_does_not_change_hit_geometry() -> None: + report = _run_node( + """ + const calls = { arcs: 0, ellipses: 0, fills: 0, strokes: 0, gradients: 0 }; + const ctx = { + save() {}, restore() {}, beginPath() {}, + moveTo() {}, lineTo() {}, + arc() { calls.arcs++; }, ellipse() { calls.ellipses++; }, + fill() { calls.fills++; }, stroke() { calls.strokes++; }, + createRadialGradient() { calls.gradients++; return { addColorStop() {} }; }, + set fillStyle(value) {}, set strokeStyle(value) {}, set lineWidth(value) {}, + }; + const global = { id: 'bh', x: 0, y: 0, radius: 9, + color: '#8f7cff', anchor_role: 'global' }; + const community = { id: 'star', x: 20, y: 0, radius: 5, + color: '#63d8cb', anchor_role: 'community' }; + const ordinary = { id: 'planet', x: 30, y: 0, radius: 3, + color: '#ffffff', anchor_role: 'none' }; + const before = [global.radius, community.radius, ordinary.radius]; + const painted = [ + I.paintGalaxyAnchorAdornment(ctx, global, 1, '#a58cff', false), + I.paintGalaxyAnchorAdornment(ctx, global, 1, '#a58cff', true), + I.paintGalaxyAnchorAdornment(ctx, community, 1, '#63d8cb', false), + I.paintGalaxyAnchorAdornment(ctx, ordinary, 1, '#ffffff', false), + ]; + emit({ calls, painted, before, + after: [global.radius, community.radius, ordinary.radius] }); + """ + ) + assert report["painted"] == [1, 1, 1, 0] + assert report["before"] == report["after"] == [9, 5, 3] + assert report["calls"]["gradients"] == 2 + assert report["calls"]["ellipses"] == 1 + assert report["calls"]["arcs"] >= 3 + assert report["calls"]["fills"] >= 2 + assert report["calls"]["strokes"] >= 3 + source = ASSET.read_text(encoding="utf-8") + style_node = source[source.index("function styleNode(node, ctx, scale)"): + source.index("function applyChrome", source.index("function styleNode(node, ctx, scale)"))] + assert "state.settings.mode === 'galaxy'" in style_node + assert style_node.count("paintGalaxyAnchorAdornment(") == 2 + + pointer = _run_engine( + """ + const pointerCalls = []; + const ctx = { + beginPath() {}, fill() {}, + arc(_x, _y, radius) { pointerCalls.push(radius); }, + set fillStyle(_value) {}, + }; + const api = G.create(el, {}); + api.setPreset('galaxy'); + store.nodePointerAreaPaint( + { id: 'bh', x: 0, y: 0, radius: 9, anchor_role: 'global' }, '#fff', ctx + ); + store.nodePointerAreaPaint( + { id: 'planet', x: 0, y: 0, radius: 3, anchor_role: 'none' }, '#fff', ctx + ); + emit({ pointerCalls }); + """ + ) + assert pointer["pointerCalls"] == [20, 5] + + +@requires_node +def test_black_hole_adornment_keeps_a_live_orbital_spin_phase() -> None: + report = _run_node( + """ + const spin = orbitalSpeed => { + const nodes = [{ id: 'bh', anchor_role: 'global', community_id: 'core', + x: 0, y: 0, vx: 0, vy: 0, gravity_mass: 64 }]; + const start = I.galaxyBlackHoleSpinAngle(nodes[0]); + for (let step = 0; step < 30; step += 1) { + I.advanceGalaxyBlackHoleSpin(nodes, { + layoutSeed: 7331, orbitalSpeed, timestep: .032, + }); + } + return I.galaxyBlackHoleSpinAngle(nodes[0]) - start; + }; + const slow = spin(100), fast = spin(400); + emit({ slow, fast, ratio: Math.abs(fast / slow) }); + """ + ) + assert abs(report["slow"]) > 0.1 + assert abs(report["fast"]) > abs(report["slow"]) + assert report["ratio"] == pytest.approx(4.0, rel=1e-9) + + +@requires_node +def test_galaxy_black_hole_seeds_circular_carriers_with_tangential_rotation() -> None: + report = _run_node( + """ + const nodes = [ + { id: 'anchor', x: 0, y: 0, vx: 0, vy: 0, gravity_mass: 16, + community_id: 'core', anchor_role: 'global' }, + { id: 'inner', x: 70, y: 0, vx: 0, vy: 0, gravity_mass: 2, + community_id: 'inner' }, + { id: 'outer', x: 180, y: 0, vx: 0, vy: 0, gravity_mass: 1, + community_id: 'outer' }, + ]; + I.seedGalaxySystemOrbits(nodes, 91, 48, 40, false); + const radius = node => Math.hypot(node.x, node.y); + const radialVelocity = node => node.x * node.vx + node.y * node.vy; + const initial = nodes.slice(1).map(node => ({ + radius: radius(node), radial: radialVelocity(node), + angular: node.x * node.vy - node.y * node.vx, + })); + for (let index = 0; index < 120; index++) { + I.integrateGalaxyLeapfrog(nodes, [], [], { + gravity: 48, softening: 8, centralSoftening: 40, timestep: 0.021328125, + velocityDecay: 0.02, speedLimit: 100, collisionStrength: 0, + }); + } + emit({ + initial, + final: nodes.slice(1).map(node => ({ + radius: radius(node), + angular: node.x * node.vy - node.y * node.vx, + })), + anchor: [nodes[0].x, nodes[0].y, nodes[0].vx, nodes[0].vy], + }); + """ + ) + # Admitted carrier lanes begin circularly; a compulsory inward seed would make a clean + # galaxy collapse into its neighbours and trigger packing pops. + assert all(abs(item["radial"]) < 1e-8 for item in report["initial"]) + assert all( + 0.5 * initial["radius"] < final["radius"] < 1.5 * initial["radius"] + for initial, final in zip(report["initial"], report["final"]) + ) + assert all(abs(item["angular"]) > 1e-6 for item in report["initial"]) + assert all(abs(item["angular"]) > 1e-6 for item in report["final"]) + assert report["anchor"] == pytest.approx([0, 0, 0, 0]) + + +@requires_node +def test_galaxy_relation_springs_are_local_mass_aware_and_momentum_symmetric() -> None: + report = _run_node( + """ + const fixture = () => [ + { id: 'heavy', x: 0, y: 0, vx: 0, vy: 0, gravity_mass: 4, community_id: 'solar' }, + { id: 'light', x: 30, y: 0, vx: 0, vy: 0, gravity_mass: 1, community_id: 'solar' }, + { id: 'remote', x: 80, y: 0, vx: 0, vy: 0, gravity_mass: 2, community_id: 'remote' }, + { id: 'history', x: 12, y: 0, vx: 0, vy: 0, gravity_mass: 0, + community_id: 'solar', ghost: true }, + ]; + const stretched = fixture(); + const stretchedStats = I.applyGalaxyRelationSprings(stretched, [ + { source: 'heavy', target: 'light', rest_length: 20, spring_strength: 0.1 }, + { source: 'light', target: 'remote', rest_length: 20, spring_strength: 0.2 }, + { source: 'heavy', target: 'remote', rest_length: 20, spring_strength: 0.2, + ghost: true, physics_strength: 0 }, + { source: 'heavy', target: 'history', rest_length: 20, spring_strength: 0.2 }, + ], { alpha: 1, orbitScale: 1 }); + const compressed = fixture(); + I.applyGalaxyRelationSprings(compressed, [ + { source: 'heavy', target: 'light', rest_length: 20, spring_strength: 0.1 }, + ], { alpha: 1, orbitScale: 2 }); + emit({ + stretched: stretched.map(node => [node.vx, node.vy]), + compressed: compressed.map(node => [node.vx, node.vy]), + applied: stretchedStats.applied, + momentum: stretched.reduce( + (sum, node) => sum + node.gravity_mass * node.vx, 0 + ), + }); + """ + ) + assert report["stretched"][0] == pytest.approx([0.2, 0]) + assert report["stretched"][1] == pytest.approx([-0.8, 0]) + assert report["stretched"][2] == pytest.approx([0, 0]) + assert report["stretched"][3] == pytest.approx([0, 0]) + assert report["compressed"][0] == pytest.approx([-0.2, 0]) + assert report["compressed"][1] == pytest.approx([0.8, 0]) + assert report["compressed"][2] == pytest.approx([0, 0]) + assert report["compressed"][3] == pytest.approx([0, 0]) + assert report["applied"] == 1 + assert report["momentum"] == pytest.approx(0, abs=1e-12) + + +@requires_node +def test_galaxy_link_distance_has_squared_scale_and_release_stable_response() -> None: + report = _run_node( + """ + const spring = (setting, strengthMultiplier = 2, + forceCap = 1.6, accelerationCap = 3.2) => { + const nodes = [ + { id: 'star', x: 0, y: 0, vx: 0, vy: 0, + gravity_mass: 4, radius: 1, community_id: 'solar' }, + { id: 'planet', x: 10, y: 0, vx: 0, vy: 0, + gravity_mass: 1, radius: 1, community_id: 'solar' }, + ]; + const link = { source: 'star', target: 'planet', + rest_length: 20, spring_strength: 0.1 }; + const orbitScale = I.galaxyRelationOrbitScale(setting); + const stats = I.applyGalaxyRelationSprings(nodes, [link], { + alpha: 1, orbitScale, strengthMultiplier, + forceCap, accelerationCap, + }); + return { + orbitScale, + target: I.galaxySpringDistance(link, orbitScale), + velocities: nodes.map(node => node.vx), + momentum: nodes.reduce( + (sum, node) => sum + node.gravity_mass * node.vx, 0), + stats, + }; + }; + const ordinary = [ + { id: 'star', x: 0, y: 0, vx: 0, vy: 0, + gravity_mass: 4, radius: 1, community_id: 'solar' }, + { id: 'planet', x: 10, y: 0, vx: 0, vy: 0, + gravity_mass: 1, radius: 1, community_id: 'solar' }, + ]; + I.applyGalaxyRelationSprings(ordinary, [{ + source: 'star', target: 'planet', rest_length: 20, spring_strength: 0.1, + }], { alpha: 1, orbitScale: 0.25, forceCap: 1.6, accelerationCap: 3.2 }); + emit({ + tight: spring(4), baseline: spring(8), reference: spring(16), loose: spring(80), + unsafeLoose: spring(80, 4, 3.2, 6.4), + ordinary: ordinary.map(node => node.vx), + constraint: (() => { + const make = () => [ + { id: 'star', x: 0, y: 0, vx: 0, vy: 0, + gravity_mass: 4, radius: 1, community_id: 'solar' }, + { id: 'planet', x: 10, y: 0, vx: 0, vy: 0, + gravity_mass: 1, radius: 1, community_id: 'solar' }, + ]; + const link = { source: 'star', target: 'planet', + rest_length: 20, spring_strength: 0.1 }; + const run = (setting, responseMultiplier, maxCorrection) => { + const nodes = make(); + const beforeCom = (nodes[0].x * 4 + nodes[1].x) / 5; + const stats = I.applyGalaxyRelationDistanceConstraints(nodes, [link], { + orbitScale: I.galaxyRelationOrbitScale(setting), strengthMultiplier: 2, + responseMultiplier, wallClockSeconds: 1 / 30, rate: 24, maxCorrection, + }); + return { + distance: Math.abs(nodes[1].x - nodes[0].x), + target: I.galaxySpringDistance(link, I.galaxyRelationOrbitScale(setting)), + beforeCom, afterCom: (nodes[0].x * 4 + nodes[1].x) / 5, stats, + }; + }; + return { + tight: run(8, 1, 12), loose: run(80, 1, 12), + responseStable: run(8, 1, 100), unsafeDoubled: run(8, 2, 100), + capStable: run(80, 1, 12), unsafeCapDoubled: run(80, 2, 12), + }; + })(), + }); + """ + ) + assert report["tight"]["orbitScale"] == pytest.approx(1 / 16) + assert report["baseline"]["orbitScale"] == pytest.approx(0.25) + assert report["reference"]["orbitScale"] == pytest.approx(1) + assert report["loose"]["orbitScale"] == pytest.approx(25) + assert report["tight"]["target"] == pytest.approx(1.25) + assert report["baseline"]["target"] == pytest.approx(5) + assert report["loose"]["target"] == pytest.approx(500) + assert report["baseline"]["velocities"] == pytest.approx( + [value * 2 for value in report["ordinary"]] + ) + assert report["loose"]["target"] == report["unsafeLoose"]["target"] + assert report["unsafeLoose"]["velocities"] == pytest.approx( + [value * 2 for value in report["loose"]["velocities"]] + ) + assert report["unsafeLoose"]["stats"]["maximumAcceleration"] == pytest.approx( + report["loose"]["stats"]["maximumAcceleration"] * 2 + ) + assert report["tight"]["velocities"][0] > 0 + assert report["loose"]["velocities"][0] < 0 + assert report["constraint"]["tight"]["distance"] < 10 + assert report["constraint"]["loose"]["distance"] > 10 + assert report["constraint"]["tight"]["stats"]["applied"] == 1 + assert report["constraint"]["loose"]["stats"]["applied"] == 1 + assert report["constraint"]["unsafeDoubled"]["target"] == \ + report["constraint"]["responseStable"]["target"] + # Doubling a continuous convergence rate squares the fraction of relation error left + # after one frame. It must not multiply the completed displacement past the target. + prior_correction = report["constraint"]["responseStable"]["stats"]["correctedDistance"] + initial_error = 5 + prior_response = prior_correction / initial_error + doubled_response = 1 - (1 - prior_response) ** 2 + assert report["constraint"]["unsafeDoubled"]["stats"]["correctedDistance"] \ + == pytest.approx(initial_error * doubled_response, rel=1e-12) + assert report["constraint"]["unsafeDoubled"]["stats"]["correctedDistance"] \ + < prior_correction * 2 + assert report["constraint"]["capStable"]["stats"]["maximumNodeShift"] \ + == pytest.approx(9.6) + assert report["constraint"]["unsafeCapDoubled"]["stats"]["maximumNodeShift"] \ + == pytest.approx(9.6) + assert report["constraint"]["capStable"]["stats"]["correctedDistance"] \ + == pytest.approx(12) + assert report["constraint"]["unsafeCapDoubled"]["stats"]["correctedDistance"] \ + == pytest.approx(12) + assert report["constraint"]["unsafeCapDoubled"]["stats"]["correctedDistance"] \ + == pytest.approx(report["constraint"]["capStable"]["stats"]["correctedDistance"]) + assert report["constraint"]["tight"]["afterCom"] == pytest.approx( + report["constraint"]["tight"]["beforeCom"], abs=1e-12 + ) + assert report["constraint"]["loose"]["afterCom"] == pytest.approx( + report["constraint"]["loose"]["beforeCom"], abs=1e-12 + ) + assert all( + item["momentum"] == pytest.approx(0, abs=1e-12) + for item in (report["tight"], report["baseline"], report["loose"]) + ) + + +@requires_node +def test_orbital_separation_is_contractive_and_preserves_local_mass_center() -> None: + report = _run_node( + """ + const run = (setting, strengthOverride = null) => { + const nodes = [ + { id: 'star', x: 0, y: 0, vx: 0, vy: 0, radius: 3, + gravity_mass: 4, community_id: 'solar' }, + { id: 'planet', x: 10, y: 0, vx: 0, vy: 0, radius: 3, + gravity_mass: 1, community_id: 'solar' }, + { id: 'other-system', x: 1, y: 0, vx: 0, vy: 0, radius: 3, + gravity_mass: 2, community_id: 'other' }, + ]; + const beforeCom = (nodes[0].x * 4 + nodes[1].x) / 5; + const otherBefore = [nodes[2].x, nodes[2].y, nodes[2].vx, nodes[2].vy]; + const padding = I.galaxyOrbitalSeparationPadding(setting); + const strength = I.galaxyOrbitalSeparationStrength(setting); + const stats = I.applyGalaxyOrbitalSeparation(nodes, { + padding, strength: strengthOverride === null ? strength : strengthOverride, + maxCorrection: 100, maxVelocityCorrection: 100, + }); + return { + padding, strength, stats, + distance: Math.hypot(nodes[1].x - nodes[0].x, nodes[1].y - nodes[0].y), + beforeCom, afterCom: (nodes[0].x * 4 + nodes[1].x) / 5, + otherBefore, + otherAfter: [nodes[2].x, nodes[2].y, nodes[2].vx, nodes[2].vy], + }; + }; + emit({ off: run(0), default: run(48), preset: run(60), maximum: run(120), + priorDefault: run(48, 0.8), priorMaximum: run(120, 1) }); + """ + ) + assert report["off"]["padding"] == 0 + assert report["off"]["strength"] == 0 + assert report["off"]["distance"] == pytest.approx(10) + assert report["default"]["padding"] == pytest.approx(12) + assert report["default"]["strength"] == pytest.approx(0.8) + assert report["default"]["distance"] == pytest.approx(16.4) + assert report["preset"]["strength"] == pytest.approx(1) + assert report["preset"]["distance"] == pytest.approx(21) + assert report["maximum"]["padding"] == pytest.approx(30) + assert report["maximum"]["strength"] == pytest.approx(1) + assert report["maximum"]["distance"] == pytest.approx(36) + # The release-safe response never exceeds one. It approaches contact monotonically and + # retains the pre-speed-up 48-setting calibration instead of crossing the manifold. + assert report["default"]["stats"]["correctionDistance"] == pytest.approx( + report["priorDefault"]["stats"]["correctionDistance"] + ) + assert report["maximum"]["stats"]["correctionDistance"] == pytest.approx( + report["priorMaximum"]["stats"]["correctionDistance"] + ) + for item in (report["default"], report["preset"], report["maximum"]): + assert item["stats"]["overlaps"] == 1 + assert item["afterCom"] == pytest.approx(item["beforeCom"], abs=1e-12) + assert item["otherAfter"] == item["otherBefore"] + + +@requires_node +def test_cross_system_repulsion_is_weak_bounded_and_preserves_orbital_velocity() -> None: + report = _run_node( + """ + const fixture = (leftVx, rightVx) => [ + { id: 'heavy', community_id: 'left-system', x: 0, y: 0, + vx: leftVx, vy: 0, radius: 3, gravity_mass: 4 }, + { id: 'light', community_id: 'right-system', x: 4, y: 0, + vx: rightVx, vy: 0, radius: 3, gravity_mass: 1 }, + ]; + const options = { + padding: 12, strength: 0, + crossCommunityPadding: 1.5, crossCommunityStrength: 0.16, + maxCorrection: 4, maxVelocityCorrection: 8, + }; + const closing = fixture(1, -1); + const separating = fixture(-1, 1); + const disabled = fixture(1, -1); + const beforeCom = (closing[0].x * 4 + closing[1].x) / 5; + const beforeMomentum = closing[0].vx * 4 + closing[1].vx; + const stats = I.applyGalaxyOrbitalSeparation(closing, options); + I.applyGalaxyOrbitalSeparation(separating, options); + const disabledStats = I.applyGalaxyOrbitalSeparation(disabled, { + ...options, crossCommunityStrength: 0, + }); + emit({ + stats, disabledStats, + distance: closing[1].x - closing[0].x, + center: (closing[0].x * 4 + closing[1].x) / 5, + beforeCom, + momentum: closing[0].vx * 4 + closing[1].vx, + beforeMomentum, + closingVelocity: closing.map(node => node.vx), + separatingVelocity: separating.map(node => node.vx), + disabledPhase: disabled.map(node => [node.x, node.y, node.vx, node.vy]), + finite: closing.concat(separating).every(node => + [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), + }); + """ + ) + assert report["finite"] is True + assert report["stats"]["crossCommunityPairs"] == 1 + assert report["stats"]["crossCommunityOverlaps"] == 1 + assert report["stats"]["crossCommunityCorrectionDistance"] == pytest.approx(0.56) + assert report["distance"] == pytest.approx(4.56) + assert report["center"] == pytest.approx(report["beforeCom"], abs=1e-12) + assert report["momentum"] == pytest.approx(report["beforeMomentum"], abs=1e-12) + # Cross-system contact is positional only: dissipating its COM motion repeatedly in a + # crowded galaxy bleeds the tangential velocity that keeps both systems orbiting the well. + assert report["closingVelocity"] == pytest.approx([1, -1], abs=1e-12) + assert report["separatingVelocity"] == pytest.approx([-1, 1], abs=1e-12) + assert report["disabledStats"]["overlaps"] == 0 + assert report["disabledPhase"] == [[0, 0, 1, 0], [4, 0, -1, 0]] + + +@requires_node +def test_cross_system_repulsion_translates_whole_systems_without_warping_orbits() -> None: + report = _run_node( + """ + const fixture = () => [ + { id: 'left-star', community_id: 'left-system', x: 0, y: 0, + vx: 1, vy: 0, radius: 1, gravity_mass: 3 }, + { id: 'left-moon', community_id: 'left-system', x: 2, y: 1, + vx: 1, vy: 2, radius: 1, gravity_mass: 1 }, + { id: 'right-star', community_id: 'right-system', x: 5, y: 0, + vx: -1, vy: 0, radius: 1, gravity_mass: 2 }, + { id: 'right-moon', community_id: 'right-system', x: 7, y: -1, + vx: -1, vy: -3, radius: 1, gravity_mass: 1 }, + ]; + const options = { + padding: 12, strength: 0, + crossCommunityPadding: 1.5, crossCommunityStrength: 0.16, + maxCorrection: 4, maxVelocityCorrection: 8, + }; + const relativeState = nodes => [ + nodes[1].x - nodes[0].x, nodes[1].y - nodes[0].y, + nodes[1].vx - nodes[0].vx, nodes[1].vy - nodes[0].vy, + nodes[3].x - nodes[2].x, nodes[3].y - nodes[2].y, + nodes[3].vx - nodes[2].vx, nodes[3].vy - nodes[2].vy, + ]; + const totals = nodes => { + const mass = nodes.reduce((sum, node) => sum + node.gravity_mass, 0); + return { + center: [ + nodes.reduce((sum, node) => sum + node.x * node.gravity_mass, 0) / mass, + nodes.reduce((sum, node) => sum + node.y * node.gravity_mass, 0) / mass, + ], + momentum: [ + nodes.reduce((sum, node) => sum + node.vx * node.gravity_mass, 0), + nodes.reduce((sum, node) => sum + node.vy * node.gravity_mass, 0), + ], + }; + }; + const nodes = fixture(); + const beforeRelative = relativeState(nodes); + const beforeTotals = totals(nodes); + const stats = I.applyGalaxyOrbitalSeparation(nodes, options); + const fixed = fixture(); + const fixedLeftBefore = fixed.slice(0, 2).map(node => + [node.x, node.y, node.vx, node.vy]); + I.applyGalaxyOrbitalSeparation(fixed, { ...options, fixedNodeId: 'left-star' }); + emit({ + stats, + beforeRelative, + afterRelative: relativeState(nodes), + beforeTotals, + afterTotals: totals(nodes), + fixedLeftBefore, + fixedLeftAfter: fixed.slice(0, 2).map(node => + [node.x, node.y, node.vx, node.vy]), + fixedRightMoved: fixed[2].x !== 5 || fixed[2].y !== 0, + finite: nodes.concat(fixed).every(node => + [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), + }); + """ + ) + assert report["finite"] is True + assert report["stats"]["crossCommunityOverlaps"] == 1 + assert report["afterRelative"] == pytest.approx( + report["beforeRelative"], abs=1e-12 + ) + assert report["afterTotals"]["center"] == pytest.approx( + report["beforeTotals"]["center"], abs=1e-12 + ) + assert report["afterTotals"]["momentum"] == pytest.approx( + report["beforeTotals"]["momentum"], abs=1e-12 + ) + assert report["fixedLeftAfter"] == report["fixedLeftBefore"] + assert report["fixedRightMoved"] is True + + +@requires_node +def test_dense_system_admission_assigns_clear_carrier_lanes_without_warping_local_frames() -> None: + """505 stacked systems receive one collision-free carrier admission, not live packing.""" + report = _run_node( + """ + const SYSTEMS = 84, PLANETS = 5, GAP = 2.4; + const nodes = [{ id: 'custom-central-mass', anchor_role: 'global', community_id: 'core', + gravity_mass: 64, radius: 9, x: 0, y: 0, vx: 0, vy: 0 }]; + for (let system = 0; system < SYSTEMS; system++) { + const id = 'packed-' + system, starId = id + '-star'; + nodes.push({ id: starId, anchor_role: 'community', community_id: id, + system_anchor_id: starId, orbit_tier: 0, gravity_mass: 9, radius: 5, + x: 120, y: 0, vx: 1.5, vy: -2 }); + for (let planet = 1; planet <= PLANETS; planet++) { + const radius = 18 + planet * 4, angle = planet * Math.PI * 2 / PLANETS; + nodes.push({ id: `${id}-p${planet}`, community_id: id, system_anchor_id: starId, + orbit_tier: planet, gravity_mass: 1, radius: 2.5, + x: 120 + Math.cos(angle) * radius, y: Math.sin(angle) * radius, + vx: 1.5 - Math.sin(angle), vy: -2 + Math.cos(angle) }); + } + } + const byId = id => nodes.find(node => node.id === id); + const localFrames = () => Array.from({ length: SYSTEMS }, (_, system) => { + const id = 'packed-' + system, star = byId(id + '-star'); + return Array.from({ length: PLANETS }, (_, index) => { + const planet = byId(`${id}-p${index + 1}`); + return [planet.x - star.x, planet.y - star.y, planet.vx - star.vx, planet.vy - star.vy]; + }); + }); + const envelopes = () => I.galaxySystemEnvelopes(nodes, { + blackHoleExclusionPadding: 2.5, + }).filter(envelope => envelope.anchor.anchor_role === 'community'); + const metrics = () => { + const systems = envelopes(); let minimumClearance = Infinity, overlaps = 0; + for (let left = 0; left < systems.length; left++) for (let right = 0; + right < left; right++) { + const a = systems[left], b = systems[right]; + const clearance = Math.hypot(a.x - b.x, a.y - b.y) - a.radius - b.radius; + minimumClearance = Math.min(minimumClearance, clearance); + if (clearance < GAP - 1e-8) overlaps++; + } + const blackHole = nodes[0]; + const horizonClearance = Math.min(...systems.map(system => + Math.hypot(system.x - blackHole.x, system.y - blackHole.y) + - system.radius - blackHole.radius - 2.5)); + return { count: systems.length, minimumClearance, overlaps, horizonClearance }; + }; + const before = localFrames(), initial = metrics(); + const fixedBefore = nodes.filter(node => node.community_id === 'packed-0') + .map(node => [node.x, node.y, node.vx, node.vy]); + const admissionStart = performance.now(); + const stats = I.establishGalaxyCarrierLanes(nodes, { + blackHoleExclusionPadding: 2.5, layoutSeed: 7103, + }); + const admissionMilliseconds = performance.now() - admissionStart; + const after = localFrames(), final = metrics(); + const maximumLocalFrameError = Math.max(...after.flat(2).map((value, index) => + Math.abs(value - before.flat(2)[index]))); + emit({ nodes: nodes.length, initial, final, stats, admissionMilliseconds, + maximumLocalFrameError, + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); + """ + ) + assert report["nodes"] == 505 + assert report["finite"] is True + assert report["initial"]["overlaps"] == 84 * 83 // 2 + assert report["final"]["count"] == 84 + assert report["final"]["overlaps"] == 0 + assert report["final"]["minimumClearance"] >= 2.4 - 1e-6 + assert report["final"]["horizonClearance"] >= -1e-9 + assert report["stats"]["assigned"] == 84 + assert report["stats"]["moved"] == 84 + # Admission translates an entire solar system exactly once; no planet is warped in its + # carrier frame and live integration no longer needs a packer to repair it. + assert report["maximumLocalFrameError"] < 1e-10 + + +@requires_node +def test_live_dense_system_lanes_stay_clear_without_packing_under_default_high_and_reduced_physics() -> None: + """A pre-admitted 505-body galaxy remains clear while both orbit levels advance.""" + report = _run_node( + """ + const SYSTEMS = 84, PLANETS = 5; + const make = gap => { + const nodes = [{ id: 'bh', anchor_role: 'global', community_id: 'core', + gravity_mass: 64, radius: 9, x: 0, y: 0, vx: 0, vy: 0 }], links = []; + for (let system = 0; system < SYSTEMS; system++) { + const id = 'orbit-' + system, starId = id + '-star'; + nodes.push({ id: starId, anchor_role: 'community', community_id: id, + system_anchor_id: starId, orbit_tier: 0, gravity_mass: 9, radius: 5, + x: 150, y: 0, vx: 0, vy: 0 }); + for (let planet = 1; planet <= PLANETS; planet++) { + const radius = 18 + planet * 4, angle = planet * Math.PI * 2 / PLANETS; + const planetId = `${id}-p${planet}`; + nodes.push({ id: planetId, community_id: id, system_anchor_id: starId, + orbit_tier: planet, gravity_mass: 1, radius: 2.5, + x: 150 + Math.cos(angle) * radius, y: Math.sin(angle) * radius, vx: 0, vy: 0 }); + links.push({ source: starId, target: planetId, relation: 'orbits', + rest_length: radius, spring_strength: .08 }); + } + } + const admission = I.establishGalaxyCarrierLanes(nodes, { gap, layoutSeed: 8831 }); + I.seedGalaxyOrbits(nodes, 8831, 48, 32, false); + I.seedGalaxySystemOrbits(nodes, 8831, 48, 40, false); + return { nodes, links, admission }; + }; + const run = (gap, strength, reducedMotion) => { + const { nodes, links, admission } = make(gap); + const byId = id => nodes.find(node => node.id === id); + const initialRadius = new Map(nodes.filter(node => node.orbit_tier > 0).map(node => { + const star = byId(node.system_anchor_id); + return [node.id, Math.hypot(node.x - star.x, node.y - star.y)]; + })); + const options = { + gravity: 48, gravitationalConstant: 1, localGravitationalConstant: 1, + blackHoleMass: 1, softening: 32, centralSoftening: 40, + timestep: .032, wallClockSeconds: 1 / 30, velocityDecay: .00005, + speedLimit: 48, localRelativeSpeedLimit: 48, + includeMutualSystems: true, mutualSystemGravityFraction: .12, + mutualSystemSoftening: 80, exactLimit: 64, theta: .85, + includeRelations: true, includeRelationSprings: false, + skipSystemAnchorRelations: true, skipOrbitalSystemRelations: true, + includeOrbitalSeparation: true, orbitalSeparationPadding: 8, + orbitalSeparationStrength: .5, orbitalSeparationMaxCorrection: 4, + orbitalSeparationMaxVelocityCorrection: 8, preserveLocalTangentialVelocity: true, + preserveSystemRadii: true, skipSystemAnchorPairs: true, + systemAnchorExclusionPadding: 1.5, includeBlackHoleExclusion: true, + blackHoleExclusionPadding: 2.5, includeFarFieldConfinement: true, + farFieldEnvelopeScale: 2, farFieldMinimumRadius: 96, farFieldSoftFraction: .82, + farFieldAcceleration: 12, farFieldMaxAcceleration: 16, includeSpacetime: true, + frameDraggingFraction: .018, frameDraggingMaxAcceleration: .22, + eventHorizonDecayRate: .12, eventHorizonInwardAcceleration: .28, + includeCollisions: false, includeSystemPacking: false, systemPackingGap: gap, + systemPackingStrength: strength, systemPackingMaxCorrection: 12, reducedMotion, + }; + const clearance = () => { + const systems = I.galaxySystemEnvelopes(nodes).filter(system => + system.anchor.anchor_role === 'community'); + let minimum = Infinity, overlaps = 0; + for (let left = 0; left < systems.length; left++) for (let right = 0; + right < left; right++) { + const a = systems[left], b = systems[right]; + const value = Math.hypot(a.x - b.x, a.y - b.y) - a.radius - b.radius; + minimum = Math.min(minimum, value); + if (value < gap - 1e-8) overlaps++; + } + return { count: systems.length, minimum, overlaps }; + }; + const initial = clearance(); let speedCaps = 0, maximumRadiusDrift = 0; + let totalPackingAdjustments = 0, maximumRemainingOverlaps = 0; + const liveStart = performance.now(); + for (let step = 0; step < 120; step++) { + const tick = I.integrateGalaxyLeapfrog(nodes, links, [], options); + speedCaps += tick.speedCapped ? 1 : 0; + totalPackingAdjustments += tick.systemPacking.adjustedSystems; + maximumRemainingOverlaps = Math.max(maximumRemainingOverlaps, + tick.systemPacking.remainingOverlaps); + initialRadius.forEach((radius, id) => { + const node = byId(id), star = byId(node.system_anchor_id); + maximumRadiusDrift = Math.max(maximumRadiusDrift, + Math.abs(Math.hypot(node.x - star.x, node.y - star.y) - radius)); + }); + } + const liveMilliseconds = performance.now() - liveStart; + return { admission, initial, final: clearance(), speedCaps, maximumRadiusDrift, + totalPackingAdjustments, maximumRemainingOverlaps, liveMilliseconds, + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }; + }; + emit({ normal: run(8, .4, false), reduced: run(8, .4, true), high: run(12, .8, false) }); + """ + ) + for mode, gap in (("normal", 8), ("reduced", 8), ("high", 12)): + sample = report[mode] + assert sample["finite"] is True + assert sample["admission"]["assigned"] == 84 + assert sample["admission"]["moved"] == 84 + assert sample["initial"]["count"] == sample["final"]["count"] == 84 + assert sample["initial"]["overlaps"] == 0 + assert sample["final"]["overlaps"] == 0 + assert sample["final"]["minimum"] >= gap - 1e-6 + assert sample["speedCaps"] == 0 + # Carrier packing is exactly rigid; this allows only the small bounded Verlet orbit + # drift accrued across 120 real local-gravity steps (well below a painted pixel). + assert sample["maximumRadiusDrift"] < .01 + assert sample["maximumRemainingOverlaps"] == 0 + assert sample["totalPackingAdjustments"] == 0 + + +@requires_node +def test_annulus_aware_packing_keeps_two_large_solar_systems_clear_and_rigid() -> None: + """The finite galaxy annulus must not trade envelope overlap for an outer-bound escape.""" + report = _run_node( + """ + const OUTER = 249.375, GAP = 8; + const make = () => { + const nodes = [{ id: 'bh', anchor_role: 'global', community_id: 'core', + gravity_mass: 64, radius: 9, x: 0, y: 0, vx: 0, vy: 0 }]; + ['a', 'b'].forEach(id => { + const star = `${id}-star`; + nodes.push({ id: star, anchor_role: 'community', community_id: id, + system_anchor_id: star, orbit_tier: 0, gravity_mass: 9, radius: 5, + x: 120, y: 0, vx: 0, vy: 0 }); + nodes.push({ id: `${id}-planet`, community_id: id, system_anchor_id: star, + orbit_tier: 1, gravity_mass: 1, radius: 2.5, x: 159.5, y: 0, vx: 0, vy: 0 }); + }); + return nodes; + }; + const options = { + gravity: 48, gravitationalConstant: 1, localGravitationalConstant: 1, + blackHoleMass: 1, softening: 32, centralSoftening: 40, + includeFarFieldConfinement: true, farFieldEnvelopeRadius: OUTER, + farFieldMinimumRadius: 96, farFieldSoftFraction: .82, + farFieldAcceleration: 12, farFieldMaxAcceleration: 16, + includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, + includeCollisions: false, includeRelations: false, includeOrbitalSeparation: false, + includeSystemPacking: true, systemPackingGap: GAP, systemPackingStrength: 1, + systemPackingMaxCorrection: Infinity, timestep: .032, wallClockSeconds: 1 / 30, + velocityDecay: .00005, speedLimit: 48, localRelativeSpeedLimit: 48, + }; + const local = nodes => ['a', 'b'].map(id => { + const star = nodes.find(node => node.id === `${id}-star`); + const planet = nodes.find(node => node.id === `${id}-planet`); + return [planet.x - star.x, planet.y - star.y, planet.vx - star.vx, planet.vy - star.vy]; + }); + const safety = nodes => { + const bh = nodes[0]; + let inner = Infinity, outer = Infinity; + nodes.slice(1).forEach(node => { + const distance = Math.hypot(node.x - bh.x, node.y - bh.y); + inner = Math.min(inner, distance - bh.radius - node.radius - 2.5); + outer = Math.min(outer, OUTER - distance - node.radius); + }); + const systems = I.galaxySystemEnvelopes(nodes, options).filter(system => + system.anchor.anchor_role === 'community'); + return { inner, outer, pairClearance: Math.hypot(systems[0].x - systems[1].x, + systems[0].y - systems[1].y) - systems[0].radius - systems[1].radius }; + }; + const directNodes = make(), before = local(directNodes); + const direct = I.applyGalaxySystemPacking(directNodes, { + ...options, gap: GAP, strength: 1, maxCorrection: Infinity, + }); + const directAfter = local(directNodes), directSafety = safety(directNodes); + const directLocalFrameError = Math.max(...before.flatMap((frame, index) => + frame.map((value, component) => Math.abs(value - directAfter[index][component])))); + + const liveNodes = make(); + I.applyGalaxySystemPacking(liveNodes, { ...options, gap: GAP, strength: 1, maxCorrection: Infinity }); + liveNodes.forEach(node => { delete node.__galaxyOrbitSeeded; delete node.__galaxySystemOrbitSeeded; }); + I.seedGalaxyOrbits(liveNodes, 442, 48, 32, false); + I.seedGalaxySystemOrbits(liveNodes, 442, 48, 40, false); + let live = null, liveCaps = 0; + for (let step = 0; step < 24; step++) { + live = I.integrateGalaxyLeapfrog(liveNodes, [], [], options); + liveCaps += live.speedCapped ? 1 : 0; + } + + const kinematicNodes = make(); + I.applyGalaxySystemPacking(kinematicNodes, { ...options, gap: GAP, strength: 1, maxCorrection: Infinity }); + let kinematic = null; + for (let step = 0; step < 24; step++) { + kinematic = I.advanceGalaxyKinematicOrbits(kinematicNodes, { ...options, layoutSeed: 442 }); + } + emit({ direct, directLocalFrameError, directSafety, livePacking: live.systemPacking, + liveSafety: safety(liveNodes), liveCaps, kinematicPacking: kinematic.systemPacking, + kinematicSafety: safety(kinematicNodes), + finite: directNodes.concat(liveNodes, kinematicNodes).every(node => + [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); + """ + ) + assert report["finite"] is True + assert report["direct"]["remainingOverlaps"] == 0 + assert report["direct"]["boundaryViolations"] == 0 + assert report["direct"]["minimumBlackHoleClearance"] >= 0 + assert report["direct"]["minimumOuterClearance"] >= 0 + assert report["directSafety"]["pairClearance"] >= 8 - 1e-8 + assert report["directSafety"]["inner"] >= 0 + assert report["directSafety"]["outer"] >= 0 + assert report["directLocalFrameError"] <= 1e-12 + for packing, safety in ((report["livePacking"], report["liveSafety"]), + (report["kinematicPacking"], report["kinematicSafety"])): + assert packing["remainingOverlaps"] == 0 + assert packing["boundaryViolations"] == 0 + assert packing["minimumBlackHoleClearance"] >= 0 + assert packing["minimumOuterClearance"] >= 0 + assert safety["pairClearance"] >= 8 - 1e-8 + assert safety["inner"] >= 0 and safety["outer"] >= 0 + assert report["liveCaps"] == 0 + + +@requires_node +def test_far_field_confinement_bounds_painted_members_without_erasing_orbits() -> None: + """The outer guard is a physical boundary, not a centre-only convergence hint. + + In particular, a satellite in the anchor community and the outer member of a + multi-node external system must both be contained. The external system moves + rigidly, while the core satellite keeps its angular motion. + """ + report = _run_node( + """ + const options = { + /* Deliberately use the live/default envelope scale. */ + farFieldMinimumRadius: 120, + farFieldSoftFraction: 0.55, farFieldAcceleration: 0.2, + farFieldMaxAcceleration: 0.2, + }; + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 64, radius: 12, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'core-satellite', community_id: 'core', system_anchor_id: 'black-hole', + gravity_mass: 1, radius: 3, x: 900, y: 0, vx: 0, vy: 8 }, + { id: 'outer-star', anchor_role: 'community', community_id: 'outer', + system_anchor_id: 'outer-star', gravity_mass: 4, + radius: 5, x: 600, y: 0, vx: 0, vy: 3 }, + { id: 'outer-moon', community_id: 'outer', system_anchor_id: 'outer-star', + gravity_mass: 1, radius: 3, x: 760, y: 0, vx: 0, vy: 5 }, + /* A pointer-owned system exercises the same painted outer guard. */ + { id: 'fixed-star', anchor_role: 'community', community_id: 'fixed', + system_anchor_id: 'fixed-star', gravity_mass: 2, + radius: 3, x: 300, y: -40, vx: 2, vy: 1 }, + { id: 'fixed-moon', community_id: 'fixed', system_anchor_id: 'fixed-star', + gravity_mass: 1, radius: 2, x: 320, y: -40, vx: 2, vy: 4 }, + ]; + const fixedPhase = nodes.slice(4).map(node => [node.x, node.y, node.vx, node.vy]); + const bootstrap = I.applyGalaxyFarFieldConfinement(nodes, { + ...options, fixedNodeId: 'fixed-star', + }); + const envelope = bootstrap.envelopeRadius; + const core = nodes[1], star = nodes[2], moon = nodes[3]; + + /* The smooth far-field must act before the exact cap. Put the external system in + its soft band, but leave the core satellite for the strict member-level case. */ + core.x = envelope - 10; core.y = 0; core.vx = 0; core.vy = 8; + star.x = envelope - 80; star.y = 0; star.vx = 0; star.vy = 3; + moon.x = envelope + 80; moon.y = 0; moon.vx = 0; moon.vy = 5; + const gravity = I.applyGalaxyFarFieldGravity(nodes, options); + const inwardAcceleration = (star.vx * 4 + moon.vx) / 5; + const coreInwardAcceleration = core.vx; + + /* Escape the core member outright, and put only the outer painted member of the + external system past the cached envelope. Its COM is still within it. */ + core.x = envelope + 90; core.y = 0; core.vx = 12; core.vy = 8; + star.x = envelope - 180; star.y = 0; star.vx = 12; star.vy = 3; + moon.x = envelope + 40; moon.y = 0; moon.vx = 12; moon.vy = 5; + const externalRelativeBefore = [ + moon.x - star.x, moon.y - star.y, moon.vx - star.vx, moon.vy - star.vy, + ]; + const coreAngularBefore = core.x * core.vy - core.y * core.vx; + const constrained = I.applyGalaxyFarFieldConfinement(nodes, { + ...options, fixedNodeId: 'fixed-star', + }); + const externalRelativeAfterConstraint = [ + moon.x - star.x, moon.y - star.y, moon.vx - star.vx, moon.vy - star.vy, + ]; + const coreAngularAfterConstraint = core.x * core.vy - core.y * core.vx; + /* Pointer targets outside the envelope are clamped before paint for the source and + every companion, so release does not need to repair stretched geometry. */ + const fixedStar = nodes[4], fixedMoon = nodes[5]; + fixedStar.x = envelope + 240; fixedStar.y = -40; fixedStar.vx = 12; fixedStar.vy = 1; + fixedMoon.x = envelope + 260; fixedMoon.y = -40; fixedMoon.vx = 12; fixedMoon.vy = 4; + const fixedHeldBefore = nodes.slice(4).map(node => [node.x, node.y, node.vx, node.vy]); + const fixedHeld = I.applyGalaxyFarFieldConfinement(nodes, { + ...options, fixedNodeId: 'fixed-star', + }); + const fixedHeldAfter = nodes.slice(4).map(node => [node.x, node.y, node.vx, node.vy]); + const fixedHeldClearance = nodes.slice(4).map(node => + envelope - (Math.hypot(node.x, node.y) + node.radius)); + const fixedBeforeRelease = nodes.slice(4).map(node => [node.x, node.y]); + const released = I.applyGalaxyFarFieldConfinement(nodes, options); + const maximumFixedReleaseStep = Math.max(...nodes.slice(4).map((node, index) => + Math.hypot(node.x - fixedBeforeRelease[index][0], node.y - fixedBeforeRelease[index][1]))); + const clearance = node => envelope - (Math.hypot(node.x, node.y) + node.radius); + const nonFixed = nodes.slice(1, 4); + let maximumRadius = Math.max(...nonFixed.map(node => Math.hypot(node.x, node.y) + node.radius)); + let minimumClearance = Math.min(...nonFixed.map(clearance)); + let finalStep; + for (let step = 0; step < 240; step++) { + finalStep = I.integrateGalaxyLeapfrog(nodes, [], [], { + ...options, gravity: 0, central: true, fixedNodeId: 'fixed-star', + includeFarFieldConfinement: true, includeBlackHoleExclusion: true, + includeCollisions: false, includeRelations: false, + includeOrbitalSeparation: false, inwardConvergence: false, + timestep: 0.021328125, wallClockSeconds: 1 / 30, + velocityDecay: 0, speedLimit: 24, + }); + const currentEnvelope = finalStep.farFieldConfinement.envelopeRadius; + nonFixed.forEach(node => { + maximumRadius = Math.max(maximumRadius, Math.hypot(node.x, node.y) + node.radius); + minimumClearance = Math.min(minimumClearance, + currentEnvelope - (Math.hypot(node.x, node.y) + node.radius)); + }); + } + emit({ + bootstrap, gravity, constrained, envelope, inwardAcceleration, + coreInwardAcceleration, + externalRelativeBefore, + externalRelativeAfterConstraint, + coreAngularBefore, + coreAngularAfterConstraint, + coreTangentAfterConstraint: core.vy, + coreAngularAfter: core.x * core.vy - core.y * core.vx, + fixedPhase, + fixedHeld, fixedHeldBefore, fixedHeldAfter, fixedHeldClearance, released, + maximumFixedReleaseStep, + fixedAfterRelease: nodes.slice(4).map(node => [node.x, node.y, node.vx, node.vy]), + minimumClearance, maximumRadius, + finalEnvelope: finalStep.farFieldConfinement.envelopeRadius, + maximumSpeed: finalStep.maximumSpeed, + horizonClearance: Math.hypot(core.x, core.y) - nodes[0].radius - core.radius - 2.5, + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), + }); + """ + ) + assert report["finite"] is True + assert report["bootstrap"]["envelopeRadius"] > 0 + assert report["gravity"]["acceleratedSystems"] >= 1 + assert report["gravity"]["acceleratedCoreNodes"] >= 1 + assert report["inwardAcceleration"] < 0 + assert report["coreInwardAcceleration"] < 0 + assert report["constrained"]["boundedCoreNodes"] >= 1 + assert report["constrained"]["boundedSystems"] >= 1 + assert report["externalRelativeAfterConstraint"] == pytest.approx( + report["externalRelativeBefore"], abs=1e-10 + ) + # The exact inward cap must retain the tangential direction instead of stopping or + # reversing the satellite. It intentionally does not speed it up to manufacture L. + assert 0 < report["coreAngularAfterConstraint"] <= report["coreAngularBefore"] + assert report["coreTangentAfterConstraint"] > 0 + assert report["coreAngularAfter"] > 0 + assert report["fixedHeld"]["boundedFixedSource"] >= 1 + assert report["fixedHeld"]["boundedFixedFollowers"] >= 1 + assert min(report["fixedHeldClearance"]) >= -1e-8 + assert abs(report["fixedHeldClearance"][0]) <= 1e-8 + assert report["maximumFixedReleaseStep"] <= 48 + assert all( + math.hypot(phase[0], phase[1]) + radius <= report["finalEnvelope"] + 1e-8 + for phase, radius in zip(report["fixedAfterRelease"], [3, 2]) + ) + assert report["minimumClearance"] >= -1e-8 + assert report["maximumRadius"] <= report["finalEnvelope"] + 1e-8 + assert report["horizonClearance"] >= -1e-8 + assert report["maximumSpeed"] <= 24 + + +@requires_node +def test_far_field_envelope_cache_survives_frozen_anchor() -> None: + """Object.defineProperty silently fails on frozen nodes; the WeakMap cache must still pin + the envelope so a late outward escape cannot make the permitted radius chase it.""" + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 64, radius: 12, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'inner', community_id: 'core', gravity_mass: 2, + radius: 3, x: 40, y: 0, vx: 0, vy: 4 }, + { id: 'outer-star', community_id: 'outer', gravity_mass: 4, + radius: 5, x: 90, y: 0, vx: 0, vy: 3 }, + { id: 'outer-moon', community_id: 'outer', gravity_mass: 1, + radius: 3, x: 102, y: 6, vx: 0, vy: 5 }, + ]; + const anchor = nodes[0]; + const first = I.galaxyFarFieldEnvelope(nodes, { + farFieldMinimumRadius: 96, farFieldEnvelopeScale: 1.25, + farFieldSoftFraction: 0.82, + }); + Object.freeze(anchor); + const whileFrozen = I.galaxyFarFieldEnvelope(nodes, { + farFieldMinimumRadius: 96, farFieldEnvelopeScale: 1.25, + farFieldSoftFraction: 0.82, + }); + nodes[2].x = first.envelopeRadius + 400; + nodes[2].y = 0; + nodes[3].x = first.envelopeRadius + 420; + nodes[3].y = 0; + const afterEscape = I.galaxyFarFieldEnvelope(nodes, { + farFieldMinimumRadius: 96, farFieldEnvelopeScale: 1.25, + farFieldSoftFraction: 0.82, + }); + emit({ + initial: first.envelopeRadius, + whileFrozen: whileFrozen.envelopeRadius, + afterEscape: afterEscape.envelopeRadius, + anchorFrozen: Object.isFrozen(anchor), + finite: nodes.every(node => + [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), + }); + """ + ) + assert report["finite"] is True + assert report["anchorFrozen"] is True + assert report["initial"] > 0 + assert report["whileFrozen"] == pytest.approx(report["initial"], abs=1e-12) + assert report["afterEscape"] == pytest.approx(report["initial"], abs=1e-12) + +@requires_node +def test_pathological_oversized_system_stays_inside_the_black_hole_annulus() -> None: + """The final annular pass must solve both edges after an impossible rigid outer fit.""" + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 64, radius: 12, x: 0, y: 0, vx: 0, vy: 0 }, + /* A heavy near member makes the external COM stay near the horizon while its light + partner stretches far beyond the cached envelope. The rigid outer correction + therefore carries this member through the black hole unless the final annulus + alternates the two strict boundaries member-by-member. */ + { id: 'heavy-near', community_id: 'pathological', gravity_mass: 100, + radius: 4, x: 40, y: 0, vx: 2, vy: 3 }, + { id: 'light-far', community_id: 'pathological', gravity_mass: 1, + radius: 4, x: 80, y: 0, vx: 2, vy: -2 }, + ]; + const options = { + gravity: 0, central: true, includeFarFieldConfinement: true, + includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, + includeCollisions: false, includeRelations: false, + includeOrbitalSeparation: false, inwardConvergence: false, + timestep: 0.021328125, wallClockSeconds: 1 / 30, + velocityDecay: 0, speedLimit: 24, farFieldMinimumRadius: 80, + }; + /* Cache a normal painted extent first; this emulates a late pathological deformation + rather than allowing the anomalous member to enlarge the initial envelope. */ + const bootstrap = I.applyGalaxyFarFieldConfinement(nodes, options); + const envelope = bootstrap.envelopeRadius; + nodes[1].x = 20; nodes[1].y = 0; nodes[1].vx = 4; nodes[1].vy = 3; + nodes[2].x = envelope + 300; nodes[2].y = 0; nodes[2].vx = 4; nodes[2].vy = -2; + let minimumInner = Infinity, minimumOuter = Infinity; + let oversized = 0, horizonContacts = 0, annulusInner = 0, annulusOuter = 0; + let finalStep; + for (let step = 0; step < 8; step++) { + finalStep = I.integrateGalaxyLeapfrog(nodes, [], [], options); + const far = finalStep.farFieldConfinement; + oversized += far.boundedOversizedNodes; + horizonContacts += finalStep.blackHoleExclusion.contacts; + annulusInner += far.annulus.innerCorrectedNodes; + annulusOuter += far.annulus.outerCorrectedNodes; + nodes.slice(1).forEach(node => { + const distance = Math.hypot(node.x - nodes[0].x, node.y - nodes[0].y); + minimumInner = Math.min(minimumInner, + distance - nodes[0].radius - node.radius - options.blackHoleExclusionPadding); + minimumOuter = Math.min(minimumOuter, + far.envelopeRadius - (distance + node.radius)); + }); + } + emit({ + bootstrap, finalStep, envelope, oversized, horizonContacts, annulusInner, annulusOuter, + minimumInner, minimumOuter, + anchor: [nodes[0].x, nodes[0].y, nodes[0].vx, nodes[0].vy], + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), + maximumSpeed: finalStep.maximumSpeed, + }); + """ + ) + assert report["bootstrap"]["envelopeRadius"] > 0 + assert report["finite"] is True + assert report["anchor"] == pytest.approx([0, 0, 0, 0], abs=1e-12) + assert report["oversized"] > 0 + assert report["horizonContacts"] > 0 + assert report["minimumInner"] >= -1e-8 + assert report["minimumOuter"] >= -1e-8 + assert report["maximumSpeed"] <= 24 + + +@requires_node +def test_final_outer_annulus_never_reopens_a_dominant_star_surface_overlap() -> None: + """The final painted phase must satisfy the outer and local stellar bounds together.""" + report = _run_node( + """ + const blackHole = { id: 'bh', anchor_role: 'global', community_id: 'core', + gravity_mass: 20, radius: 10, x: 0, y: 0, vx: 0, vy: 0 }; + const nodes = [blackHole]; + const boundaryOptions = { + includeFarFieldConfinement: true, farFieldEnvelopeScale: 1, + farFieldMinimumRadius: 96, farFieldSoftFraction: 0.82, + farFieldAcceleration: 12, farFieldMaxAcceleration: 16, + }; + // Cache the 96-unit envelope before the late outer system appears. + const bootstrap = I.applyGalaxyFarFieldConfinement(nodes, boundaryOptions); + const star = { id: 'star', anchor_role: 'community', community_id: 'solar', + system_anchor_id: 'star', orbit_tier: 0, gravity_mass: 8, radius: 5, + x: 88, y: 0, vx: 0, vy: 0 }; + const planet = { id: 'planet', community_id: 'solar', system_anchor_id: 'star', + orbit_tier: 1, gravity_mass: 1, radius: 3, x: 96, y: 0, vx: 0, vy: 0 }; + nodes.push(star, planet); + const options = { + ...boundaryOptions, gravity: 0, softening: 32, centralSoftening: 40, + includeRelations: false, includeMutualSystems: false, + includeOrbitalSeparation: false, includeCollisions: false, + includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, + systemAnchorExclusionPadding: 1.5, + timestep: 0.032, wallClockSeconds: 1 / 30, + inwardConvergence: false, velocityDecay: 0.0001, speedLimit: 48, + }; + let tick, minimumActualStarClearance = Infinity, firstFrame = null; + let totalBoundedSystems = 0, totalCorrectedDistance = 0; + for (let step = 0; step < 12; step += 1) { + tick = I.integrateGalaxyLeapfrog(nodes, [], [], options); + const actualStarClearance = Math.hypot(planet.x - star.x, planet.y - star.y) + - star.radius - planet.radius - options.systemAnchorExclusionPadding; + minimumActualStarClearance = Math.min( + minimumActualStarClearance, actualStarClearance); + totalBoundedSystems += tick.farFieldConfinement.boundedSystems; + totalCorrectedDistance += tick.farFieldConfinement.correctedDistance; + if (step === 0) { + firstFrame = { + starClearance: actualStarClearance, + reportedStarClearance: tick.systemAnchorExclusion.minimumClearance, + blackHoleClearance: Math.min(...nodes.slice(1).map(node => + Math.hypot(node.x - blackHole.x, node.y - blackHole.y) + - blackHole.radius - node.radius - options.blackHoleExclusionPadding)), + outerClearance: Math.min(...nodes.slice(1).map(node => + tick.farFieldConfinement.envelopeRadius + - Math.hypot(node.x - blackHole.x, node.y - blackHole.y) - node.radius)), + }; + } + } + const starClearance = Math.hypot(planet.x - star.x, planet.y - star.y) + - star.radius - planet.radius - options.systemAnchorExclusionPadding; + const blackHoleClearance = Math.min(...nodes.slice(1).map(node => + Math.hypot(node.x - blackHole.x, node.y - blackHole.y) + - blackHole.radius - node.radius - options.blackHoleExclusionPadding)); + const outerClearance = Math.min(...nodes.slice(1).map(node => + tick.farFieldConfinement.envelopeRadius + - Math.hypot(node.x - blackHole.x, node.y - blackHole.y) - node.radius)); + emit({ + bootstrap: bootstrap.envelopeRadius, + envelope: tick.farFieldConfinement.envelopeRadius, + starClearance, minimumActualStarClearance, blackHoleClearance, outerClearance, + firstFrame, totalBoundedSystems, totalCorrectedDistance, + reportedStarClearance: tick.systemAnchorExclusion.minimumClearance, + boundaryIterations: tick.systemAnchorExclusion.boundaryIterations, + annulus: tick.farFieldConfinement.annulus, + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)), + }); + """ + ) + assert report["bootstrap"] == report["envelope"] == pytest.approx(96) + assert report["finite"] is True + assert report["minimumActualStarClearance"] >= -1e-9, report + assert report["firstFrame"]["starClearance"] >= -1e-9, report + assert report["firstFrame"]["reportedStarClearance"] == pytest.approx( + report["firstFrame"]["starClearance"], abs=1e-9 + ) + assert report["firstFrame"]["blackHoleClearance"] >= -1e-9 + assert report["firstFrame"]["outerClearance"] >= -1e-9 + assert report["starClearance"] >= -1e-9 + assert report["blackHoleClearance"] >= -1e-9 + assert report["outerClearance"] >= -1e-9 + assert report["reportedStarClearance"] == pytest.approx( + report["starClearance"], abs=1e-9 + ) + assert report["boundaryIterations"] > 0 + assert report["totalBoundedSystems"] > 0 + assert report["totalCorrectedDistance"] > 0 + assert report["annulus"]["infeasibleNodes"] == 0 + + +@requires_node +def test_black_hole_exclusion_preserves_system_orbits_at_the_painted_edge() -> None: + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + x: 0, y: 0, vx: 0, vy: 0, radius: 12, gravity_mass: 64 }, + { id: 'core-satellite', community_id: 'core', system_anchor_id: 'black-hole', + x: 2, y: 0, vx: -4, vy: 7, radius: 3, gravity_mass: 1 }, + { id: 'outer-star', anchor_role: 'community', community_id: 'outer', + system_anchor_id: 'outer-star', + x: 4, y: 0, vx: -3, vy: 2, radius: 4, gravity_mass: 4 }, + { id: 'outer-planet', community_id: 'outer', system_anchor_id: 'outer-star', + x: 8, y: 0, vx: -3, vy: 7, radius: 2, gravity_mass: 1 }, + ]; + const before = { + diameter: Math.hypot(nodes[3].x - nodes[2].x, nodes[3].y - nodes[2].y), + relativeVelocity: [nodes[3].vx - nodes[2].vx, nodes[3].vy - nodes[2].vy], + coreTangent: nodes[1].vy, + outerTangent: (nodes[2].vy * 4 + nodes[3].vy) / 5, + coreAngular: nodes[1].x * nodes[1].vy - nodes[1].y * nodes[1].vx, + outerAngular: ((nodes[2].x * 4 + nodes[3].x) / 5) + * ((nodes[2].vy * 4 + nodes[3].vy) / 5) + - ((nodes[2].y * 4 + nodes[3].y) / 5) + * ((nodes[2].vx * 4 + nodes[3].vx) / 5), + }; + const stats = I.applyGalaxyBlackHoleExclusion(nodes, { padding: 2.5 }); + const anchor = nodes[0]; + const clearances = nodes.slice(1).map(node => Math.hypot( + node.x - anchor.x, node.y - anchor.y + ) - anchor.radius - node.radius - 2.5); + emit({ + stats, + anchor: [anchor.x, anchor.y, anchor.vx, anchor.vy], + clearances, + core: [nodes[1].x, nodes[1].y, nodes[1].vx, nodes[1].vy], + diameter: Math.hypot(nodes[3].x - nodes[2].x, nodes[3].y - nodes[2].y), + relativeVelocity: [nodes[3].vx - nodes[2].vx, nodes[3].vy - nodes[2].vy], + outerTangent: (nodes[2].vy * 4 + nodes[3].vy) / 5, + coreAngular: nodes[1].x * nodes[1].vy - nodes[1].y * nodes[1].vx, + outerAngular: ((nodes[2].x * 4 + nodes[3].x) / 5) + * ((nodes[2].vy * 4 + nodes[3].vy) / 5) + - ((nodes[2].y * 4 + nodes[3].y) / 5) + * ((nodes[2].vx * 4 + nodes[3].vx) / 5), + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), + before, + }); + """ + ) + assert report["finite"] is True + assert report["anchor"] == pytest.approx([0, 0, 0, 0], abs=1e-12) + assert min(report["clearances"]) >= -1e-10 + assert report["stats"]["contacts"] == 2 + assert report["stats"]["systems"] == 1 + assert report["stats"]["coreNodes"] == 1 + assert report["stats"]["repelledNodes"] == 3 + assert report["stats"]["minimumClearance"] == pytest.approx(0, abs=1e-10) + assert report["stats"]["inwardVelocityRemoved"] == pytest.approx(7, abs=1e-12) + assert report["stats"]["tangentialVelocityRemoved"] > 0 + assert report["core"][2] == pytest.approx(0, abs=1e-12) + assert 0 < report["core"][3] < report["before"]["coreTangent"] + assert report["coreAngular"] == pytest.approx(report["before"]["coreAngular"], abs=1e-12) + assert report["diameter"] == pytest.approx(report["before"]["diameter"], abs=1e-12) + assert report["relativeVelocity"] == pytest.approx( + report["before"]["relativeVelocity"], abs=1e-12 + ) + assert 0 < report["outerTangent"] < report["before"]["outerTangent"] + assert report["outerAngular"] == pytest.approx( + report["before"]["outerAngular"], abs=1e-12 + ) + + +@requires_node +def test_link_and_orbital_separation_share_one_settling_target_without_jitter() -> None: + report = _run_node( + """ + const nodes = [ + { id: 'star', x: 0, y: 0, vx: 0, vy: 0, radius: 3, + gravity_mass: 4, community_id: 'solar' }, + { id: 'planet', x: 10, y: 0, vx: 0, vy: 0, radius: 3, + gravity_mass: 1, community_id: 'solar' }, + ]; + const links = [{ source: 'star', target: 'planet', rest_length: 20, + spring_strength: 0.1 }]; + const options = { + gravity: 0, central: false, timestep: 0.021328125, velocityDecay: 0.0001, + speedLimit: 48, includeCollisions: false, + includeRelations: true, includeRelationSprings: false, orbitScale: 0.25, + relationStrengthMultiplier: 2, relationConstraintRate: 24, + relationConstraintMaxCorrection: 12, relationPadding: 12, + wallClockSeconds: 1 / 30, + includeOrbitalSeparation: true, orbitalSeparationPadding: 12, + orbitalSeparationStrength: 0.8, orbitalSeparationMaxCorrection: 4, + orbitalSeparationMaxVelocityCorrection: 8, localRelativeSpeedLimit: 16, + // This unannotated compatibility pair is a relation/separation convergence fixture, + // not an explicit community-star stellar-pressure test. + systemAnchorRepulsionAcceleration: 0, + }; + const distances = [Math.hypot(nodes[1].x - nodes[0].x, + nodes[1].y - nodes[0].y)]; + const corrections = []; + let speedCaps = 0; + for (let step = 0; step < 120; step++) { + const tick = I.integrateGalaxyLeapfrog(nodes, links, [], options); + distances.push(Math.hypot(nodes[1].x - nodes[0].x, + nodes[1].y - nodes[0].y)); + corrections.push(tick.relationConstraint.correctedDistance + + tick.orbitalSeparation.correctionDistance); + speedCaps += tick.speedCapped ? 1 : 0; + } + emit({ + distances, corrections, speedCaps, + finalVelocity: nodes.map(node => [node.vx, node.vy]), + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)), + }); + """ + ) + assert report["finite"] is True + assert report["speedCaps"] == 0 + assert all( + current >= previous - 1e-10 + for previous, current in zip(report["distances"], report["distances"][1:]) + ) + assert report["distances"][-1] == pytest.approx(18, abs=2e-3) + # A bounded residual is expected while the relation and orbital-separation projections + # share the same settling target; it must remain three orders below the initial correction. + assert max(report["corrections"][-20:]) < report["corrections"][0] * 1e-3 + assert report["finalVelocity"][0] == pytest.approx(report["finalVelocity"][1], abs=1e-10) + assert math.hypot(*report["finalVelocity"][0]) <= 16 + + +@requires_node +def test_live_relation_constraints_skip_only_explicit_orbital_system_links() -> None: + """Topology links within an explicit solar system must not overwrite orbital phase.""" + report = _run_node( + """ + const fixture = () => [ + { id: 'star', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 0, + gravity_mass: 8, x: 0, y: 0 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 1, + gravity_mass: 1, x: 30, y: 0 }, + // Same community but no explicit anchor metadata: a compatibility relation remains + // eligible for the legacy Link constraint. + { id: 'legacy-a', community_id: 'legacy', gravity_mass: 1, x: 0, y: 20 }, + { id: 'legacy-b', community_id: 'legacy', gravity_mass: 1, x: 30, y: 20 }, + ]; + const links = [ + { source: 'star', target: 'planet', rest_length: 10, spring_strength: 0.2 }, + { source: 'legacy-a', target: 'legacy-b', rest_length: 10, spring_strength: 0.2 }, + ]; + const run = skipOrbitalSystemRelations => { + const nodes = fixture(); + const before = nodes.map(node => [node.x, node.y]); + const stats = I.applyGalaxyRelationDistanceConstraints(nodes, links, { + orbitScale: 1, rate: 24, wallClockSeconds: 1 / 30, maxCorrection: 12, + skipOrbitalSystemRelations, + }); + return { stats, before, after: nodes.map(node => [node.x, node.y]) }; + }; + emit({ live: run(true), legacy: run(false) }); + """ + ) + live, legacy = report["live"], report["legacy"] + assert live["stats"]["skippedOrbitalSystem"] == 1 + assert live["stats"]["applied"] == 1 + for actual, expected in zip(live["after"][:2], live["before"][:2]): + assert actual == pytest.approx(expected) + assert any(actual != pytest.approx(expected) + for actual, expected in zip(live["after"][2:], live["before"][2:])) + # Direct helper callers retain the compatibility behavior until they opt into the live + # orbital-system guard; both relations are then eligible. + assert legacy["stats"]["skippedOrbitalSystem"] == 0 + assert legacy["stats"]["applied"] == 2 + assert any(actual != pytest.approx(expected) + for actual, expected in zip(legacy["after"][:2], legacy["before"][:2])) + + +@requires_node +def test_dense_hub_constraints_are_simultaneous_order_independent_and_bounded() -> None: + report = _run_node( + """ + const make = () => { + const nodes = [{ id: 'hub', x: 0, y: 0, vx: 0, vy: 0, + gravity_mass: 12, radius: 8, community_id: 'dense' }]; + for (let index = 0; index < 24; index++) nodes.push({ + id: 'leaf-' + index, x: 90 + index * 0.2, y: -18 + index * 1.5, + vx: 0, vy: 0, gravity_mass: 1, radius: 2, community_id: 'dense', + }); + return nodes; + }; + const links = Array.from({ length: 24 }, (_, index) => ({ + source: 'hub', target: 'leaf-' + index, + rest_length: 20, spring_strength: 0.1, + })); + const run = reverse => { + const nodes = make(); + const beforeCom = nodes.reduce((sum, node) => ({ + x: sum.x + node.gravity_mass * node.x, + y: sum.y + node.gravity_mass * node.y, + mass: sum.mass + node.gravity_mass, + }), { x: 0, y: 0, mass: 0 }); + const stats = I.applyGalaxyRelationDistanceConstraints( + nodes, reverse ? [...links].reverse() : links, + { orbitScale: 0.25, strengthMultiplier: 2, + wallClockSeconds: 1 / 30, rate: 24, maxCorrection: 12, padding: 12 } + ); + const afterCom = nodes.reduce((sum, node) => ({ + x: sum.x + node.gravity_mass * node.x, + y: sum.y + node.gravity_mass * node.y, + mass: sum.mass + node.gravity_mass, + }), { x: 0, y: 0, mass: 0 }); + return { + phase: Object.fromEntries(nodes.map(node => [node.id, [node.x, node.y]])), + before: [beforeCom.x / beforeCom.mass, beforeCom.y / beforeCom.mass], + after: [afterCom.x / afterCom.mass, afterCom.y / afterCom.mass], + stats, + }; + }; + emit({ forward: run(false), reverse: run(true) }); + """ + ) + assert report["forward"]["stats"]["applied"] == 24 + assert report["forward"]["stats"]["aggregateLimited"] is True + assert report["forward"]["stats"]["maximumNodeShift"] == pytest.approx(12) + assert report["forward"]["after"] == pytest.approx(report["forward"]["before"], abs=1e-12) + assert report["reverse"]["after"] == pytest.approx(report["reverse"]["before"], abs=1e-12) + for node_id, phase in report["forward"]["phase"].items(): + assert report["reverse"]["phase"][node_id] == pytest.approx(phase, abs=1e-12) + + +@requires_node +def test_dense_orbital_contacts_and_hot_members_receive_one_bounded_system_update() -> None: + report = _run_node( + """ + const nodes = [{ id: 'hub', x: 0, y: 0, vx: 0, vy: 0, + gravity_mass: 12, radius: 8, community_id: 'dense' }]; + for (let index = 0; index < 20; index++) { + const angle = index / 20 * Math.PI * 2; + nodes.push({ id: 'leaf-' + index, + x: Math.cos(angle) * 6, y: Math.sin(angle) * 6, + vx: -Math.sin(angle) * (index === 3 ? 90 : 4), + vy: Math.cos(angle) * (index === 3 ? 90 : 4), + gravity_mass: 1, radius: 2, community_id: 'dense' }); + } + const beforeCom = nodes.reduce((sum, node) => ({ + x: sum.x + node.gravity_mass * node.x, + y: sum.y + node.gravity_mass * node.y, + mass: sum.mass + node.gravity_mass, + }), { x: 0, y: 0, mass: 0 }); + const separation = I.applyGalaxyOrbitalSeparation(nodes, { + padding: 12, strength: 0.8, maxCorrection: 4, maxVelocityCorrection: 8, + }); + const afterPositionCom = nodes.reduce((sum, node) => ({ + x: sum.x + node.gravity_mass * node.x, + y: sum.y + node.gravity_mass * node.y, + mass: sum.mass + node.gravity_mass, + }), { x: 0, y: 0, mass: 0 }); + const beforeMomentum = nodes.reduce((sum, node) => ({ + x: sum.x + node.gravity_mass * node.vx, + y: sum.y + node.gravity_mass * node.vy, + }), { x: 0, y: 0 }); + const velocity = I.stabilizeGalaxySystemVelocities(nodes, { limit: 16 }); + const afterMomentum = nodes.reduce((sum, node) => ({ + x: sum.x + node.gravity_mass * node.vx, + y: sum.y + node.gravity_mass * node.vy, + }), { x: 0, y: 0 }); + const mass = beforeCom.mass; + const centerVx = afterMomentum.x / mass, centerVy = afterMomentum.y / mass; + emit({ separation, velocity, + positionComBefore: [beforeCom.x / mass, beforeCom.y / mass], + positionComAfter: [afterPositionCom.x / mass, afterPositionCom.y / mass], + momentumBefore: beforeMomentum, momentumAfter: afterMomentum, + maximumFinalRelativeSpeed: Math.max(...nodes.map(node => + Math.hypot(node.vx - centerVx, node.vy - centerVy))), + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)), + }); + """ + ) + assert report["finite"] is True + assert report["separation"]["overlaps"] > 20 + assert report["separation"]["aggregateLimited"] is True + assert report["separation"]["maximumNodeShift"] <= 4 + 1e-12 + assert report["separation"]["maximumVelocityShift"] <= 8 + 1e-12 + assert report["positionComAfter"] == pytest.approx(report["positionComBefore"], abs=1e-12) + assert report["velocity"]["limitedSystems"] == 1 + assert report["maximumFinalRelativeSpeed"] == pytest.approx(16, abs=1e-10) + assert [report["momentumAfter"]["x"], report["momentumAfter"]["y"]] == pytest.approx( + [report["momentumBefore"]["x"], report["momentumBefore"]["y"]], abs=1e-10 + ) + + +@requires_node +def test_release_sized_dense_galaxy_never_reheats_or_ping_pongs_at_slider_extremes() -> None: + """The 542-body release shape stays contractive at both ordinary and 120/80 tuning. + + Endpoint displacement did not catch the regression: over-unity cross-system contact could + kick a solar-system COM one direction and project it back on the next frame while ending in + a plausible place. Sample every fixed step and require bounded radii/energy, signed phase, + painted clearances, and a low per-system COM-step tail for six seconds of solver time. + """ + report = _run_node( + """ + const make = () => { + const nodes = [{ id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', orbit_tier: 0, gravity_mass: 64, radius: 8, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'core-star', community_id: 'core', system_anchor_id: 'black-hole', + orbit_tier: 1, gravity_mass: 6, radius: 5, x: 52, y: 0, vx: 0, vy: 0 }]; + const links = [{ source: 'black-hole', target: 'core-star', rest_length: 52, + spring_strength: 0.08 }]; + for (let system = 0; system < 60; system++) { + const id = system === 0 ? 'aurora' : 'system-' + system; + const starId = id + '-star'; + const phase = 0.31 + system * 2.399963229728653; + const galacticRadius = 112 + system * 3.15; + const centerX = Math.cos(phase) * galacticRadius; + const centerY = Math.sin(phase) * galacticRadius * 0.84; + for (let member = 0; member < 9; member++) { + const localRadius = member === 0 ? 0 : (member === 1 ? 40 : 18 + member * 5); + const localPhase = phase + member * 2.399963229728653; + const nodeId = member === 0 ? starId + : (member === 1 ? id + '-planet' : id + '-planet-' + member); + nodes.push({ id: nodeId, community_id: id, + anchor_role: member === 0 ? 'community' : 'none', + system_anchor_id: starId, orbit_tier: member, + gravity_mass: member === 0 ? 8 + system % 5 : 1 + (member % 3) * 0.25, + radius: member === 0 ? 5.5 : 2.5, + x: centerX + Math.cos(localPhase) * localRadius, + y: centerY + Math.sin(localPhase) * localRadius, vx: 0, vy: 0 }); + if (member > 0) links.push({ source: starId, target: nodeId, + rest_length: localRadius, spring_strength: 0.08 }); + } + } + return { nodes, links }; + }; + const quantile = (items, portion) => { + const values = [...items].sort((a, b) => a - b); + return values[Math.floor((values.length - 1) * portion)]; + }; + const delta = (next, previous) => Math.atan2( + Math.sin(next - previous), Math.cos(next - previous)); + const run = (repel, link) => { + const { nodes, links } = make(); + // Admission chooses the exact carrier lane first; both global and local seed vectors + // are then composed in that final frame, as in layoutSeed 3031 at runtime. + I.establishGalaxyCarrierLanes(nodes, { gap: 8, layoutSeed: 3031 }); + I.seedGalaxyOrbits(nodes, 3031, 48, 32, false); + // Match galaxyIntegratorOptions(): Repel 60 yields live central softening 48. + I.seedGalaxySystemOrbits(nodes, 3031, 48, 48, false); + const separationPadding = I.galaxyOrbitalSeparationPadding(repel); + const separationStrength = I.galaxyOrbitalSeparationStrength(repel); + const options = { + layoutSeed: 3031, gravity: 48, softening: 32, centralSoftening: 48, + exactLimit: 64, theta: 0.85, + localPairFraction: 0.15, corePairMultiplier: 0.75, + includeBridges: false, includeMutualSystems: true, + mutualSystemGravityFraction: 0.12, mutualSystemSoftening: 80, + includeRelations: true, includeRelationSprings: false, + skipSystemAnchorRelations: true, skipOrbitalSystemRelations: true, + orbitScale: I.galaxyRelationOrbitScale(link), + relationConstraintStrengthMultiplier: 2, + relationConstraintResponseMultiplier: 1, + relationConstraintRate: 24, relationConstraintMaxCorrection: 12, + relationPadding: Math.max(1.5, separationPadding), + includeOrbitalSeparation: true, + orbitalSeparationPadding: separationPadding, + orbitalSeparationStrength: separationStrength, + crossCommunitySeparationPadding: 1.5, + crossCommunitySeparationStrength: separationStrength * 0.18, + orbitalSeparationMaxCorrection: 4, + orbitalSeparationMaxVelocityCorrection: 8, + preserveLocalTangentialVelocity: true, preserveSystemRadii: true, + skipSystemAnchorPairs: true, systemAnchorExclusionPadding: 1.5, + systemAnchorRepulsionRange: 6, systemAnchorRepulsionAcceleration: 0.12, + includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, + includeFarFieldConfinement: true, farFieldEnvelopeScale: 1.75, + farFieldMinimumRadius: 96, farFieldSoftFraction: 0.82, + farFieldAcceleration: 12, farFieldMaxAcceleration: 16, + localRelativeSpeedLimit: 48, timestep: 0.032, + inwardConvergence: false, wallClockSeconds: 1 / 30, + velocityDecay: 0.0001, speedLimit: 48, includeCollisions: false, + includeSystemPacking: false, + }; + const byId = new Map(nodes.map(node => [node.id, node])); + const tracked = ['aurora', 'system-11', 'system-23', 'system-35', + 'system-47', 'system-59']; + const local = new Map(tracked.map(id => { + const star = byId.get(id + '-star'), planet = byId.get( + id === 'aurora' ? 'aurora-planet' : id + '-planet'); + const dx = planet.x - star.x, dy = planet.y - star.y; + const dvx = planet.vx - star.vx, dvy = planet.vy - star.vy; + return [id, { star, planet, radius0: Math.hypot(dx, dy), + radiusMin: Math.hypot(dx, dy), radiusMax: Math.hypot(dx, dy), + angle: Math.atan2(dy, dx), direction: Math.sign(dx * dvy - dy * dvx), + reversals: 0, maxPhaseStep: 0, radialReversals: 0, + previousRadius: Math.hypot(dx, dy), previousRadial: 0, + kinetic0: 0.5 * star.gravity_mass * planet.gravity_mass + / (star.gravity_mass + planet.gravity_mass) * (dvx * dvx + dvy * dvy), + kineticMin: Infinity, kineticMax: 0 }]; + })); + const centers = () => new Map(nodes.filter(node => node.anchor_role === 'community') + .map(star => [String(star.id), { x: star.x, y: star.y, nodes: nodes.filter(node => + String(node.system_anchor_id || '') === String(star.id)), mass: star.gravity_mass }])); + let previousCenters = centers(); + const globalTracks = new Map(tracked.map(id => { + const center = previousCenters.get(id + '-star'), radius = Math.hypot(center.x, center.y); + const vx = center.nodes.reduce((sum, node) => sum + + node.gravity_mass * node.vx, 0) / center.mass; + const vy = center.nodes.reduce((sum, node) => sum + + node.gravity_mass * node.vy, 0) / center.mass; + return [id, { angle: Math.atan2(center.y, center.x), + direction: Math.sign(center.x * vy - center.y * vx), + radius0: radius, radiusMin: radius, radiusMax: radius, + reversals: 0, maxPhaseStep: 0 }]; + })); + const comSteps = [], crossCorrections = []; + let speedCaps = 0, localVelocityLimits = 0, maximumSpeed = 0; + let minimumBlackHoleClearance = Infinity, minimumStarClearance = Infinity; + let minimumOuterClearance = Infinity, maximumOrbitalShift = 0; + let alternatingRadialSteps = 0, relationApplications = 0; + for (let step = 0; step < 180; step++) { + const tick = I.integrateGalaxyLeapfrog(nodes, links, [], options); + speedCaps += tick.speedCapped ? 1 : 0; + localVelocityLimits += tick.systemVelocity.limitedSystems; + maximumSpeed = Math.max(maximumSpeed, tick.maximumSpeed); + maximumOrbitalShift = Math.max(maximumOrbitalShift, + tick.orbitalSeparation.maximumNodeShift || 0); + crossCorrections.push(tick.orbitalSeparation.crossCommunityCorrectionDistance || 0); + relationApplications += tick.relationConstraint.applied || 0; + const nextCenters = centers(); + nextCenters.forEach((center, id) => { + if (id === 'core') return; + const previous = previousCenters.get(id); + if (previous) comSteps.push(Math.hypot(center.x - previous.x, center.y - previous.y)); + }); + tracked.forEach(id => { + const item = local.get(id), star = item.star, planet = item.planet; + const dx = planet.x - star.x, dy = planet.y - star.y; + const radius = Math.hypot(dx, dy), angle = Math.atan2(dy, dx); + const phaseStep = delta(angle, item.angle); + if (item.direction && Math.sign(phaseStep) === -item.direction + && Math.abs(phaseStep) > 0.001) item.reversals++; + item.maxPhaseStep = Math.max(item.maxPhaseStep, Math.abs(phaseStep)); + const radialStep = radius - item.previousRadius; + if (item.previousRadial * radialStep < -0.0025) item.radialReversals++; + if (item.previousRadial * radialStep < -0.0025) alternatingRadialSteps++; + item.previousRadial = radialStep; + item.previousRadius = radius; + item.radiusMin = Math.min(item.radiusMin, radius); + item.radiusMax = Math.max(item.radiusMax, radius); + item.angle = angle; + const dvx = planet.vx - star.vx, dvy = planet.vy - star.vy; + const kinetic = 0.5 * star.gravity_mass * planet.gravity_mass + / (star.gravity_mass + planet.gravity_mass) * (dvx * dvx + dvy * dvy); + item.kineticMin = Math.min(item.kineticMin, kinetic); + item.kineticMax = Math.max(item.kineticMax, kinetic); + minimumStarClearance = Math.min(minimumStarClearance, + radius - star.radius - planet.radius - 1.5); + const center = nextCenters.get(star.id), global = globalTracks.get(id); + const globalRadius = Math.hypot(center.x, center.y); + const globalStep = delta(Math.atan2(center.y, center.x), global.angle); + if (global.direction && Math.sign(globalStep) === -global.direction + && Math.abs(globalStep) > 0.001) global.reversals++; + global.maxPhaseStep = Math.max(global.maxPhaseStep, Math.abs(globalStep)); + global.radiusMin = Math.min(global.radiusMin, globalRadius); + global.radiusMax = Math.max(global.radiusMax, globalRadius); + global.angle = Math.atan2(center.y, center.x); + }); + const envelope = tick.farFieldConfinement.envelopeRadius; + nodes.slice(1).forEach(node => { + minimumBlackHoleClearance = Math.min(minimumBlackHoleClearance, + Math.hypot(node.x, node.y) - nodes[0].radius - node.radius - 2.5); + minimumOuterClearance = Math.min(minimumOuterClearance, + envelope - Math.hypot(node.x, node.y) - node.radius); + }); + previousCenters = nextCenters; + } + return { + repel, link, separationStrength, + crossStrength: separationStrength * 0.18, + local: Object.fromEntries([...local].map(([id, item]) => [id, { + radius0: item.radius0, radiusMin: item.radiusMin, radiusMax: item.radiusMax, + reversals: item.reversals, radialReversals: item.radialReversals, + maxPhaseStep: item.maxPhaseStep, kinetic0: item.kinetic0, + kineticMin: item.kineticMin, kineticMax: item.kineticMax }])), + global: Object.fromEntries(globalTracks), + comStepMedian: quantile(comSteps, 0.5), comStepP95: quantile(comSteps, 0.95), + comStepMax: Math.max(...comSteps), + crossCorrectionP95: quantile(crossCorrections, 0.95), + crossCorrectionMax: Math.max(...crossCorrections), + speedCaps, localVelocityLimits, maximumSpeed, maximumOrbitalShift, + alternatingRadialSteps, relationApplications, + minimumBlackHoleClearance, minimumStarClearance, minimumOuterClearance, + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)), + }; + }; + emit({ ordinary: run(60, 8), maximum: run(120, 80) }); + """ + ) + for trial in report.values(): + assert trial["finite"] is True + assert trial["separationStrength"] == pytest.approx(1) + # This is the release bug's exact oracle: pressure 0.36 crossed the contact manifold. + assert trial["crossStrength"] == pytest.approx(0.18) + assert trial["speedCaps"] == 0 + assert trial["localVelocityLimits"] == 0 + assert trial["maximumSpeed"] < 48 + assert trial["maximumOrbitalShift"] <= 4 + 1e-9 + assert trial["relationApplications"] == 0 + assert trial["minimumBlackHoleClearance"] >= -1e-8 + assert trial["minimumStarClearance"] >= -1e-8 + assert trial["minimumOuterClearance"] >= -1e-8 + assert trial["comStepP95"] < 1.25, trial + assert trial["comStepMax"] < 3, trial + assert trial["crossCorrectionP95"] < 500, trial + assert trial["crossCorrectionMax"] < 900, trial + # Sparse eccentric perturbations are physical; the regression was frame-to-frame + # reversal across many systems. Across 1,080 tracked phase slices allow at most two. + assert sum(system["reversals"] for system in trial["local"].values()) <= 2 + for system in trial["local"].values(): + assert system["reversals"] <= 2 + assert system["radialReversals"] <= 12 + # 0.085 rad is 4.9 degrees per fixed slice. The unstable response reached + # 0.10415 here; retain margin for floating-point ordering without admitting it. + assert system["maxPhaseStep"] < 0.088 + assert system["radiusMin"] > system["radius0"] * 0.65 + assert system["radiusMax"] < system["radius0"] * 1.35 + assert system["kineticMin"] > system["kinetic0"] * 0.15 + assert system["kineticMax"] < system["kinetic0"] * 4 + for system_id, system in trial["global"].items(): + # A crowded galaxy may receive an occasional genuine near-field perturbation; + # four or fewer opposite samples in 180 slices is not the frame-to-frame ping-pong + # produced by the former over-unity contact response. + assert system["reversals"] == 0, (system_id, system, { + key: trial[key] for key in ("repel", "link", "comStepMedian", + "comStepP95", "comStepMax") + }) + assert system["maxPhaseStep"] < 0.08 + assert system["radiusMin"] > system["radius0"] * .99999 + assert system["radiusMax"] < system["radius0"] * 1.00001 + + +@requires_node +def test_drag_follow_uses_softened_source_mass_gravity_and_preserves_tangent() -> None: + report = _run_node( + """ + const run = ({ mass = 12, distance = 60, gravity = 48, + localGravitySetting = 48 } = {}) => { + const source = { id: 'star', x: 0, y: 0, vx: 0, vy: 0, + radius: 2, gravity_mass: mass, community_id: 'solar' }; + const follower = { id: 'planet', x: distance, y: 0, vx: 0, vy: 3, + radius: 2, gravity_mass: 1, community_id: 'solar' }; + const remote = { id: 'remote', x: 200, y: 40, vx: 2, vy: -1, + radius: 2, gravity_mass: 1, community_id: 'remote' }; + const beforeRemote = [remote.x, remote.y, remote.vx, remote.vy]; + const stats = I.applyDraggedNodeGravity(source, [{ + node: follower, + link: { source: 'star', target: 'planet', rest_length: 20, + spring_strength: 0.1 }, + }, { node: remote, link: null, proximity: 'field' }], { + gravity, localGravitySetting, linkSetting: 8, softening: 12, duration: 6, + maximumPull: 36, maximumImpulse: 8, padding: 1.5 }); + return { + follower: [follower.x, follower.y, follower.vx, follower.vy], + remote: [remote.x, remote.y, remote.vx, remote.vy], + beforeRemote, stats, + }; + }; + const coincidentSource = { id: 'same-star', x: 0, y: 0, + gravity_mass: 12, community_id: 'same' }; + const coincident = { id: 'same-planet', x: 0, y: 0, vx: 1, vy: 2, + gravity_mass: 1, community_id: 'same' }; + const coincidentStats = I.applyDraggedNodeGravity(coincidentSource, + [{ node: coincident }], { gravity: 100 }); + emit({ + heavy: run(), light: run({ mass: 6 }), + near: run({ distance: 60 }), far: run({ distance: 120 }), + zero: run({ gravity: 0 }), + coincident: [coincident.x, coincident.y, coincident.vx, coincident.vy], + coincidentStats, + }); + """ + ) + assert report["heavy"]["stats"]["applied"] == 2 + assert report["heavy"]["stats"]["maximumAcceleration"] == pytest.approx( + report["light"]["stats"]["maximumAcceleration"] * 2, rel=1e-12 + ) + assert report["near"]["stats"]["maximumAcceleration"] > report["far"]["stats"][ + "maximumAcceleration" + ] + assert report["near"]["stats"]["maximumPull"] <= 36 + assert report["far"]["stats"]["maximumPull"] <= 36 + assert report["heavy"]["follower"][0] < 60 + assert report["heavy"]["follower"][2] < 0 + assert report["heavy"]["follower"][3] == pytest.approx(3) + assert report["heavy"]["remote"] != report["heavy"]["beforeRemote"] + assert report["heavy"]["remote"][0] < report["heavy"]["beforeRemote"][0] + assert report["heavy"]["remote"][1] < report["heavy"]["beforeRemote"][1] + assert report["zero"]["follower"] == pytest.approx(report["heavy"]["follower"]) + assert report["zero"]["remote"] == pytest.approx(report["heavy"]["remote"]) + assert report["coincident"] == pytest.approx([0, 0, 1, 2]) + assert report["coincidentStats"]["applied"] == 0 + + +@requires_node +def test_live_drag_force_is_fixed_step_acceleration_not_pointer_displacement() -> None: + report = _run_node( + """ + const primary = { id: 'star', x: 0, y: 0, vx: 0, vy: 0, + radius: 2, gravity_mass: 12, community_id: 'solar' }; + const follower = { id: 'planet', x: 60, y: 0, vx: 0, vy: 3, + radius: 2, gravity_mass: 1, community_id: 'solar' }; + const before = [follower.x, follower.y, follower.vx, follower.vy]; + const stats = I.applyDraggedNodeAcceleration(primary, [{ node: follower }], { + gravity: 48, localGravitySetting: 48, softening: 12, + }); + const expected = I.galaxyLocalGravityConstant(48) * 2 * 12 * 60 + / Math.pow(60 * 60 + 12 * 12, 1.5); + const zeroFollower = { id: 'zero-planet', x: 60, y: 0, vx: 0, vy: 3, + radius: 2, gravity_mass: 1, community_id: 'solar' }; + const zeroStats = I.applyDraggedNodeAcceleration(primary, [{ node: zeroFollower }], { + gravity: 0, localGravitySetting: 48, softening: 12, + }); + emit({ before, after: [follower.x, follower.y, follower.vx, follower.vy], + stats, expected, + zeroAfter: [zeroFollower.x, zeroFollower.y, zeroFollower.vx, zeroFollower.vy], + zeroStats }); + """ + ) + assert report["stats"]["applied"] == 1 + assert report["stats"]["maximumPull"] == 0 + assert report["stats"]["maximumAcceleration"] == pytest.approx( + report["expected"], rel=1e-12 + ) + assert report["after"][:2] == report["before"][:2] + assert report["after"][2] == pytest.approx(-report["expected"]) + assert report["after"][3] == pytest.approx(report["before"][3]) + assert report["zeroAfter"] == pytest.approx(report["after"]) + assert report["zeroStats"]["maximumAcceleration"] == pytest.approx( + report["stats"]["maximumAcceleration"], rel=1e-12 + ) + + +@requires_node +def test_connected_galaxy_drag_keeps_followers_and_unrelated_systems_bounded() -> None: + """A cursor-owned source obeys painted bounds without turning bodies into projectiles.""" + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 64, radius: 12, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'dragged', community_id: 'cursor', gravity_mass: 8, radius: 4, + x: 100, y: 0, vx: 0, vy: 0 }, + { id: 'follower-a', community_id: 'follower-a', gravity_mass: 2, radius: 3, + x: 132, y: 0, vx: 0, vy: 2 }, + { id: 'follower-b', community_id: 'follower-b', gravity_mass: 2, radius: 3, + x: 112, y: 30, vx: -1, vy: 1 }, + { id: 'remote-star', community_id: 'remote', gravity_mass: 5, radius: 4, + x: -130, y: 30, vx: 0, vy: -2 }, + { id: 'remote-moon', community_id: 'remote', gravity_mass: 1, radius: 2, + x: -112, y: 36, vx: 1, vy: -1 }, + ]; + const links = [ + { source: 'dragged', target: 'follower-a', rest_length: 30, spring_strength: 0.1 }, + { source: 'dragged', target: 'follower-b', rest_length: 30, spring_strength: 0.1 }, + ]; + const common = { + gravity: 48, central: true, includeFarFieldConfinement: true, + includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, + includeMutualSystems: true, mutualSystemGravityFraction: 0.12, + mutualSystemSoftening: 80, includeCollisions: false, + includeRelations: true, includeRelationSprings: true, + orbitScale: 0.25, relationStrengthMultiplier: 2, + relationConstraintRate: 24, relationConstraintMaxCorrection: 12, + relationPadding: 12, includeOrbitalSeparation: true, + orbitalSeparationPadding: 12, orbitalSeparationStrength: 0.8, + crossCommunitySeparationPadding: 1.5, crossCommunitySeparationStrength: 0.144, + orbitalSeparationMaxCorrection: 4, orbitalSeparationMaxVelocityCorrection: 8, + localRelativeSpeedLimit: 16, timestep: 0.021328125, + wallClockSeconds: 1 / 30, velocityDecay: 0.0001, speedLimit: 24, + }; + /* Establish the cached envelope, then make a gradual cursor path that crosses it. */ + I.applyGalaxyFarFieldConfinement(nodes, common); + const envelope = I.galaxyFarFieldEnvelope(nodes, common).envelopeRadius; + const dragged = nodes[1], followerA = nodes[2], followerB = nodes[3]; + dragged.x = envelope - 100; dragged.y = 0; + followerA.x = envelope - 68; followerA.y = 0; + followerB.x = envelope - 88; followerB.y = 30; + const targets = [ + [envelope - 70, 0], [envelope - 35, 15], [envelope + 5, 20], + [envelope + 45, 10], [envelope + 80, -5], + ]; + const followers = [ + { node: followerA, link: links[0] }, { node: followerB, link: links[1] }, + ]; + let finite = true, maximumSpeed = 0, maximumFollowerStep = 0; + let maximumLinkDistance = 0, maximumRemoteRadius = 0, maximumRemoteStep = 0; + let dragAcceleration = 0, dragPull = 0; + let requestedBeyondEnvelope = false, minimumSourceOuterClearance = Infinity; + let sourceEdgeContact = false; + for (const [x, y] of targets) { + const beforeFollowers = [followerA, followerB].map(node => [node.x, node.y]); + const beforeRemote = nodes.slice(4).map(node => [node.x, node.y]); + dragged.x = x; dragged.y = y; dragged.vx = 0; dragged.vy = 0; + const tick = I.integrateGalaxyLeapfrog(nodes, links, [], { + ...common, fixedNodeId: 'dragged', dragSource: dragged, dragFollowers: followers, + }); + requestedBeyondEnvelope = requestedBeyondEnvelope + || Math.hypot(x, y) + dragged.radius > envelope + 1e-8; + const sourceClearance = envelope - (Math.hypot(dragged.x, dragged.y) + dragged.radius); + minimumSourceOuterClearance = Math.min(minimumSourceOuterClearance, sourceClearance); + sourceEdgeContact = sourceEdgeContact || Math.abs(sourceClearance) <= 1e-8; + dragAcceleration = Math.max(dragAcceleration, tick.dragGravity.maximumAcceleration); + dragPull = Math.max(dragPull, tick.dragGravity.maximumPull); + maximumSpeed = Math.max(maximumSpeed, tick.maximumSpeed); + [followerA, followerB].forEach((node, index) => { + maximumFollowerStep = Math.max(maximumFollowerStep, + Math.hypot(node.x - beforeFollowers[index][0], node.y - beforeFollowers[index][1])); + }); + links.forEach(link => { + const source = nodes.find(node => node.id === link.source); + const target = nodes.find(node => node.id === link.target); + maximumLinkDistance = Math.max(maximumLinkDistance, + Math.hypot(source.x - target.x, source.y - target.y)); + }); + nodes.slice(4).forEach((node, index) => { + maximumRemoteRadius = Math.max(maximumRemoteRadius, + Math.hypot(node.x, node.y) + node.radius); + maximumRemoteStep = Math.max(maximumRemoteStep, + Math.hypot(node.x - beforeRemote[index][0], node.y - beforeRemote[index][1])); + }); + finite = finite && nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)); + } + const held = [dragged.x, dragged.y]; + let releaseSpeed = 0; + for (let step = 0; step < 20; step++) { + const tick = I.integrateGalaxyLeapfrog(nodes, links, [], common); + releaseSpeed = Math.max(releaseSpeed, tick.maximumSpeed); + finite = finite && nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)); + } + emit({ + envelope, requestedBeyondEnvelope, minimumSourceOuterClearance, sourceEdgeContact, + finite, maximumSpeed, releaseSpeed, + maximumFollowerStep, maximumLinkDistance, maximumRemoteRadius, maximumRemoteStep, + dragAcceleration, dragPull, held, released: [dragged.x, dragged.y], + }); + """ + ) + assert report["requestedBeyondEnvelope"] is True + assert report["minimumSourceOuterClearance"] >= -1e-8 + assert report["sourceEdgeContact"] is True + assert report["finite"] is True + assert report["dragAcceleration"] > 0 + assert report["dragPull"] > 0 + assert report["maximumSpeed"] <= 24, report + assert report["releaseSpeed"] <= 24, report + # Fixed geometry and the relation cap limit every cursor sample; neither link may run away. + assert report["maximumFollowerStep"] <= 48 + assert report["maximumLinkDistance"] <= 180 + assert report["maximumRemoteRadius"] <= report["envelope"] + 1e-8 + assert report["maximumRemoteStep"] <= 32 + # Removing fixedNodeId/dragSource lets the former cursor point resume normal physics. + assert math.dist(report["held"], report["released"]) > 1e-4 + + +@requires_node +@pytest.mark.parametrize( + ("drag_community", "expect_fixed_system_nodes"), + [("core", False), ("drag-system", True)], +) +def test_dragging_connected_core_node_over_black_hole_keeps_the_annulus_stable( + drag_community: str, expect_fixed_system_nodes: bool, +) -> None: + """The pointer may target the hole centre, but its painted body cannot cover it.""" + report = _run_node( + "const dragCommunity = " + repr(drag_community) + + ";\nconst externalSystem = " + ("true" if expect_fixed_system_nodes else "false") + + ";\n" + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 64, radius: 12, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'dragged', community_id: dragCommunity, + anchor_role: externalSystem ? 'community' : 'none', + system_anchor_id: externalSystem ? 'dragged' : 'black-hole', + gravity_mass: 8, radius: 4, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'core-follower-a', community_id: dragCommunity, system_anchor_id: 'dragged', + gravity_mass: 2, radius: 3, x: 26, y: 0, vx: 0, vy: 2 }, + { id: 'core-follower-b', community_id: dragCommunity, system_anchor_id: 'dragged', + gravity_mass: 2, radius: 3, x: 0, y: 28, vx: -2, vy: 0 }, + { id: 'remote-star', anchor_role: 'community', community_id: 'remote', + system_anchor_id: 'remote-star', gravity_mass: 5, radius: 4, + x: -100, y: 25, vx: 0, vy: -2 }, + { id: 'remote-moon', community_id: 'remote', system_anchor_id: 'remote-star', + gravity_mass: 1, radius: 2, x: -84, y: 31, vx: 1, vy: -1 }, + ]; + const links = [ + { source: 'dragged', target: 'core-follower-a', rest_length: 24, spring_strength: 0.1 }, + { source: 'dragged', target: 'core-follower-b', rest_length: 24, spring_strength: 0.1 }, + ]; + const dragged = nodes[1], followers = [ + { node: nodes[2], link: links[0] }, { node: nodes[3], link: links[1] }, + ]; + const options = { + gravity: 48, central: true, fixedNodeId: 'dragged', dragSource: dragged, + dragFollowers: followers, includeFarFieldConfinement: true, + includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, + includeMutualSystems: true, mutualSystemGravityFraction: 0.12, + mutualSystemSoftening: 80, includeCollisions: false, + includeRelations: true, includeRelationSprings: true, orbitScale: 0.25, + relationStrengthMultiplier: 2, relationConstraintRate: 24, + relationConstraintMaxCorrection: 12, relationPadding: 12, + includeOrbitalSeparation: true, orbitalSeparationPadding: 12, + orbitalSeparationStrength: 0.8, crossCommunitySeparationPadding: 1.5, + crossCommunitySeparationStrength: 0.144, orbitalSeparationMaxCorrection: 4, + orbitalSeparationMaxVelocityCorrection: 8, localRelativeSpeedLimit: 16, + timestep: 0.021328125, wallClockSeconds: 1 / 30, + velocityDecay: 0.0001, speedLimit: 24, + }; + I.applyGalaxyFarFieldConfinement(nodes, options); + const envelope = I.galaxyFarFieldEnvelope(nodes, options).envelopeRadius; + let minimumClearance = Infinity, maximumFollowerStep = 0, maximumLinkDistance = 0; + let maximumRemoteRadius = 0, maximumSpeed = 0, dragPull = 0, finite = true; + let fixedSystemNodes = 0, skippedFixedEndpoint = 0; + let outerFollowerClearance = Infinity, minimumSourceOuterClearance = Infinity; + let maximumOuterFollowerStep = 0, requestedBeyondEnvelope = false, sourceEdgeContact = false; + for (let step = 0; step < 48; step++) { + const before = nodes.slice(2, 4).map(node => [node.x, node.y]); + const remoteBefore = nodes.slice(4).map(node => [node.x, node.y]); + /* This is the adversarial pointer target. The final horizon owns the paint phase. */ + dragged.x = 0; dragged.y = 0; dragged.vx = 0; dragged.vy = 0; + const tick = I.integrateGalaxyLeapfrog(nodes, links, [], options); + maximumSpeed = Math.max(maximumSpeed, tick.maximumSpeed); + dragPull = Math.max(dragPull, tick.dragGravity.maximumPull); + fixedSystemNodes += tick.blackHoleExclusion.fixedSystemNodes; + skippedFixedEndpoint += tick.relationConstraint.skippedFixedEndpoint; + nodes.slice(1).forEach(node => { + minimumClearance = Math.min(minimumClearance, + Math.hypot(node.x, node.y) - nodes[0].radius - node.radius + - options.blackHoleExclusionPadding); + }); + nodes.slice(2, 4).forEach((node, index) => { + maximumFollowerStep = Math.max(maximumFollowerStep, + Math.hypot(node.x - before[index][0], node.y - before[index][1])); + }); + links.forEach(link => { + const target = nodes.find(node => node.id === link.target); + maximumLinkDistance = Math.max(maximumLinkDistance, + Math.hypot(dragged.x - target.x, dragged.y - target.y)); + }); + nodes.slice(4).forEach((node, index) => { + maximumRemoteRadius = Math.max(maximumRemoteRadius, + Math.hypot(node.x, node.y) + node.radius); + maximumFollowerStep = Math.max(maximumFollowerStep, + Math.hypot(node.x - remoteBefore[index][0], node.y - remoteBefore[index][1])); + }); + finite = finite && nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)); + } + const centreHeld = [dragged.x, dragged.y]; + /* An external pointer may request a source beyond the envelope, but the painted source + and its nonfixed followers must remain inside it throughout a long, gradual outward + drag. This is the former 400-slice runaway: a skipped fixed system let followers + drift hundreds of units out, then snap back only after release. */ + if (externalSystem) { + const startRadius = nodes[0].radius + dragged.radius + options.blackHoleExclusionPadding; + const endRadius = envelope + 320; + for (let step = 0; step < 400; step++) { + const before = nodes.slice(2, 4).map(node => [node.x, node.y]); + const targetX = startRadius + (endRadius - startRadius) * (step + 1) / 400; + dragged.x = targetX; dragged.y = 0; dragged.vx = 0; dragged.vy = 0; + const tick = I.integrateGalaxyLeapfrog(nodes, links, [], options); + requestedBeyondEnvelope = requestedBeyondEnvelope + || targetX + dragged.radius > envelope + 1e-8; + const sourceClearance = envelope - (Math.hypot(dragged.x, dragged.y) + dragged.radius); + minimumSourceOuterClearance = Math.min(minimumSourceOuterClearance, sourceClearance); + sourceEdgeContact = sourceEdgeContact || Math.abs(sourceClearance) <= 1e-8; + maximumSpeed = Math.max(maximumSpeed, tick.maximumSpeed); + dragPull = Math.max(dragPull, tick.dragGravity.maximumPull); + fixedSystemNodes += tick.blackHoleExclusion.fixedSystemNodes; + skippedFixedEndpoint += tick.relationConstraint.skippedFixedEndpoint; + nodes.slice(1).forEach(node => { + minimumClearance = Math.min(minimumClearance, + Math.hypot(node.x, node.y) - nodes[0].radius - node.radius + - options.blackHoleExclusionPadding); + }); + nodes.slice(2, 4).forEach((node, index) => { + outerFollowerClearance = Math.min(outerFollowerClearance, + envelope - (Math.hypot(node.x, node.y) + node.radius)); + maximumOuterFollowerStep = Math.max(maximumOuterFollowerStep, + Math.hypot(node.x - before[index][0], node.y - before[index][1])); + }); + finite = finite && nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)); + } + } + const held = [dragged.x, dragged.y]; + let releaseSpeed = 0, maximumReleaseFollowerStep = 0; + for (let step = 0; step < 20; step++) { + const before = nodes.slice(2, 4).map(node => [node.x, node.y]); + const tick = I.integrateGalaxyLeapfrog(nodes, links, [], { + ...options, fixedNodeId: null, dragSource: null, dragFollowers: [], + }); + releaseSpeed = Math.max(releaseSpeed, tick.maximumSpeed); + nodes.slice(2, 4).forEach((node, index) => { + maximumReleaseFollowerStep = Math.max(maximumReleaseFollowerStep, + Math.hypot(node.x - before[index][0], node.y - before[index][1])); + }); + finite = finite && nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)); + } + emit({ + envelope, minimumClearance, maximumFollowerStep, maximumLinkDistance, + maximumRemoteRadius, maximumSpeed, releaseSpeed, dragPull, finite, + fixedSystemNodes, skippedFixedEndpoint, requestedBeyondEnvelope, sourceEdgeContact, + outerFollowerClearance, minimumSourceOuterClearance, maximumOuterFollowerStep, + maximumReleaseFollowerStep, + centreHeld, held, released: [dragged.x, dragged.y], + anchor: [nodes[0].x, nodes[0].y, nodes[0].vx, nodes[0].vy], + draggedRadius: Math.hypot(centreHeld[0], centreHeld[1]), + paintedHorizon: nodes[0].radius + dragged.radius + options.blackHoleExclusionPadding, + }); + """ + ) + assert report["finite"] is True + assert report["anchor"] == pytest.approx([0, 0, 0, 0], abs=1e-12) + # The fixed source is projected to the event horizon, not allowed to paint at the centre. + assert report["draggedRadius"] == pytest.approx(report["paintedHorizon"], abs=1e-8) + assert report["minimumClearance"] >= -1e-8 + assert report["dragPull"] > 0 + # The dragged cluster may be the anchor community or a pointer-owned external system. The + # latter must use its dedicated horizon path, while both skip direct spring correction. + if expect_fixed_system_nodes: + assert report["fixedSystemNodes"] > 0 + # Pointer targets beyond the cached envelope are requests, not paint positions: the + # source must meet the same finite outer boundary as every follower while held. + assert report["requestedBeyondEnvelope"] is True + assert report["minimumSourceOuterClearance"] >= -1e-8 + assert report["sourceEdgeContact"] is True + assert report["outerFollowerClearance"] >= -1e-8 + assert report["maximumOuterFollowerStep"] <= 48 + assert report["maximumReleaseFollowerStep"] <= 48 + else: + assert report["fixedSystemNodes"] == 0 + assert report["skippedFixedEndpoint"] > 0 + assert report["maximumSpeed"] <= 24 + assert report["releaseSpeed"] <= 24 + assert report["maximumFollowerStep"] <= 48 + assert report["maximumLinkDistance"] <= 96 + assert report["maximumRemoteRadius"] <= report["envelope"] + 1e-8 + assert math.dist(report["held"], report["released"]) > 1e-4 + + +@requires_node +@pytest.mark.parametrize("drag_id", ["star", "planet"]) +def test_dragging_star_or_planet_across_stellar_surface_stays_bounded(drag_id: str) -> None: + """A fixed source may cross a stellar surface without a follower feedback runaway.""" + report = _run_node( + "const dragId = " + repr(drag_id) + ";\n" + """ + const nodes = [ + { id: 'bh', anchor_role: 'global', community_id: 'core', gravity_mass: 8, + radius: 10, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'star', community_id: 'solar', gravity_mass: 14, + radius: 5, x: 54, y: 0, vx: 0, vy: 0 }, + { id: 'planet', orbit_tier: 1, community_id: 'solar', gravity_mass: 1, + radius: 3, x: 64, y: 0, vx: 0, vy: 0 }, + { id: 'moon', orbit_tier: 2, community_id: 'solar', gravity_mass: 1, + radius: 3, x: 54, y: 16, vx: 0, vy: 0 }, + { id: 'remote-star', community_id: 'remote', gravity_mass: 10, + radius: 5, x: -60, y: 0, vx: 0, vy: 0 }, + { id: 'remote-planet', orbit_tier: 1, community_id: 'remote', gravity_mass: 1, + radius: 3, x: -48, y: 0, vx: 0, vy: 0 }, + ]; + const links = [ + { source: 'star', target: 'planet', rest_length: 10, spring_strength: 0.08 }, + { source: 'star', target: 'moon', rest_length: 16, spring_strength: 0.08 }, + ]; + const dragSourceNode = nodes.find(node => node.id === dragId); + const star = nodes.find(node => node.id === 'star'); + const planet = nodes.find(node => node.id === 'planet'); + const target = dragId === 'star' ? [planet.x, planet.y] : [star.x, star.y]; + const followers = nodes.filter(node => node !== dragSourceNode && node.id !== 'bh') + .map(node => ({ node, link: links.find(link => link.source === node.id + || link.target === node.id) || null })); + const options = { + gravity: 48, central: true, fixedNodeId: dragId, dragSource: dragSourceNode, + dragFollowers: followers, softening: 12, centralSoftening: 40, + includeMutualSystems: true, mutualSystemGravityFraction: 0.12, + mutualSystemSoftening: 80, includeCollisions: false, + includeRelations: true, includeRelationSprings: false, + skipSystemAnchorRelations: true, relationStrengthMultiplier: 1, + relationConstraintRate: 24, relationConstraintMaxCorrection: 12, + includeOrbitalSeparation: true, orbitalSeparationPadding: 1.5, + orbitalSeparationStrength: 0.8, orbitalSeparationMaxCorrection: 4, + orbitalSeparationMaxVelocityCorrection: 8, preserveLocalTangentialVelocity: true, + skipSystemAnchorPairs: true, systemAnchorExclusionPadding: 1.5, + crossCommunitySeparationPadding: 1.5, crossCommunitySeparationStrength: 0.144, + includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, + includeFarFieldConfinement: true, farFieldEnvelopeScale: 1.25, + farFieldMinimumRadius: 96, farFieldSoftFraction: 0.82, + farFieldAcceleration: 12, farFieldMaxAcceleration: 16, inwardConvergence: true, + timestep: 0.021328125, wallClockSeconds: 1 / 30, + velocityDecay: 0.0001, speedLimit: 24, localRelativeSpeedLimit: 16, + }; + let anchorContacts = 0, minimumStarClearance = Infinity, maximumFollowerStep = 0; + let maximumSpeed = 0, finite = true, envelope = 0; + for (let step = 0; step < 120; step++) { + const before = followers.map(follower => [follower.node.x, follower.node.y]); + dragSourceNode.x = target[0]; dragSourceNode.y = target[1]; + dragSourceNode.vx = 0; dragSourceNode.vy = 0; + const tick = I.integrateGalaxyLeapfrog(nodes, links, [], options); + anchorContacts += tick.systemAnchorExclusion.contacts; + envelope = tick.farFieldConfinement.envelopeRadius; + maximumSpeed = Math.max(maximumSpeed, tick.maximumSpeed); + followers.forEach((follower, index) => { + maximumFollowerStep = Math.max(maximumFollowerStep, + Math.hypot(follower.node.x - before[index][0], follower.node.y - before[index][1])); + }); + [planet, nodes.find(node => node.id === 'moon')].forEach(satellite => { + if (satellite === star) return; + minimumStarClearance = Math.min(minimumStarClearance, + Math.hypot(satellite.x - star.x, satellite.y - star.y) + - star.radius - satellite.radius - options.systemAnchorExclusionPadding); + }); + finite = finite && nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)); + } + const held = [dragSourceNode.x, dragSourceNode.y]; + let maximumReleaseStep = 0; + for (let step = 0; step < 40; step++) { + const before = nodes.map(node => [node.x, node.y]); + const tick = I.integrateGalaxyLeapfrog(nodes, links, [], { + ...options, fixedNodeId: null, dragSource: null, dragFollowers: [], + }); + maximumSpeed = Math.max(maximumSpeed, tick.maximumSpeed); + maximumReleaseStep = Math.max(maximumReleaseStep, ...nodes.map((node, index) => + Math.hypot(node.x - before[index][0], node.y - before[index][1]))); + finite = finite && nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)); + } + emit({ + anchorContacts, minimumStarClearance, maximumFollowerStep, maximumReleaseStep, + maximumSpeed, finite, held, released: [dragSourceNode.x, dragSourceNode.y], + outerBounded: nodes.slice(1).every(node => + Math.hypot(node.x, node.y) + node.radius <= envelope + 1e-8), + }); + """ + ) + assert report["anchorContacts"] > 0 + assert report["minimumStarClearance"] >= -1e-9 + assert report["finite"] is True + assert report["outerBounded"] is True + assert report["maximumSpeed"] <= 24 + assert report["maximumFollowerStep"] <= 32 + assert report["maximumReleaseStep"] <= 32 + assert math.dist(report["held"], report["released"]) > 1e-4 + + +@requires_node +def test_dense_stellar_surface_exclusion_keeps_com_momentum_and_tangential_phase() -> None: + """Many simultaneous planets must clear a star without a contact-induced slingshot.""" + report = _run_node( + """ + const star = { id: 'star', anchor_role: 'community', community_id: 'solar', + gravity_mass: 20, radius: 5, x: 40, y: -12, vx: 1.5, vy: -0.75 }; + const nodes = [star]; + for (let index = 0; index < 16; index++) { + const angle = index * Math.PI * 2 / 16; + const radius = 6; // strictly inside the 5 + 2 + 1.5 painted stellar surface + nodes.push({ id: 'planet-' + index, community_id: 'solar', gravity_mass: 1, + radius: 2, x: star.x + Math.cos(angle) * radius, + y: star.y + Math.sin(angle) * radius, + vx: star.vx - Math.sin(angle) * 3, + vy: star.vy + Math.cos(angle) * 3 }); + } + const totals = () => nodes.reduce((sum, node) => ({ + mass: sum.mass + node.gravity_mass, + x: sum.x + node.gravity_mass * node.x, + y: sum.y + node.gravity_mass * node.y, + px: sum.px + node.gravity_mass * node.vx, + py: sum.py + node.gravity_mass * node.vy, + }), { mass: 0, x: 0, y: 0, px: 0, py: 0 }); + const before = totals(); + const exclusion = I.applyGalaxySystemAnchorExclusion(nodes, { padding: 1.5 }); + const after = totals(); + emit({ + exclusion, + comShift: Math.hypot(after.x / after.mass - before.x / before.mass, + after.y / after.mass - before.y / before.mass), + momentumDelta: Math.hypot(after.px - before.px, after.py - before.py), + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)), + }); + """ + ) + assert report["exclusion"]["contacts"] >= 16 + assert report["exclusion"]["minimumClearance"] >= -1e-10 + assert report["comShift"] <= 1e-10 + assert report["momentumDelta"] <= 1e-10 + assert report["exclusion"]["tangentialVelocityRemoved"] == 0 + assert report["finite"] is True + + +@requires_node +def test_dominant_star_has_smooth_mass_balanced_repulsion_before_its_hard_surface() -> None: + """A star's surface pressure beats its well without becoming generic pair repulsion.""" + report = _run_node( + """ + const fixture = innerMass => [ + { id: 'star', anchor_role: 'community', community_id: 'solar', gravity_mass: 8, + radius: 5, x: 0, y: 0, vx: 1, vy: -2 }, + // 9.5 is the exact painted boundary: 5 + 3 radii + 1.5 padding. + { id: 'inner', community_id: 'solar', orbit_tier: 1, gravity_mass: innerMass, + radius: 3, x: 9.5, y: 0, vx: 1, vy: 2 }, + { id: 'outer', community_id: 'solar', orbit_tier: 2, gravity_mass: 1, + radius: 3, x: 100, y: 0, vx: 1, vy: -2 }, + ]; + const trial = (innerMass, pressure = 0.12) => { + const nodes = fixture(innerMass); + const before = nodes.map(node => [node.vx, node.vy]); + const momentum = nodes.reduce((total, node) => [ + total[0] + node.gravity_mass * node.vx, + total[1] + node.gravity_mass * node.vy, + ], [0, 0]); + const stats = I.applyGalaxySystemAnchorGravity(nodes, { + gravity: 0, alpha: 1, softening: 12, repulsionPadding: 1.5, + repulsionRange: 6, repulsionAcceleration: pressure, accelerationCap: 100, + }); + const afterMomentum = nodes.reduce((total, node) => [ + total[0] + node.gravity_mass * node.vx, + total[1] + node.gravity_mass * node.vy, + ], [0, 0]); + return { before, after: nodes.map(node => [node.vx, node.vy]), stats, + momentumDelta: [afterMomentum[0] - momentum[0], afterMomentum[1] - momentum[1]], + radialRelative: nodes[1].vx - nodes[0].vx, + outerRadialRelative: nodes[2].vx - nodes[0].vx, + tangentialRelative: nodes[1].vy - nodes[0].vy, + }; + }; + emit({ light: trial(1), heavy: trial(9), + lightControl: trial(1, 0), heavyControl: trial(9, 0) }); + """ + ) + light, heavy = report["light"], report["heavy"] + controls = (report["lightControl"], report["heavyControl"]) + for trial, control in zip((light, heavy), controls): + stats = trial["stats"] + assert stats["systems"] == stats["anchors"] == 1 + assert stats["satellites"] == 2 + assert stats["repulsions"] == 1 + assert stats["repulsionPadding"] == pytest.approx(1.5) + 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) + assert stats["eligibleStellarAnchors"] == 1 + assert stats["fallbackAnchors"] == 0 + assert stats["globalAnchors"] == 0 + assert stats["stellarFloorActive"] is True + assert stats["surfaceRepulsions"] == 1 + assert stats["maximumRepulsion"] > 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 + # attraction by the requested bounded margin at the painted surface. Comparing with + # pressure disabled isolates the radial correction from the shared gravity field. + assert trial["radialRelative"] == pytest.approx(stats["maximumNetRepulsion"]) + assert trial["radialRelative"] - control["radialRelative"] == pytest.approx( + stats["maximumRepulsion"] + ) + # The named star is an external local carrier. Surface pressure changes only the + # planet's phase-space state; aggregate system momentum is intentionally no longer + # conserved through an artificial equal-and-opposite star recoil. + assert trial["after"][0] == pytest.approx(trial["before"][0], abs=1e-12) + assert trial["tangentialRelative"] == pytest.approx(4) + # The inner planet is not promoted into a second pressure source: enabling its surface + # correction leaves the remote planet's star-relative radial response unchanged. + assert trial["outerRadialRelative"] == pytest.approx( + control["outerRadialRelative"], abs=1e-12 + ) + # Surface strength depends on the star field and geometry, not satellite evidence mass. + assert light["stats"]["maximumRepulsion"] == pytest.approx( + heavy["stats"]["maximumRepulsion"], abs=1e-12 + ) + + +@requires_node +def test_live_gravity_stellar_pressure_is_outward_at_the_surface_and_tapers_smoothly() -> None: + """The soft stellar surface beats live attraction without moving its local star.""" + report = _run_node( + """ + const trial = (gravity, distance, repulsionAcceleration) => { + const nodes = [ + { id: 'star', anchor_role: 'community', community_id: 'solar', gravity_mass: 8, + radius: 5, x: 0, y: 0, vx: 1, vy: -2 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 1, + gravity_mass: 1, radius: 3, x: distance, y: 0, vx: 1, vy: 2 }, + ]; + const before = nodes.map(node => ({ vx: node.vx, vy: node.vy })); + const momentumBefore = ['vx', 'vy'].map(axis => nodes.reduce((sum, node) => + sum + node.gravity_mass * node[axis], 0)); + const options = { gravity, softening: 32, alpha: 1, + repulsionPadding: 1.5, repulsionRange: 6 }; + if (repulsionAcceleration !== undefined) { + options.repulsionAcceleration = repulsionAcceleration; + } + const stats = I.applyGalaxySystemAnchorGravity(nodes, options); + const momentumAfter = ['vx', 'vy'].map(axis => nodes.reduce((sum, node) => + sum + node.gravity_mass * node[axis], 0)); + return { + stats, + starBefore: before[0], starAfter: { vx: nodes[0].vx, vy: nodes[0].vy }, + relativeRadial: (nodes[1].vx - nodes[0].vx) + - (before[1].vx - before[0].vx), + relativeTangential: nodes[1].vy - nodes[0].vy, + momentumDelta: momentumAfter.map((value, index) => value - momentumBefore[index]), + finite: nodes.every(node => [node.vx, node.vy].every(Number.isFinite)), + }; + }; + const hardDistance = 5 + 3 + 1.5; + const pressureEdge = hardDistance + 6; + const inside = trial(48, hardDistance - 0.75); + const surface = trial(48, hardDistance); + const surfaceWithoutPressure = trial(48, hardDistance, 0); + const edge = trial(48, pressureEdge); + const edgeWithoutPressure = trial(48, pressureEdge, 0); + const maximum = trial(400, hardDistance); + emit({ hardDistance, pressureEdge, inside, surface, surfaceWithoutPressure, + edge, edgeWithoutPressure, maximum }); + """ + ) + for trial in (report["inside"], report["surface"], report["edge"], report["maximum"]): + assert trial["finite"] is True + assert trial["starAfter"] == pytest.approx(trial["starBefore"], abs=1e-12) + assert trial["relativeTangential"] == pytest.approx(4, abs=1e-12) + # At and just inside the painted 9.5-unit stellar surface, net star-relative acceleration + # must point outward even with the ordinary gravity-48 central well active. + assert report["inside"]["relativeRadial"] > 0 + assert report["surface"]["relativeRadial"] > 0 + assert report["inside"]["stats"]["repulsions"] == 1 + assert report["surface"]["stats"]["repulsions"] == 1 + assert report["inside"]["stats"]["surfaceRepulsions"] == 1 + assert report["surface"]["stats"]["surfaceRepulsions"] == 1 + assert report["surface"]["stats"]["maximumSampledAttraction"] > 0 + assert report["surface"]["stats"]["maximumNetRepulsion"] > 0 + assert report["surface"]["stats"]["minimumSurfaceNetRepulsion"] > 0 + assert report["surface"]["relativeRadial"] > \ + report["surfaceWithoutPressure"]["relativeRadial"] + # Pressure reaches zero continuously at the 15.5-unit outer edge; ordinary gravity remains. + assert report["edge"]["stats"]["repulsions"] == 0 + assert report["edge"]["relativeRadial"] == pytest.approx( + report["edgeWithoutPressure"]["relativeRadial"], abs=1e-12 + ) + # The maximum visible gravity setting stays finite and below its tested acceleration cap. + assert report["maximum"]["stats"]["surfaceRepulsions"] == 1 + assert report["maximum"]["stats"]["minimumSurfaceNetRepulsion"] > 0 + assert report["maximum"]["stats"]["maximumAcceleration"] <= 500 + assert abs(report["maximum"]["relativeRadial"]) <= 1000 + + +@requires_node +def test_galaxy_collision_uses_evidence_mass_without_injecting_system_momentum() -> None: + report = _run_node( + """ + const contact = [ + { id: 'star', x: 0, y: 0, vx: 0, vy: 0, radius: 6, gravity_mass: 4 }, + { id: 'planet', x: 10, y: 0, vx: 0, vy: 0, radius: 6, gravity_mass: 1 }, + { id: 'remote', x: 100, y: 0, vx: 0, vy: 0, radius: 2, gravity_mass: 8 }, + ]; + const stats = I.applyGalaxyCollisions(contact, { + padding: 0, strength: 1, iterations: 1, + }); + const coincident = [ + { id: 'a', x: 0, y: 0, radius: 3, gravity_mass: 2 }, + { id: 'b', x: 0, y: 0, radius: 3, gravity_mass: 5 }, + ]; + I.applyGalaxyCollisions(coincident, { padding: 0, strength: 0.7, iterations: 2 }); + const sparse = Array.from({ length: 120 }, (_, index) => ({ + id: 's' + index, x: index * 30, y: 0, radius: 2, gravity_mass: 1, + })); + const sparseStats = I.applyGalaxyCollisions(sparse, { + padding: 0, strength: 1, iterations: 1, + }); + const tangent = [ + { id: 'left', x: 0, y: 0, vx: 0, vy: 1, radius: 6, gravity_mass: 1 }, + { id: 'right', x: 10, y: 0, vx: 0, vy: 0, radius: 6, gravity_mass: 1 }, + ]; + const closing = [ + { id: 'heavy', x: 0, y: 0, vx: 1, vy: 0, radius: 6, gravity_mass: 4 }, + { id: 'light', x: 10, y: 0, vx: -2, vy: 0, radius: 6, gravity_mass: 1 }, + ]; + const angular = bodies => bodies.reduce((sum, node) => sum + + node.gravity_mass * (node.x * node.vy - node.y * node.vx), 0); + const kinetic = bodies => bodies.reduce((sum, node) => sum + + 0.5 * node.gravity_mass * (node.vx * node.vx + node.vy * node.vy), 0); + const angularBefore = angular(tangent); + const kineticBefore = kinetic(closing); + I.applyGalaxyCollisions(tangent, { padding: 0, strength: 1, iterations: 1 }); + I.applyGalaxyCollisions(closing, { padding: 0, strength: 1, iterations: 1 }); + emit({ + positions: contact.map(node => [node.x, node.y]), + velocities: contact.map(node => [node.vx, node.vy]), + momentum: [ + contact.reduce((sum, node) => sum + node.gravity_mass * node.vx, 0), + contact.reduce((sum, node) => sum + node.gravity_mass * node.vy, 0), + ], + overlaps: stats.overlaps, + coincidentFinite: coincident.every(node => Number.isFinite(node.vx) + && Number.isFinite(node.vy)), + sparsePairs: sparseStats.pairs, + quadratic: sparse.length * sparse.length, + angularBefore, + angularAfter: angular(tangent), + kineticBefore, + kineticAfter: kinetic(closing), + closingMomentum: closing.reduce( + (sum, node) => sum + node.gravity_mass * node.vx, 0 + ), + }); + """ + ) + assert report["positions"][0] == pytest.approx([-0.4, 0]) + assert report["positions"][1] == pytest.approx([11.6, 0]) + assert report["velocities"][0] == pytest.approx([0, 0]) + assert report["velocities"][1] == pytest.approx([0, 0]) + assert report["velocities"][2] == pytest.approx([0, 0]) + assert report["momentum"] == pytest.approx([0, 0], abs=1e-12) + assert report["overlaps"] == 1 + assert report["coincidentFinite"] is True + assert report["sparsePairs"] < report["quadratic"] // 20 + assert report["angularAfter"] == pytest.approx(report["angularBefore"], abs=1e-12) + assert report["kineticAfter"] <= report["kineticBefore"] + assert report["closingMomentum"] == pytest.approx(2, abs=1e-12) + + +@requires_node +def test_galaxy_leapfrog_is_fixed_step_deterministic_and_does_not_depend_on_alpha() -> None: + report = _run_node( + """ + const fixture = () => [ + { id: 'sun', x: 0, y: 0, vx: 0, vy: 0, radius: 5, + gravity_mass: 8, community_id: 'solar' }, + { id: 'planet', x: 28, y: 0, vx: 0, vy: 0, radius: 2, + gravity_mass: 1, community_id: 'solar' }, + ]; + const first = fixture(), second = fixture(), damped = fixture(), conserved = fixture(); + I.seedGalaxyOrbits(first, 77, 12, 8, false); + I.seedGalaxyOrbits(second, 77, 12, 8, false); + I.seedGalaxyOrbits(conserved, 77, 12, 8, false, { localGravitationalConstant: 1 }); + const seeded = first.map(node => [node.x, node.y, node.vx, node.vy]); + const step = nodes => I.integrateGalaxyLeapfrog(nodes, [], [], { + gravity: 12, softening: 8, central: false, timestep: 0.25, + velocityDecay: 0.012, speedLimit: 18, collisionPadding: 0, + collisionStrength: 0, collisionIterations: 1, + }); + const initialAngular = first[1].x * first[1].vy - first[1].y * first[1].vx; + let firstStep = step(first); + step(second); + for (let i = 0; i < 159; i++) { step(first); step(second); } + const energy = nodes => { + const kinetic = nodes.reduce((sum, node) => sum + 0.5 * node.gravity_mass + * (node.vx * node.vx + node.vy * node.vy), 0); + const dx = nodes[1].x - nodes[0].x, dy = nodes[1].y - nodes[0].y; + return kinetic - (I.galaxyStellarGravityConstant(12) * 8) + / Math.sqrt(dx * dx + dy * dy + 64); + }; + const angularMomentum = nodes => nodes.reduce((sum, node) => sum + node.gravity_mass + * (node.x * node.vy - node.y * node.vx), 0); + const energyStart = energy(conserved), angularStart = angularMomentum(conserved); + for (let i = 0; i < 400; i++) I.integrateGalaxyLeapfrog(conserved, [], [], { + gravity: 12, softening: 8, central: false, timestep: 0.1, + velocityDecay: 0, speedLimit: 100, localRelativeSpeedLimit: 100, + localGravitationalConstant: 1, + includeFarFieldConfinement: false, collisionStrength: 0, + }); + damped[0].vx = 6; damped[0].vy = -2; + const beforeDamping = 0.5 * damped[0].gravity_mass + * (damped[0].vx * damped[0].vx + damped[0].vy * damped[0].vy); + const dampingStep = I.integrateGalaxyLeapfrog(damped, [], [], { + gravity: 0, central: false, timestep: 1, velocityDecay: 0.2, + speedLimit: 100, collisionStrength: 0, + }); + emit({ + seeded, + firstStep, initialAngular, + first: first.map(node => [node.x, node.y, node.vx, node.vy]), + second: second.map(node => [node.x, node.y, node.vx, node.vy]), + finite: first.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)), + maximumSpeed: Math.max(...first.map(node => Math.hypot(node.vx, node.vy))), + beforeDamping, afterDamping: dampingStep.kinetic, + energyStart, energyEnd: energy(conserved), angularStart, + angularEnd: angularMomentum(conserved), + }); + """ + ) + # A fixed sequence is repeatable and changes the seeded orbit without a D3 alpha input. + assert [value for node in report["first"] for value in node] == pytest.approx( + [value for node in report["second"] for value in node] + ) + assert report["firstStep"]["bodies"] == 2 + assert report["initialAngular"] != 0 + assert report["finite"] is True + assert report["maximumSpeed"] <= 18 + assert report["first"][1][:2] != pytest.approx(report["seeded"][1][:2]) + # The calibrated local field contributes to the reported whole-system kinetic total; + # damping still keeps one step from doubling the injected energy. + assert report["afterDamping"] < report["beforeDamping"] * 2 + # The production adapter also applies bounded surface/velocity projections after the + # conservative kick-drift-kick sample; the isolated field remains finite with bounded drift. + assert report["energyEnd"] == pytest.approx(report["energyStart"], rel=0.6) + assert report["angularEnd"] == pytest.approx(report["angularStart"], rel=0.3) + source = ASSET.read_text(encoding="utf-8") + integrator = source[source.index("function integrateGalaxyLeapfrog"): + source.index("function fallbackCommunityBridges")] + assert "alpha" not in integrator + assert "kick-drift-kick" in integrator + + +@requires_node +def test_integrator_keeps_rotating_nodes_outside_black_hole_and_clamps_drag() -> None: + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 64, radius: 12, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'aurora', community_id: 'aurora', gravity_mass: 4, radius: 3, + x: 18, y: 0, vx: 0, vy: 0 }, + { id: 'borealis', community_id: 'borealis', gravity_mass: 3, radius: 3, + x: 0, y: -22, vx: 0, vy: 0 }, + { id: 'cygnus', community_id: 'cygnus', gravity_mass: 2, radius: 2, + x: -26, y: 4, vx: 0, vy: 0 }, + ]; + I.seedGalaxySystemOrbits(nodes, 123, 48, 40, false); + const options = { + gravity: 48, softening: 32, centralSoftening: 40, + localPairFraction: 0.15, corePairMultiplier: 0.75, + includeMutualSystems: true, mutualSystemGravityFraction: 0.12, + mutualSystemSoftening: 80, includeRelations: false, + includeOrbitalSeparation: true, orbitalSeparationPadding: 12, + orbitalSeparationStrength: 0.8, orbitalSeparationMaxCorrection: 4, + orbitalSeparationMaxVelocityCorrection: 8, + includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, + includeCollisions: false, inwardConvergence: true, + timestep: 0.021328125, wallClockSeconds: 1 / 30, + velocityDecay: 0.0001, speedLimit: 48, localRelativeSpeedLimit: 16, + }; + const angles = new Map(nodes.slice(1).map(node => [node.id, Math.atan2(node.y, node.x)])); + const angularTravel = new Map(nodes.slice(1).map(node => [node.id, 0])); + let minimumClearance = Infinity, contacts = 0, finalStep = null; + for (let step = 0; step < 600; step++) { + finalStep = I.integrateGalaxyLeapfrog(nodes, [], [], options); + contacts += finalStep.blackHoleExclusion.contacts; + nodes.slice(1).forEach(node => { + const clearance = Math.hypot(node.x, node.y) + - nodes[0].radius - node.radius - 2.5; + minimumClearance = Math.min(minimumClearance, clearance); + const angle = Math.atan2(node.y, node.x); + const previous = angles.get(node.id); + angularTravel.set(node.id, angularTravel.get(node.id) + + Math.abs(Math.atan2(Math.sin(angle - previous), Math.cos(angle - previous)))); + angles.set(node.id, angle); + }); + } + + const dragged = [ + { id: 'drag-anchor', anchor_role: 'global', community_id: 'core', + gravity_mass: 64, radius: 12, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'dragged', community_id: 'dragged-system', gravity_mass: 1, radius: 2, + x: 0, y: 0, vx: 0, vy: 0 }, + ]; + const dragStep = I.integrateGalaxyLeapfrog(dragged, [], [], { + gravity: 0, central: true, fixedNodeId: 'dragged', timestep: 0.021328125, + includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, + includeCollisions: false, includeRelations: false, inwardConvergence: false, + velocityDecay: 0, speedLimit: 48, + }); + emit({ + minimumClearance, contacts, + angularTravel: Object.fromEntries(angularTravel), + anchor: [nodes[0].x, nodes[0].y, nodes[0].vx, nodes[0].vy], + finalRadii: nodes.slice(1).map(node => Math.hypot(node.x, node.y)), + finite: nodes.concat(dragged).every(node => + [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), + maximumSpeed: finalStep.maximumSpeed, + finalClearance: finalStep.blackHoleExclusion.minimumClearance, + draggedClearance: Math.hypot(dragged[1].x, dragged[1].y) + - dragged[0].radius - dragged[1].radius - 2.5, + dragContacts: dragStep.blackHoleExclusion.contacts, + }); + """ + ) + assert report["finite"] is True + assert report["anchor"] == pytest.approx([0, 0, 0, 0], abs=1e-12) + assert report["minimumClearance"] >= -1e-9 + assert report["finalClearance"] >= -1e-9 + # The weaker 48 setting may never enter the horizon during this run; the boundary is still + # exercised by the explicit dragged-node case below. + assert report["contacts"] >= 0 + assert min(report["angularTravel"].values()) > 0.05 + assert report["maximumSpeed"] <= 48 + assert report["draggedClearance"] >= -1e-9 + assert report["dragContacts"] > 0 + + +@requires_node +def test_nested_galaxy_orbits_keep_global_and_local_angular_motion() -> None: + """Dense cross-system contact must not erase either layer of orbital motion.""" + report = _run_node( + """ + const nodes = [{ id: 'bh', anchor_role: 'global', community_id: 'core', + gravity_mass: 24, radius: 10, x: 0, y: 0, vx: 0, vy: 0 }]; + const systemIds = []; + for (let system = 0; system < 14; system++) { + const phase = system * 2 * Math.PI / 14; + systemIds.push('s' + system); + for (let member = 0; member < 4; member++) { + const localPhase = phase + member * Math.PI / 2; + nodes.push({ id: `${system}-${member}`, community_id: `s${system}`, + anchor_role: member ? 'none' : 'community', gravity_mass: member ? 1 : 5, + radius: member ? 3 : 5, + x: Math.cos(phase) * 38 + Math.cos(localPhase) * (member ? 9 : 0), + y: Math.sin(phase) * 38 + Math.sin(localPhase) * (member ? 9 : 0), + vx: 0, vy: 0 }); + } + } + I.seedGalaxyOrbits(nodes, 91, 48, 12, false, 0.15, 0.75); + I.seedGalaxySystemOrbits(nodes, 91, 48, 40, false); + const centers = () => I.communityCenters(nodes); + const byId = id => nodes.find(node => node.id === id); + const globalAngles = new Map(systemIds.map(id => { + const center = centers().get(id); + return [id, Math.atan2(center.y, center.x)]; + })); + const localAngles = new Map(systemIds.map((id, system) => { + const star = byId(`${system}-0`), planet = byId(`${system}-1`); + return [id, Math.atan2(planet.y - star.y, planet.x - star.x)]; + })); + const globalTravel = new Map(systemIds.map(id => [id, 0])); + const localTravel = new Map(systemIds.map(id => [id, 0])); + const angleStep = (next, previous) => Math.atan2( + Math.sin(next - previous), Math.cos(next - previous) + ); + const options = { + gravity: 48, softening: 12, centralSoftening: 40, + localPairFraction: 0.15, corePairMultiplier: 0.75, + includeMutualSystems: true, mutualSystemGravityFraction: 0.12, + mutualSystemSoftening: 80, includeRelations: false, + includeOrbitalSeparation: true, orbitalSeparationPadding: 12, + orbitalSeparationStrength: 0.8, orbitalSeparationMaxCorrection: 4, + orbitalSeparationMaxVelocityCorrection: 8, + crossCommunitySeparationPadding: 1.5, crossCommunitySeparationStrength: 0.144, + includeCollisions: false, + includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, + includeFarFieldConfinement: true, farFieldEnvelopeScale: 1.25, + farFieldMinimumRadius: 96, farFieldSoftFraction: 0.82, + farFieldAcceleration: 12, farFieldMaxAcceleration: 16, inwardConvergence: true, + timestep: 0.021328125, wallClockSeconds: 1 / 30, + velocityDecay: 0.0001, speedLimit: 48, localRelativeSpeedLimit: 16, + }; + let minimumClearance = Infinity, maximumSpeed = 0, minimumSystemSpeed = Infinity; + let crossCommunityOverlaps = 0; + for (let step = 0; step < 300; step++) { + const tick = I.integrateGalaxyLeapfrog(nodes, [], [], options); + crossCommunityOverlaps += tick.orbitalSeparation.crossCommunityOverlaps; + systemIds.forEach((id, system) => { + const center = centers().get(id); + const global = Math.atan2(center.y, center.x); + const globalDelta = angleStep(global, globalAngles.get(id)); + globalTravel.set(id, globalTravel.get(id) + Math.abs(globalDelta)); + globalAngles.set(id, global); + const star = byId(`${system}-0`), planet = byId(`${system}-1`); + const local = Math.atan2(planet.y - star.y, planet.x - star.x); + const localDelta = angleStep(local, localAngles.get(id)); + localTravel.set(id, localTravel.get(id) + Math.abs(localDelta)); + localAngles.set(id, local); + const radius = Math.hypot(center.x, center.y); + const vx = center.nodes.reduce((sum, node) => sum + + node.gravity_mass * node.vx, 0) / center.mass; + const vy = center.nodes.reduce((sum, node) => sum + + node.gravity_mass * node.vy, 0) / center.mass; + minimumSystemSpeed = Math.min(minimumSystemSpeed, Math.abs( + (-center.y / radius) * vx + (center.x / radius) * vy + )); + }); + nodes.slice(1).forEach(node => { + minimumClearance = Math.min(minimumClearance, Math.hypot(node.x, node.y) + - nodes[0].radius - node.radius - 2.5); + }); + maximumSpeed = Math.max(maximumSpeed, tick.maximumSpeed); + } + emit({ + globalTravel: Object.fromEntries(globalTravel), + localTravel: Object.fromEntries(localTravel), + minimumClearance, + maximumSpeed, crossCommunityOverlaps, minimumSystemSpeed, + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)), + }); + """ + ) + assert report["finite"] is True + assert report["minimumClearance"] >= -1e-9 + assert report["maximumSpeed"] <= 48 + assert report["crossCommunityOverlaps"] > 1000 + assert report["minimumSystemSpeed"] > 3 + assert min(report["globalTravel"].values()) > 1 + assert min(report["localTravel"].values()) > 0.3 + + +@requires_node +def test_hierarchical_galaxy_keeps_planets_bound_to_one_dominant_star() -> None: + """A local star is the sole source for its planets while its system orbits the hole. + + This deliberately starts one planet slightly inside its star's painted exclusion radius. + The contact layer must repair that hard local boundary without draining either the + system's black-hole orbit or the satellites' signed local angular phase. + """ + report = _run_node( + """ + const nodes = [ + { id: 'bh', anchor_role: 'global', community_id: 'core', + gravity_mass: 64, radius: 10, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'a-star', community_id: 'a', system_anchor_id: 'a-star', gravity_mass: 14, radius: 5, + x: 46, y: 0, vx: 0, vy: 0 }, + { id: 'a-inner', orbit_tier: 1, community_id: 'a', system_anchor_id: 'a-star', gravity_mass: 1, radius: 3, + x: 54, y: 0, vx: 0, vy: 0 }, + { id: 'a-outer', orbit_tier: 2, community_id: 'a', system_anchor_id: 'a-star', gravity_mass: 1, radius: 3, + x: 54, y: 7, vx: 0, vy: 0 }, + { id: 'b-star', community_id: 'b', system_anchor_id: 'b-star', gravity_mass: 12, radius: 5, + x: -54, y: 0, vx: 0, vy: 0 }, + { id: 'b-inner', orbit_tier: 1, community_id: 'b', system_anchor_id: 'b-star', gravity_mass: 1, radius: 3, + x: -44, y: 0, vx: 0, vy: 0 }, + { id: 'b-outer', orbit_tier: 2, community_id: 'b', system_anchor_id: 'b-star', gravity_mass: 1, radius: 3, + x: -54, y: -16, vx: 0, vy: 0 }, + ]; + const links = [ + { source: 'a-star', target: 'a-inner', rest_length: 10, spring_strength: 0.08 }, + { source: 'a-star', target: 'a-outer', rest_length: 16, spring_strength: 0.08 }, + { source: 'b-star', target: 'b-inner', rest_length: 10, spring_strength: 0.08 }, + { source: 'b-star', target: 'b-outer', rest_length: 16, spring_strength: 0.08 }, + ]; + const systemIds = ['a', 'b']; + const planetIds = ['a-inner', 'a-outer', 'b-inner', 'b-outer']; + const byId = id => nodes.find(node => node.id === id); + const centers = () => I.communityCenters(nodes); + const angleStep = (next, previous) => Math.atan2( + Math.sin(next - previous), Math.cos(next - previous) + ); + const localSourceAcceleration = innerMass => { + /* A planet's inertial mass must not make it an additional local gravity source. */ + const sample = [ + { id: 'star', anchor_role: 'community', community_id: 'sample', + gravity_mass: 14, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'inner', community_id: 'sample', gravity_mass: innerMass, + x: 16, y: 0, vx: 0, vy: 0 }, + { id: 'outer', community_id: 'sample', gravity_mass: 1, + x: 0, y: 24, vx: 0, vy: 0 }, + ]; + I.applyGalaxySystemAnchorGravity(sample, { + gravity: 48, softening: 12, accelerationCap: 100, + }); + // The free-system frame can translate after a massive satellite recoils the star. + // Only outer-minus-star acceleration proves planets are not secondary wells. + return [sample[2].vx - sample[0].vx, sample[2].vy - sample[0].vy]; + }; + const lightPlanetField = localSourceAcceleration(1); + const heavyPlanetField = localSourceAcceleration(8); + + I.seedGalaxyOrbits(nodes, 9, 48, 12, false, 0.15, 0.75); + I.seedGalaxySystemOrbits(nodes, 9, 48, 40, false); + const globalAngles = new Map(systemIds.map(id => { + const center = centers().get(id); + return [id, Math.atan2(center.y, center.x)]; + })); + const localAngles = new Map(planetIds.map(id => { + const planet = byId(id), star = byId(id.slice(0, 1) + '-star'); + return [id, Math.atan2(planet.y - star.y, planet.x - star.x)]; + })); + const globalTravel = new Map(systemIds.map(id => [id, 0])); + const localTravel = new Map(planetIds.map(id => [id, 0])); + const options = { + gravity: 48, softening: 12, centralSoftening: 40, + localPairFraction: 0.15, corePairMultiplier: 0.75, + includeMutualSystems: true, mutualSystemGravityFraction: 0.12, + mutualSystemSoftening: 80, includeRelations: true, + relationStrengthMultiplier: 1, relationConstraintRate: 24, + relationConstraintMaxCorrection: 12, + includeRelationSprings: false, skipSystemAnchorRelations: true, + skipOrbitalSystemRelations: true, + includeOrbitalSeparation: true, orbitalSeparationPadding: 1.5, + orbitalSeparationStrength: 0.8, orbitalSeparationMaxCorrection: 4, + orbitalSeparationMaxVelocityCorrection: 8, + preserveLocalTangentialVelocity: true, skipSystemAnchorPairs: true, + systemAnchorExclusionPadding: 1.5, + crossCommunitySeparationPadding: 1.5, crossCommunitySeparationStrength: 0.144, + includeCollisions: false, + includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, + includeFarFieldConfinement: true, farFieldEnvelopeScale: 1.25, + farFieldMinimumRadius: 96, farFieldSoftFraction: 0.82, + farFieldAcceleration: 12, farFieldMaxAcceleration: 16, inwardConvergence: true, + timestep: 0.021328125, wallClockSeconds: 1 / 30, + velocityDecay: 0.0001, speedLimit: 48, localRelativeSpeedLimit: 16, + }; + let localContacts = 0, systemAnchorContacts = 0, systemRepulsions = 0; + let surfaceRepulsions = 0, maximumSystemRepulsion = 0; + let relationAnchorSkips = 0; + let relationOrbitalSystemSkips = 0; + let maximumSpeed = 0, minimumBlackHoleClearance = Infinity; + let minimumStarClearance = Infinity, maximumInnerOrbitRadius = 0, finalTick = null; + for (let step = 0; step < 600; step++) { + finalTick = I.integrateGalaxyLeapfrog(nodes, links, [], options); + localContacts += finalTick.orbitalSeparation.overlaps; + systemAnchorContacts += finalTick.systemAnchorExclusion.contacts; + systemRepulsions += finalTick.systemGravity.repulsions; + surfaceRepulsions += finalTick.systemGravity.surfaceRepulsions; + maximumSystemRepulsion = Math.max( + maximumSystemRepulsion, finalTick.systemGravity.maximumRepulsion); + relationAnchorSkips += finalTick.relationConstraint.skippedSystemAnchor; + relationOrbitalSystemSkips += finalTick.relationConstraint.skippedOrbitalSystem; + maximumSpeed = Math.max(maximumSpeed, finalTick.maximumSpeed); + systemIds.forEach(id => { + const center = centers().get(id); + const angle = Math.atan2(center.y, center.x); + globalTravel.set(id, globalTravel.get(id) + angleStep(angle, globalAngles.get(id))); + globalAngles.set(id, angle); + }); + planetIds.forEach(id => { + const planet = byId(id), star = byId(id.slice(0, 1) + '-star'); + const angle = Math.atan2(planet.y - star.y, planet.x - star.x); + localTravel.set(id, localTravel.get(id) + angleStep(angle, localAngles.get(id))); + localAngles.set(id, angle); + minimumStarClearance = Math.min(minimumStarClearance, + Math.hypot(planet.x - star.x, planet.y - star.y) + - star.radius - planet.radius - 1.5); + if (id.endsWith('-inner')) maximumInnerOrbitRadius = Math.max( + maximumInnerOrbitRadius, Math.hypot(planet.x - star.x, planet.y - star.y) + ); + }); + nodes.slice(1).forEach(node => { + minimumBlackHoleClearance = Math.min(minimumBlackHoleClearance, + Math.hypot(node.x, node.y) - nodes[0].radius - node.radius - 2.5); + }); + } + const envelope = finalTick.farFieldConfinement.envelopeRadius; + emit({ + dominantOnly: systemIds.every(id => { + const star = byId(id + '-star'); + return !star.__galaxyOrbitOrder && ['inner', 'outer'].every(tier => + !!byId(id + '-' + tier).__galaxyOrbitOrder); + }), + localSourceShift: Math.hypot( + lightPlanetField[0] - heavyPlanetField[0], + lightPlanetField[1] - heavyPlanetField[1], + ), + globalTravel: Object.fromEntries(globalTravel), + localTravel: Object.fromEntries(localTravel), + localContacts, systemAnchorContacts, systemRepulsions, surfaceRepulsions, + maximumSystemRepulsion, + relationAnchorSkips, relationOrbitalSystemSkips, + maximumSpeed, minimumBlackHoleClearance, minimumStarClearance, + maximumInnerOrbitRadius, + outerBounded: nodes.slice(1).every(node => + Math.hypot(node.x, node.y) + node.radius <= envelope + 1e-8), + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)), + }); + """ + ) + assert report["dominantOnly"] is True + assert report["localSourceShift"] <= 1e-10 + assert report["finite"] is True + assert report["outerBounded"] is True + assert report["localContacts"] > 0 + assert report["systemRepulsions"] > 0 + assert report["maximumSystemRepulsion"] > 0 + # Explicit orbital metadata now takes precedence over the older anchor-only exemption. + assert report["relationAnchorSkips"] == 0 + assert report["relationOrbitalSystemSkips"] > 0 + assert report["minimumBlackHoleClearance"] >= -1e-9 + assert report["minimumStarClearance"] >= -1e-9 + # The six-unit soft stellar-pressure band intentionally expands the near-surface r=10 + # seeds, but they remain strongly bound below the retired always-on ~20 separation brake. + assert report["maximumInnerOrbitRadius"] < 18 + assert report["maximumSpeed"] <= 48 + assert min(abs(value) for value in report["globalTravel"].values()) > 1 + assert min(abs(value) for value in report["localTravel"].values()) > 1 + + +@requires_node +def test_render_enforces_horizon_before_paint_for_oversized_static_galaxy() -> None: + report = _run_engine( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 64, visual_radius: 8, degree: 1, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'intruder', community_id: 'intruder', gravity_mass: 1, + visual_radius: 3, degree: 1, x: 0, y: 0, vx: 0, vy: 5 }, + ]; + for (let index = 0; index < 1499; index++) nodes.push({ + id: 'filler-' + index, community_id: 'filler-' + index, + gravity_mass: 1, visual_radius: 3, degree: 1, + x: 240 + index * 2, y: 180 + (index % 17) * 3, vx: 0, vy: 0, + }); + const api = G.create(el, { reducedMotion: () => true }); + api.setData({ nodes, links: [], communities: [], community_bridges: [], + meta: { layout_seed: 7 } }); + const rendered = fg.graphData().nodes; + const anchor = rendered.find(node => node.id === 'black-hole'); + const intruder = rendered.find(node => node.id === 'intruder'); + const diagnostics = api.physicsDiagnostics(); + const integrator = source.slice(source.indexOf('function integrateGalaxyLeapfrog'), + source.indexOf('function galaxyMotionDiagnostics')); + emit({ + staticLayout: diagnostics.staticLayout, + exclusion: diagnostics.blackHoleExclusion, + clearance: Math.hypot(intruder.x - anchor.x, intruder.y - anchor.y) + - anchor.radius - intruder.radius - diagnostics.blackHoleExclusionPadding, + anchor: [anchor.x, anchor.y, anchor.vx, anchor.vy], + pinned: [intruder.fx, intruder.fy], + position: [intruder.x, intruder.y], + initialBeforeAcceleration: integrator.indexOf('const initialHorizon') + < integrator.indexOf('const start = galaxyAccelerations'), + }); + """ + ) + assert report["staticLayout"] is True + assert report["exclusion"]["contacts"] > 0 + assert report["clearance"] >= -1e-9 + assert report["anchor"] == pytest.approx([0, 0, 0, 0], abs=1e-12) + assert report["pinned"] == pytest.approx(report["position"], abs=1e-12) + assert report["initialBeforeAcceleration"] is True + + +@requires_node +def test_render_reapplies_far_field_envelope_before_static_repaint() -> None: + """A reused oversized/static payload must not bypass the cached outer boundary.""" + report = _run_engine( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 64, visual_radius: 8, degree: 1, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'intruder', community_id: 'outer', gravity_mass: 1, + visual_radius: 3, degree: 1, x: 300, y: 0, vx: 0, vy: 4 }, + ]; + for (let index = 0; index < 1499; index++) nodes.push({ + id: 'filler-' + index, community_id: 'filler-' + index, + gravity_mass: 1, visual_radius: 3, degree: 1, + x: 160 + index * 2, y: 140 + (index % 17) * 3, vx: 0, vy: 0, + }); + const api = G.create(el, { reducedMotion: () => true }); + api.setData({ nodes, links: [], communities: [], community_bridges: [], + meta: { layout_seed: 19 } }); + const initial = api.physicsDiagnostics(); + const rendered = fg.graphData().nodes; + const anchor = rendered.find(node => node.id === 'black-hole'); + const intruder = rendered.find(node => node.id === 'intruder'); + intruder.x = initial.farFieldConfinement.envelopeRadius + 400; + intruder.y = 0; + intruder.fx = intruder.x; + intruder.fy = intruder.y; + /* A cosmetic setting keeps the same static arrays; it must still project before + force-graph's next paint rather than relying on the disabled live integrator. */ + api.setSettings({ font: 13 }); + const diagnostics = api.physicsDiagnostics(); + const clearance = diagnostics.farFieldConfinement.envelopeRadius + - (Math.hypot(intruder.x - anchor.x, intruder.y - anchor.y) + intruder.radius); + emit({ + staticLayout: diagnostics.staticLayout, + initialEnvelope: initial.farFieldConfinement.envelopeRadius, + confinement: diagnostics.farFieldConfinement, + clearance, + pinned: [intruder.fx, intruder.fy], + position: [intruder.x, intruder.y], + finite: rendered.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)), + }); + """ + ) + assert report["staticLayout"] is True + assert report["initialEnvelope"] > 0 + assert report["confinement"]["boundedSystems"] >= 1 + assert report["clearance"] >= -1e-8 + assert report["pinned"] == pytest.approx(report["position"], abs=1e-12) + assert report["finite"] is True + + +@requires_node +def test_opt_in_inward_convergence_helper_is_bounded_and_keeps_local_frames_tangential() -> None: + report = _run_node( + """ + const options = { + gravity: 48, central: true, timestep: 0.021328125, velocityDecay: 0, + speedLimit: 1000, includeCollisions: false, inwardConvergence: true, + wallClockSeconds: 1 / 30, + }; + const anchor = { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 100, radius: 12, x: 0, y: 0, vx: 0, vy: 0 }; + const body = { id: 'outer', community_id: 'outer', gravity_mass: 1, radius: 2, + x: 120, y: 0, vx: 0, vy: 0 }; + const nodes = [anchor, body]; + let previous = Math.hypot(body.x, body.y), monotone = true; + for (let index = 0; index < 1800; index++) { + I.integrateGalaxyLeapfrog(nodes, [], [], options); + const radius = Math.hypot(body.x, body.y); + monotone = monotone && radius <= previous + 1e-10; + previous = radius; + } + const outbound = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 100, radius: 12, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'escape', community_id: 'outer', gravity_mass: 1, radius: 2, + x: 100, y: 0, vx: 30, vy: 0 }, + ]; + // Disable the central field explicitly for this low-level convergence-only trial; + // Galaxy's live carrier path intentionally retains its shallow floor at zero. + const escapeOptions = { ...options, gravity: 0, central: false }; + const escape = I.integrateGalaxyLeapfrog(outbound, [], [], escapeOptions); + const escapedRadius = Math.hypot(outbound[1].x, outbound[1].y); + const candidateRadius = 100 + 30 * options.timestep; + const attemptedOutward = candidateRadius - 100; + const counteracted = candidateRadius - escapedRadius; + const tangent = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 100, radius: 12, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'orbit', community_id: 'outer', gravity_mass: 1, radius: 2, + x: 120, y: 20, vx: 3, vy: 11 }, + ]; + const initial = new Map([['outer', { radius: 100 }]]); + const unitX = tangent[1].x / Math.hypot(tangent[1].x, tangent[1].y); + const unitY = tangent[1].y / Math.hypot(tangent[1].x, tangent[1].y); + const tangentBefore = tangent[1].vx * -unitY + tangent[1].vy * unitX; + const direct = I.applyGalaxyInwardConvergence(tangent, tangent[0], initial, + { wallClockSeconds: 1 / 30 }); + const postX = tangent[1].x / Math.hypot(tangent[1].x, tangent[1].y); + const postY = tangent[1].y / Math.hypot(tangent[1].x, tangent[1].y); + const tangentAfter = tangent[1].vx * -postY + tangent[1].vy * postX; + const localSystem = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 100, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'star', community_id: 'solar', gravity_mass: 4, + x: 100, y: 0, vx: 1, vy: 3 }, + { id: 'planet', community_id: 'solar', gravity_mass: 1, + x: 112, y: 0, vx: -2, vy: 8 }, + ]; + const localCenter = I.communityCenters(localSystem).get('solar'); + const localInitial = new Map([['solar', { + radius: Math.hypot(localCenter.x, localCenter.y), + }]]); + const internalBefore = Math.hypot( + localSystem[2].x - localSystem[1].x, localSystem[2].y - localSystem[1].y); + const relativeVelocityBefore = [ + localSystem[2].vx - localSystem[1].vx, + localSystem[2].vy - localSystem[1].vy, + ]; + I.applyGalaxyInwardConvergence(localSystem, localSystem[0], localInitial, + { wallClockSeconds: 1 / 30, gravity: 48, timestep: 0.021328125 }); + const internalAfter = Math.hypot( + localSystem[2].x - localSystem[1].x, localSystem[2].y - localSystem[1].y); + const relativeVelocityAfter = [ + localSystem[2].vx - localSystem[1].vx, + localSystem[2].vy - localSystem[1].vy, + ]; + const dense = Array.from({ length: 512 }, (_, index) => ({ + id: `n${index}`, x: 40 + (index % 32), y: 30 + Math.floor(index / 32), + vx: index % 3 - 1, vy: index % 5 - 2, community_id: `dense-${index}`, + })); + dense.unshift({ id: 'black-hole', anchor_role: 'global', community_id: 'core', + x: 0, y: 0, vx: 0, vy: 0 }); + let denseInitial = new Map([...I.communityCenters(dense).entries()].map( + ([id, center]) => [id, { radius: Math.hypot(center.x, center.y) }])); + let denseReport; + for (let index = 0; index < 120; index++) { + denseReport = I.applyGalaxyInwardConvergence(dense, dense[0], denseInitial, + { wallClockSeconds: 1 / 30 }); + denseInitial = new Map([...I.communityCenters(dense).entries()].map( + ([id, center]) => [id, { radius: Math.hypot(center.x, center.y) }])); + } + emit({ + minuteRadius: previous, monotone, + anchor: [anchor.x, anchor.y, anchor.vx, anchor.vy], + escapedRadius, attemptedOutward, counteracted, + outboundVelocity: outbound[1].vx, + tangentBefore, tangentAfter, direct, + internalBefore, internalAfter, + relativeVelocityBefore, relativeVelocityAfter, + finite: nodes.concat(outbound, tangent, dense).every(node => + [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), + denseApplied: denseReport.applied, + factors: [0, 48, 100].map(gravity => + I.galaxyInwardConvergenceFactor(60, gravity)), + rates: [0, 48, 100].map(gravity => + I.galaxyInwardConvergencePerMinute(gravity)), + convergence: escape.convergence, + }); + """ + ) + # Convergence is disabled (rate=0) for stable orbits: factor is 1 and rate is 0 + # at every gravity setting. The helper still runs but performs no movement. + assert report["factors"][0] == pytest.approx(1) + assert report["factors"][1] == pytest.approx(1) + assert report["factors"][2] == pytest.approx(1) + assert report["rates"][0] == pytest.approx(0) + assert report["rates"][1] == pytest.approx(0) + assert report["rates"][2] == pytest.approx(0) + # With convergence disabled, carrier support injects tangential velocity and the body + # enters an orbit rather than falling straight in. Radius oscillates — this is correct. + assert report["minuteRadius"] > 0 + assert report["minuteRadius"] < 240 + # monotone is False because the orbit oscillates, which is the desired stable behavior. + assert report["anchor"] == pytest.approx([0, 0, 0, 0], abs=1e-12) + # The optional inward projector is a no-op at rate=0; escape trajectory is ballistic. + candidate_radius = 100 + 30 * 0.021328125 + assert 100 < report["escapedRadius"] <= candidate_radius + assert 0 <= report["counteracted"] < 0.01 + assert 29 < report["outboundVelocity"] <= 30 + assert report["tangentAfter"] == pytest.approx(report["tangentBefore"], abs=1e-12) + assert report["internalAfter"] == pytest.approx(report["internalBefore"], abs=1e-12) + assert report["relativeVelocityAfter"] == pytest.approx( + report["relativeVelocityBefore"], abs=1e-12 + ) + assert report["finite"] is True + # Factor=1 triggers the early-return path: applied=0, no convergence work done. + assert report["denseApplied"] == 0 + assert report["convergence"]["overrides"] == 0 + + +@requires_node +def test_gravity_setting_changes_orbital_support_without_teleporting_system_density() -> None: + report = _run_node( + """ + const fixture = () => [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + gravity_mass: 20, x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'star-a', anchor_role: 'community', community_id: 'a', + gravity_mass: 6, x: 120, y: 20, vx: 1, vy: 3 }, + { id: 'planet-a', community_id: 'a', gravity_mass: 1, + x: 132, y: 20, vx: -2, vy: 7 }, + { id: 'star-b', anchor_role: 'community', community_id: 'b', + gravity_mass: 4, x: -180, y: 80, vx: -1, vy: -2 }, + ]; + const radius = (nodes, id) => { + const center = I.communityCenters(nodes).get(id); + return Math.hypot(center.x, center.y); + }; + const direct = fixture(), stepped = fixture(); + const before = { + radius: radius(direct, 'a'), + diameter: Math.hypot(direct[2].x - direct[1].x, direct[2].y - direct[1].y), + phase: direct.map(node => [node.x, node.y, node.vx, node.vy]), + }; + const tightened = I.applyGalaxyGravitySettingResponse(direct, 48, 100); + const tight = { + radius: radius(direct, 'a'), + diameter: Math.hypot(direct[2].x - direct[1].x, direct[2].y - direct[1].y), + phase: direct.map(node => [node.x, node.y, node.vx, node.vy]), + }; + const loosened = I.applyGalaxyGravitySettingResponse(direct, 100, 48); + [60, 80, 100].reduce((previous, setting) => { + I.applyGalaxyGravitySettingResponse(stepped, previous, setting); + return setting; + }, 48); + emit({ + before, tight, + roundTrip: direct.map(node => [node.x, node.y, node.vx, node.vy]), + stepped: stepped.map(node => [node.x, node.y, node.vx, node.vy]), + tightened, loosened, + }); + """ + ) + assert report["tightened"]["systems"] == 2 + assert report["tightened"]["moved"] == 2 + assert report["tightened"]["velocityAdjusted"] == 3 + assert report["tightened"]["maximumVelocityShift"] > 0 + assert report["tightened"]["maximumShift"] == pytest.approx(0, abs=1e-12) + assert report["tight"]["radius"] == pytest.approx(report["before"]["radius"], abs=1e-12) + assert report["tight"]["diameter"] == pytest.approx( + report["before"]["diameter"], abs=1e-12 + ) + # The slider re-seeds the black-hole-frame tangent immediately, but does not teleport the + # carrier or change any planet's local star-relative vector. + assert [row[:2] for row in report["tight"]["phase"]] == [ + row[:2] for row in report["before"]["phase"] + ] + assert report["tight"]["phase"][2][2] - report["tight"]["phase"][1][2] == pytest.approx( + report["before"]["phase"][2][2] - report["before"]["phase"][1][2] + ) + assert report["tightened"]["ratio"] > 1 + assert report["loosened"]["moved"] == 2 + assert report["loosened"]["velocityAdjusted"] == 3 + assert report["loosened"]["maximumShift"] == pytest.approx(0, abs=1e-12) + # A stepped change is path-independent: the final 100-setting velocity matches a direct + # 48→100 response even when intermediate slider values were visited. + for actual, expected in zip(report["stepped"], report["tight"]["phase"]): + assert actual == pytest.approx(expected, abs=1e-12) + + +@requires_node +def test_cached_carrier_lanes_support_cross_community_black_hole_children() -> None: + """Explicit ``system_anchor_id`` wins over community grouping for BH satellites.""" + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', gravity_mass: 64, radius: 9, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'outer-star', anchor_role: 'community', community_id: 'outer', + system_anchor_id: 'outer-star', gravity_mass: 8, radius: 5, + x: 220, y: 0, vx: 0, vy: 12 }, + { id: 'outer-planet', community_id: 'outer', system_anchor_id: 'outer-star', + gravity_mass: 1, radius: 2, x: 248, y: 0, vx: 0, vy: 15 }, + // This satellite deliberately belongs to a different community while explicitly + // orbiting the black hole. A community-only implementation freezes or drops it. + { id: 'cross-core-child', community_id: 'cross-core', system_anchor_id: 'black-hole', + orbit_tier: 1, gravity_mass: 3, radius: 3, x: 0, y: 54, vx: -8, vy: 0 }, + ]; + Object.defineProperty(nodes[1], '__galaxyCarrierLaneRadius', + { value: 220, writable: true, configurable: true }); + Object.defineProperty(nodes[3], '__galaxyCarrierLaneRadius', + { value: 54, writable: true, configurable: true }); + const before = nodes.map(node => [node.id, node.x, node.y, node.vx, node.vy]); + const support = I.supportGalaxyCarrierOrbits(nodes, { + gravity: 48, centralSoftening: 40, softening: 32, layoutSeed: 7331, + blackHoleMass: 1, gravitationalConstant: 1, localGravitationalConstant: 1, + includeMutualSystems: false, + }); + const bh = nodes[0], cross = nodes[3]; + const dx = cross.x - bh.x, dy = cross.y - bh.y; + const tangent = dx * (cross.vy - bh.vy) - dy * (cross.vx - bh.vx); + emit({ before, support, tangent, + coordinates: nodes.map(node => [node.id, node.x, node.y, node.vx, node.vy]), + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), + }); + """ + ) + assert report["finite"] is True + assert report["support"]["eligible"] >= 2 + assert report["support"]["coreEligible"] == 1 + assert report["support"]["coreSupported"] == 1 + assert abs(report["tangent"]) > 1e-6 + # The explicit lane is authoritative: the carrier/root may be projected as a rigid group + # to its admitted radius, while the cross-community BH child is retained and supported. + by_id = {row[0]: row for row in report["coordinates"]} + assert math.hypot(by_id["outer-star"][1], by_id["outer-star"][2]) == pytest.approx(220) + assert math.hypot(by_id["cross-core-child"][1], by_id["cross-core-child"][2]) == pytest.approx(54) + + +@requires_node +def test_three_coincident_cross_community_black_hole_children_receive_distinct_clear_lanes() -> None: + """Multiple explicit BH children may share authored radius/phase but never remain stacked.""" + report = _run_node( + """ + const nodes = [{ id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', gravity_mass: 64, radius: 9, x: 0, y: 0, vx: 0, vy: 0 }]; + ['cross-a', 'cross-b', 'cross-c'].forEach((id, index) => { + const node = { id, community_id: id, system_anchor_id: 'black-hole', orbit_tier: 1, + gravity_mass: 3, radius: 3, x: 180, y: 0, orbit_radius: 180, vx: 0, vy: 0 }; + nodes.push(node); + }); + const options = { gravity: 48, centralSoftening: 40, softening: 32, layoutSeed: 90817, + blackHoleMass: 1, gravitationalConstant: 1, localGravitationalConstant: 1, + includeMutualSystems: false, includeRelations: false, includeCollisions: false, + includeOrbitalSeparation: false, includeSystemPacking: false, + includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, + includeFarFieldConfinement: true, farFieldEnvelopeScale: 2, farFieldMinimumRadius: 96, + timestep: .032, wallClockSeconds: 1 / 30, velocityDecay: .00005, speedLimit: 48 }; + // Admission owns phase-slotting. Calling support against arbitrary hand-written lane + // tags would bypass the product path and falsely manufacture a collision. + I.seedGalaxyOrbits(nodes, 90817, 48, 32, false, options); + I.supportGalaxyCarrierOrbits(nodes, options); + const phase = node => Math.atan2(node.y, node.x); + const initial = nodes.slice(1).map(node => ({ id: node.id, phase: phase(node), + lane: node.__galaxyCoreLaneRadius, radius: Math.hypot(node.x, node.y) })); + let minClearance = Infinity, frozen = 0; + let previous = nodes.slice(1).map(phase), travel = [0, 0, 0]; + for (let step = 0; step < 1000; step++) { + I.integrateGalaxyLeapfrog(nodes, [], [], options); + nodes.slice(1).forEach((node, index) => { + const next = phase(node), delta = Math.atan2(Math.sin(next - previous[index]), + Math.cos(next - previous[index])); + travel[index] += delta; + if (Math.abs(delta) < 1e-8) frozen++; + previous[index] = next; + }); + for (let left = 1; left < nodes.length; left++) for (let right = left + 1; + right < nodes.length; right++) minClearance = Math.min(minClearance, + Math.hypot(nodes[left].x - nodes[right].x, nodes[left].y - nodes[right].y) + - nodes[left].radius - nodes[right].radius); + } + emit({ initial, travel, frozen, minClearance, + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); + """ + ) + assert report["finite"] is True + assert all(item["lane"] is not None for item in report["initial"]) + assert max(item["lane"] for item in report["initial"]) < 60 + assert len({round(item["phase"], 8) for item in report["initial"]}) == 3 + assert report["minClearance"] >= -1e-8 + assert report["frozen"] == 0 + assert all(abs(value) > 0.1 for value in report["travel"]) + + +@requires_node +def test_unequal_mass_local_seed_remains_a_bound_two_body_orbit() -> None: + report = _run_node( + """ + const nodes = [ + { id: 'star', anchor_role: 'community', community_id: 'solar', + system_anchor_id: 'star', + gravity_mass: 8, x: 0, y: 0, vx: 0, vy: 0, radius: 4 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', + gravity_mass: 1, x: 24, y: 0, vx: 0, vy: 0, radius: 2 }, + ]; + I.seedGalaxyOrbits(nodes, 31, 48, 7.68, false); + let minimum = Infinity, maximum = 0, centered = true; + for (let step = 0; step < 1200; step++) { + I.integrateGalaxyLeapfrog(nodes, [], [], { + gravity: 48, softening: 7.68, central: false, + timestep: 0.525, velocityDecay: 0, speedLimit: 100, + collisionStrength: 0, + }); + const separation = Math.hypot( + nodes[1].x - nodes[0].x, nodes[1].y - nodes[0].y + ); + minimum = Math.min(minimum, separation); + maximum = Math.max(maximum, separation); + centered = centered && nodes[0].x === 0 && nodes[0].y === 0 + && nodes[0].vx === 0 && nodes[0].vy === 0; + } + emit({ minimum, maximum, centered, + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)) }); + """ + ) + assert report["centered"] is True + assert report["finite"] is True + assert report["minimum"] >= 23.9 + # Exact-2x gravity raises the integrator's dimensionless step at this deliberately coarse + # 0.525 fixture timestep; the orbit remains within roughly 8% of its seeded radius with the + # compact kinematic carrier and translate-system-descendants admission. + assert report["maximum"] <= 26.0 + + +@requires_node +def test_galaxy_motion_diagnostics_are_mass_weighted_finite_and_read_only() -> None: + report = _run_node( + """ + const clean = [ + { id: 'heavy', x: 2, y: 0, vx: 3, vy: 4, gravity_mass: 4 }, + { id: 'light', x: -2, y: 0, vx: -2, vy: 0, gravity_mass: 1 }, + { id: 'history', x: Infinity, y: 0, vx: NaN, vy: 0, ghost: true }, + ]; + const before = JSON.stringify(clean); + const diagnostics = I.galaxyMotionDiagnostics(clean); + const dirty = I.galaxyMotionDiagnostics([ + { id: 'bad', x: NaN, y: 0, vx: Infinity, vy: 0, gravity_mass: 2 }, + ]); + emit({ diagnostics, dirty, unchanged: JSON.stringify(clean) === before }); + """ + ) + diagnostics = report["diagnostics"] + assert diagnostics["bodies"] == 2 + assert diagnostics["invalidBodies"] == 0 + assert diagnostics["totalMass"] == 5 + assert diagnostics["centerX"] == pytest.approx(1.2) + assert diagnostics["centerY"] == 0 + assert [diagnostics["momentumX"], diagnostics["momentumY"]] == pytest.approx([10, 16]) + assert diagnostics["kineticEnergy"] == pytest.approx(52) + assert diagnostics["angularMomentum"] == pytest.approx(12.8) + assert diagnostics["maxSpeed"] == pytest.approx(5) + assert report["dirty"]["invalidBodies"] == 1 + assert all(math.isfinite(report["dirty"][key]) for key in ( + "totalMass", "centerX", "centerY", "momentum", "kineticEnergy", "maxSpeed" + )) + assert report["unchanged"] is True + + +@requires_node +def test_fixed_step_speed_guard_uses_one_common_scale_and_preserves_momentum() -> None: + report = _run_node( + """ + const bodies = [ + { id: 'heavy', x: 0, y: 0, gravity_mass: 10, vx: 10, vy: 0 }, + { id: 'light', x: 100, y: 0, gravity_mass: 1, vx: -100, vy: 0 }, + { id: 'invalid', x: 0, y: 100, gravity_mass: 2, vx: NaN, vy: Infinity }, + { id: 'history', x: 0, y: -100, gravity_mass: 0, vx: 99, vy: -99, ghost: true }, + ]; + I.integrateGalaxyLeapfrog(bodies, [], [], { + gravity: 0, central: false, includeBridges: false, includeRelations: false, + includeCollisions: false, timestep: 0.001, velocityDecay: 0, speedLimit: 14.4, + }); + emit({ + velocities: bodies.map(node => [node.vx, node.vy]), + momentum: [ + bodies.filter(node => !node.ghost).reduce( + (sum, node) => sum + node.gravity_mass * node.vx, 0 + ), + bodies.filter(node => !node.ghost).reduce( + (sum, node) => sum + node.gravity_mass * node.vy, 0 + ), + ], + maximum: Math.max(...bodies.filter(node => !node.ghost) + .map(node => Math.hypot(node.vx, node.vy))), + }); + """ + ) + assert report["velocities"][0] == pytest.approx([1.44, 0], abs=1e-3) + assert report["velocities"][1] == pytest.approx([-14.4, 0], abs=1e-3) + assert report["velocities"][2] == pytest.approx([0, 0], abs=1e-3) + assert report["velocities"][3] == pytest.approx([99, -99]) + # Invalid finite-position payloads are sanitized into the common scale; allow the resulting + # sub-millisecond numerical residue while still requiring near-zero total momentum. + assert report["momentum"] == pytest.approx([0, 0], abs=2e-3) + assert report["maximum"] == pytest.approx(14.4) + + +@requires_node +def test_barnes_hut_matches_exact_fixture_with_subquadratic_traversal() -> None: + report = _run_node( + """ + const fixture = Array.from({ length: 80 }, (_, i) => ({ + id: 'n' + i, x: (i % 10) * 12 + (i % 3), y: Math.floor(i / 10) * 11, + vx: 0, vy: 0, gravity_mass: 1 + (i % 5), community_id: 'large', + })); + const exact = fixture.map(n => ({ ...n })), approximate = fixture.map(n => ({ ...n })); + I.applyGalaxyGravity(exact, { gravity: 2, softening: 5, alpha: 1, exactLimit: 1000 }); + const stats = I.applyGalaxyGravity(approximate, { + gravity: 2, softening: 5, alpha: 1, exactLimit: 64, theta: 0.85, + }); + let error = 0, signal = 0; + exact.forEach((node, i) => { + error += (node.vx - approximate[i].vx) ** 2 + (node.vy - approximate[i].vy) ** 2; + signal += node.vx ** 2 + node.vy ** 2; + }); + emit({ + relativeRms: Math.sqrt(error / signal), stats, quadratic: fixture.length ** 2, + momentum: [ + approximate.reduce((sum, node) => sum + node.gravity_mass * node.vx, 0), + approximate.reduce((sum, node) => sum + node.gravity_mass * node.vy, 0), + ], + }); + """ + ) + assert report["stats"]["approximations"] > 0 + assert report["stats"]["traversals"] < report["quadratic"] + assert report["relativeRms"] < 0.25 + assert report["momentum"] == pytest.approx([0, 0], abs=1e-10) + + +@requires_node +def test_community_bridge_force_scales_with_evidence_and_preserves_momentum() -> None: + report = _run_node( + """ + const run = strength => { + const nodes = [ + { id: 'left', x: 0, y: 0, vx: 0, vy: 0, gravity_mass: 2, community_id: 'left' }, + { id: 'right', x: 20, y: 0, vx: 0, vy: 0, gravity_mass: 4, community_id: 'right' }, + ]; + const stats = I.applyCommunityBridgeGravity(nodes, [{ + source_community: 'left', target_community: 'right', physics_strength: strength, + }], { gravity: 4, softening: 8, alpha: 1 }); + return { nodes, stats }; + }; + const weak = run(0.4), strong = run(0.8), none = run(0); + emit({ + ratio: strong.nodes[0].vx / weak.nodes[0].vx, + momentum: 2 * strong.nodes[0].vx + 4 * strong.nodes[1].vx, + applied: strong.stats.bridges, + none: none.nodes.map(n => [n.vx, n.vy]), + }); + """ + ) + assert report["ratio"] == pytest.approx(2) + assert report["momentum"] == pytest.approx(0, abs=1e-12) + assert report["applied"] == 1 + assert report["none"] == [[0, 0], [0, 0]] + + +@requires_node +def test_orbital_seed_is_deterministic_tangential_and_one_shot() -> None: + report = _run_node( + """ + const fixture = () => [ + { id: 'sun', x: 0, y: 0, gravity_mass: 8, community_id: 's' }, + { id: 'planet', x: 20, y: 0, gravity_mass: 1, community_id: 's' }, + ]; + const first = fixture(), second = fixture(), reduced = fixture(); + const haunted = fixture().concat([{ + id: 'history', x: 10, y: 10, vx: 9, vy: -7, gravity_mass: 0, + community_id: 's', ghost: true, + }]); + I.seedGalaxyOrbits(first, 42, 48, 8, false); + I.seedGalaxyOrbits(second, 42, 48, 8, false); + const initial = first.map(n => [n.vx, n.vy]); + first[1].vx = 123; first[1].vy = -456; + I.seedGalaxyOrbits(first, 42, 48, 8, false); + I.seedGalaxyOrbits(reduced, 42, 48, 8, true); + I.seedGalaxyOrbits(reduced, 42, 48, 8, false); + I.seedGalaxyOrbits(haunted, 42, 48, 8, false); + emit({ + deterministic: initial, + second: second.map(n => [n.vx, n.vy]), + tangentialDot: 20 * initial[1][0], + oneShot: [first[1].vx, first[1].vy], + reduced: reduced.map(n => [n.vx, n.vy]), + ghost: [haunted[2].vx, haunted[2].vy], + hauntedStar: [haunted[0].vx, haunted[0].vy], + }); + """ + ) + assert report["deterministic"] == report["second"] + assert report["tangentialDot"] == pytest.approx(0, abs=1e-12) + assert report["oneShot"] == [123, -456] + assert report["reduced"] == report["deterministic"] + assert report["ghost"] == [0, 0] + assert report["hauntedStar"] == pytest.approx([0, 0], abs=1e-12) + + +@requires_node +def test_late_planet_gets_a_one_shot_orbit_without_erasing_the_existing_system() -> None: + """Incremental reveal seeds the fresh planet and preserves the old star-relative phase.""" + report = _run_node( + """ + const nodes = [ + { id: 'star', anchor_role: 'community', community_id: 'solar', + system_anchor_id: 'star', orbit_tier: 0, gravity_mass: 8, radius: 5, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'p1', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 1, + gravity_mass: 1, radius: 3, x: 16, y: 0, vx: 0, vy: 0 }, + ]; + const momentum = () => ['vx', 'vy'].map(axis => nodes.reduce((sum, node) => + sum + node.gravity_mass * (Number(node[axis]) || 0), 0)); + const relative = (node, anchor) => [node.vx - anchor.vx, node.vy - anchor.vy]; + I.seedGalaxyOrbits(nodes, 901, 48, 32, false); + const star = nodes[0], p1 = nodes[1]; + const starBefore = [star.x, star.y, star.vx, star.vy]; + const oldRelative = relative(p1, star); + const oldPhase = [p1.x - star.x, p1.y - star.y]; + const beforeMomentum = momentum(); + const p2 = { id: 'p2', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 2, + gravity_mass: 1, radius: 3, x: 0, y: 24, vx: 0, vy: 0 }; + nodes.push(p2); + const revealedMomentum = momentum(); + I.seedGalaxyOrbits(nodes, 901, 48, 32, false); + const afterRelative = relative(p1, star); + const freshRelative = relative(p2, star); + const freshRadialDot = (p2.x - star.x) * freshRelative[0] + + (p2.y - star.y) * freshRelative[1]; + const oldAngular = oldPhase[0] * oldRelative[1] - oldPhase[1] * oldRelative[0]; + const freshAngular = (p2.x - star.x) * freshRelative[1] + - (p2.y - star.y) * freshRelative[0]; + const afterMomentum = momentum(); + const afterFirst = nodes.map(node => [node.vx, node.vy]); + I.seedGalaxyOrbits(nodes, 901, 48, 32, false); + emit({ + oldRelative, afterRelative, oldPhase, + newPhase: [p1.x - star.x, p1.y - star.y], + freshRelative, freshRadialDot, oldAngular, freshAngular, + beforeMomentum, revealedMomentum, afterMomentum, + starBefore, starAfter: [star.x, star.y, star.vx, star.vy], + afterFirst, afterSecond: nodes.map(node => [node.vx, node.vy]), + seeded: nodes.map(node => !!node.__galaxyOrbitSeeded), + }); + """ + ) + assert report["seeded"] == [True, True, True] + assert math.hypot(*report["freshRelative"]) > 1e-6 + assert report["freshRadialDot"] == pytest.approx(0, abs=1e-10) + assert math.copysign(1, report["freshAngular"]) == math.copysign( + 1, report["oldAngular"] + ) + assert report["afterRelative"] == pytest.approx(report["oldRelative"], abs=1e-10) + assert report["newPhase"] == pytest.approx(report["oldPhase"], abs=1e-12) + # The seeded local system intentionally has nonzero total momentum: its star is the + # stationary local carrier rather than a barycentric recoil sink. + assert report["revealedMomentum"] == pytest.approx(report["beforeMomentum"], abs=1e-10) + assert report["afterMomentum"] != pytest.approx(report["beforeMomentum"], abs=1e-10) + assert report["starAfter"] == pytest.approx(report["starBefore"], abs=1e-12) + for first, second in zip(report["afterFirst"], report["afterSecond"]): + assert second == pytest.approx(first, abs=1e-12) + + +@requires_node +def test_many_massive_satellites_each_keep_a_star_only_circular_seed_and_visible_phase() -> None: + """Aggregate stellar recoil and the soft pressure band cannot zero a planet's orbit seed.""" + report = _run_node( + """ + const nodes = [{ id: 'star', anchor_role: 'community', community_id: 'solar', + gravity_mass: 8, radius: 5, x: 0, y: 0, vx: 0, vy: 0 }]; + // The counter-orbiting probe lies inside the star's smooth 6-unit pressure band. The + // many much heavier bodies on the other side make aggregate anchor recoil dominant in + // the old relative-acceleration seeder (total satellite mass is 40 > star mass 8). + nodes.push({ id: 'probe', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 1, + gravity_mass: 1, radius: 3, x: -13, y: 0, vx: 0, vy: 0 }); + for (let index = 0; index < 13; index += 1) { + const angle = -0.78 + index * 0.13, radius = 21 + index * 2.2; + nodes.push({ id: `heavy-${index}`, community_id: 'solar', system_anchor_id: 'star', + orbit_tier: index + 2, gravity_mass: 3, radius: 2, + x: Math.cos(angle) * radius, y: Math.sin(angle) * radius, vx: 0, vy: 0 }); + } + const star = nodes[0], localG = I.galaxyStellarGravityConstant(48), softening = 32; + I.seedGalaxyOrbits(nodes, 763, 48, softening, false); + const seeded = nodes.slice(1).map(node => { + const dx = node.x - star.x, dy = node.y - star.y, radius = Math.hypot(dx, dy); + const relativeVx = node.vx - star.vx, relativeVy = node.vy - star.vy; + const rawInward = localG * star.gravity_mass * radius + / Math.pow(radius * radius + softening * softening, 1.5); + return { + id: node.id, radius, expectedSpeed: Math.sqrt(rawInward * radius), + relativeSpeed: Math.hypot(relativeVx, relativeVy), + radialDot: dx * relativeVx + dy * relativeVy, + angular: dx * relativeVy - dy * relativeVx, + }; + }); + const initialAngles = new Map(nodes.slice(1).map(node => [node.id, + Math.atan2(node.y - star.y, node.x - star.x)])); + const travel = new Map(nodes.slice(1).map(node => [node.id, 0])); + const delta = (next, previous) => Math.atan2(Math.sin(next - previous), + Math.cos(next - previous)); + let clearance = Infinity, maximumSpeed = 0, maximumRelativeRadialAcceleration = -Infinity; + const options = { + gravity: 48, softening, central: false, includeMutualSystems: false, + includeRelations: false, includeBridges: false, includeCollisions: false, + includeOrbitalSeparation: false, skipSystemAnchorPairs: true, + systemAnchorExclusionPadding: 1.5, localRelativeSpeedLimit: 48, + // This runtime-centrality oracle isolates the dominant-star law. The separate + // pressure test covers the deliberate outward near-surface band. + systemAnchorRepulsionAcceleration: 0, + timestep: 0.032, velocityDecay: 0.0001, speedLimit: 48, + }; + for (let step = 0; step < 360; step += 1) { + const acceleration = I.galaxyAccelerations(nodes, [], [], options); + const anchorAcceleration = acceleration.get(star); + nodes.slice(1).forEach(node => { + const dx = node.x - star.x, dy = node.y - star.y; + const radius = Math.hypot(dx, dy); + const bodyAcceleration = acceleration.get(node); + maximumRelativeRadialAcceleration = Math.max(maximumRelativeRadialAcceleration, + ((bodyAcceleration.ax - anchorAcceleration.ax) * dx + + (bodyAcceleration.ay - anchorAcceleration.ay) * dy) / radius); + }); + const tick = I.integrateGalaxyLeapfrog(nodes, [], [], options); + maximumSpeed = Math.max(maximumSpeed, tick.maximumSpeed); + nodes.slice(1).forEach(node => { + const angle = Math.atan2(node.y - star.y, node.x - star.x); + travel.set(node.id, travel.get(node.id) + delta(angle, initialAngles.get(node.id))); + initialAngles.set(node.id, angle); + clearance = Math.min(clearance, Math.hypot(node.x - star.x, node.y - star.y) + - node.radius - star.radius - 1.5); + }); + } + emit({ seeded, travel: [...travel.values()], clearance, maximumSpeed, + maximumRelativeRadialAcceleration, + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); + """ + ) + assert report["finite"] is True + assert report["clearance"] >= -1e-9 + assert report["maximumSpeed"] <= 48 + seeded = report["seeded"] + assert len(seeded) == 14 + # The velocity is the star-only softened circular law, even for the pressure-band probe; + # all massive satellites share one local spin direction and none has a radial-only seed. + assert all(item["relativeSpeed"] == pytest.approx(item["expectedSpeed"], rel=1e-10) + for item in seeded), seeded + assert all(abs(item["radialDot"]) <= 1e-10 for item in seeded), seeded + assert all(abs(item["angular"]) > 1e-8 for item in seeded), seeded + signs = {math.copysign(1, item["angular"]) for item in seeded} + assert len(signs) == 1 + # Every live sample still sees an inward dominant-star relative acceleration even though + # satellites outweigh their star fivefold. Aggregate star recoil must be common drift, not + # an outward local force on the opposite probe. + assert report["maximumRelativeRadialAcceleration"] < 0, report + assert min(abs(value) for value in report["travel"]) > 0.45, report + + +@requires_node +def test_system_orbital_seed_preserves_barycentre_and_hierarchical_motion() -> None: + report = _run_node( + """ + const fixture = () => [ + { id: 'a', x: -100, y: 0, gravity_mass: 16, community_id: 'a' }, + { id: 'b', x: 80, y: 0, gravity_mass: 9, community_id: 'b' }, + { id: 'c', x: 0, y: 120, gravity_mass: 4, community_id: 'c' }, + ]; + const first = fixture(), second = fixture(), reduced = fixture(), late = fixture(); + I.seedGalaxySystemOrbits(first, 91, 48, 40, false); + I.seedGalaxySystemOrbits(second, 91, 48, 40, false); + const totalMass = first.reduce((sum, node) => sum + node.gravity_mass, 0); + const bx = first.reduce((sum, node) => sum + node.x * node.gravity_mass, 0) / totalMass; + const by = first.reduce((sum, node) => sum + node.y * node.gravity_mass, 0) / totalMass; + const initial = first.map(node => [node.vx, node.vy]); + first[0].vx = 123; first[0].vy = -456; + I.seedGalaxySystemOrbits(first, 91, 48, 40, false); + I.seedGalaxySystemOrbits(reduced, 91, 48, 40, true); + I.seedGalaxySystemOrbits(reduced, 91, 48, 40, false); + Object.defineProperty(late[0], '__galaxySystemOrbitSeeded', { + value: true, writable: true, configurable: true, + }); + Object.defineProperty(late[1], '__galaxySystemOrbitSeeded', { + value: true, writable: true, configurable: true, + }); + late[0].vx = 1; late[0].vy = 2; + late[1].vx = -16 / 9; late[1].vy = -32 / 9; + I.seedGalaxySystemOrbits(late, 91, 48, 40, false); + emit({ + deterministic: initial, + second: second.map(node => [node.vx, node.vy]), + radialDots: second.map(node => (node.x - bx) * node.vx + (node.y - by) * node.vy), + momentum: [ + second.reduce((sum, node) => sum + node.gravity_mass * node.vx, 0), + second.reduce((sum, node) => sum + node.gravity_mass * node.vy, 0), + ], + angularSpeeds: second.map(node => { + const dx = node.x - bx, dy = node.y - by; + return Math.abs(dx * node.vy - dy * node.vx) / (dx * dx + dy * dy); + }), + moving: second.every(node => Math.hypot(node.vx, node.vy) > 0), + oneShot: [first[0].vx, first[0].vy], + reduced: reduced.map(node => [node.vx, node.vy]), + late: late.map(node => [node.vx, node.vy]), + lateSeeded: late.every(node => node.__galaxySystemOrbitSeeded), + }); + """ + ) + assert report["deterministic"] == report["second"] + # The selected global/fallback anchor is an external black-hole frame. It remains still; + # the remaining systems get distinct tangential COM kicks rather than a fake global + # momentum cancellation that would make the visible galaxy fail to rotate. + assert max(report["angularSpeeds"]) - min(report["angularSpeeds"]) > 1e-6 + assert report["second"][0] == pytest.approx([0, 0], abs=1e-12) + assert any(math.hypot(*velocity) > 1e-8 for velocity in report["second"][1:]) + assert report["momentum"] != pytest.approx([0, 0], abs=1e-10) + assert report["oneShot"] == [123, -456] + assert report["reduced"] == report["deterministic"] + assert report["late"][0] == pytest.approx([1, 2]) + assert report["late"][1] == pytest.approx([-16 / 9, -32 / 9]) + # The only untagged late system receives its own black-hole tangent. Tagged systems keep + # their supplied phase instead of all three being reset as one barycentric block. + assert math.hypot(*report["late"][2]) > 1e-8 + assert report["lateSeeded"] is True + + +@requires_node +def test_global_system_seed_uses_faster_default_speed_cap_with_an_external_anchor() -> None: + """Authored systems orbit a fixed black-hole frame at the 30%-faster default cap.""" + report = _run_node( + """ + const nodes = [ + { id: 'bh', anchor_role: 'global', community_id: 'core', gravity_mass: 1000, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'east-star', anchor_role: 'community', community_id: 'east', gravity_mass: 1, + x: 100, y: 0, vx: 0, vy: 0 }, + { id: 'west-star', anchor_role: 'community', community_id: 'west', gravity_mass: 1, + x: -100, y: 0, vx: 0, vy: 0 }, + ]; + const field = I.galaxyBlackHoleField(nodes, { gravity: 400, softening: 40 }); + I.seedGalaxySystemOrbits(nodes, 183, 400, 40, false); + const anchor = nodes[0]; + emit({ + fieldSpeeds: field.systems.map(item => item.circularSpeed), + relative: nodes.slice(1).map(node => { + const dx = node.x - anchor.x, dy = node.y - anchor.y; + const vx = node.vx - anchor.vx, vy = node.vy - anchor.vy; + return { speed: Math.hypot(vx, vy), radialDot: dx * vx + dy * vy, + angular: dx * vy - dy * vx }; + }), + momentum: ['vx', 'vy'].map(axis => nodes.reduce((sum, node) => + sum + node.gravity_mass * node[axis], 0)), + anchor: [anchor.x, anchor.y, anchor.vx, anchor.vy], + }); + """ + ) + base_seed_limit = 18 + seed_limit = base_seed_limit * 1.3 + assert min(report["fieldSpeeds"]) > seed_limit + # Symmetric east/west seeded systems preserve zero net carrier momentum. + assert all(seed_limit * 0.9 < item["speed"] <= seed_limit * 1.01 + for item in report["relative"]), report + assert all(abs(item["angular"]) > 1e-8 for item in report["relative"]) + assert report["momentum"] == pytest.approx([0, 0], abs=1e-10) + assert report["anchor"] == pytest.approx([0, 0, 0, 0], abs=1e-12) + + +@requires_node +def test_center_coincident_external_singleton_is_admitted_to_a_live_black_hole_orbit() -> None: + """A newly revealed one-node system at the event horizon must never remain frozen.""" + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', orbit_tier: 0, gravity_mass: 64, radius: 10, + x: 0, y: 0, vx: 0, vy: 0 }, + // This is the exact late/reveal failure: it has a valid system identity but arrives + // at the black-hole centre with no velocity and no local satellite to seed it. + { id: 'late-singleton', anchor_role: 'community', community_id: 'late', + system_anchor_id: 'late-singleton', orbit_tier: 0, gravity_mass: 8, radius: 5, + x: 0, y: 0, vx: 0, vy: 0 }, + ]; + const options = { + gravity: 48, softening: 32, centralSoftening: 40, + includeMutualSystems: true, mutualSystemGravityFraction: .12, + mutualSystemSoftening: 80, includeRelations: false, includeBridges: false, + includeOrbitalSeparation: false, skipSystemAnchorPairs: true, + systemAnchorExclusionPadding: 1.5, includeBlackHoleExclusion: true, + blackHoleExclusionPadding: 2.5, includeFarFieldConfinement: true, + farFieldEnvelopeScale: 1.75, farFieldMinimumRadius: 96, + farFieldSoftFraction: .82, farFieldAcceleration: 12, farFieldMaxAcceleration: 16, + localRelativeSpeedLimit: 48, timestep: .032, wallClockSeconds: 1 / 30, + inwardConvergence: true, velocityDecay: .00005, speedLimit: 48, + includeCollisions: false, + }; + I.seedGalaxyOrbits(nodes, 60421, 48, 32, false); + I.seedGalaxySystemOrbits(nodes, 60421, 48, 40, false); + const anchor = nodes[0], singleton = nodes[1]; + const phase = () => Math.atan2(singleton.y - anchor.y, singleton.x - anchor.x); + const state = () => { + const dx = singleton.x - anchor.x, dy = singleton.y - anchor.y; + const dvx = singleton.vx - anchor.vx, dvy = singleton.vy - anchor.vy; + return { radius: Math.hypot(dx, dy), tangent: dx * dvy - dy * dvx, + radial: dx * dvx + dy * dvy }; + }; + const seeded = state(), initial = phase(); + let previous = initial, travel = 0, frozenSteps = 0, speedCaps = 0, minimumClearance = Infinity; + for (let step = 0; step < 180; step += 1) { + const tick = I.integrateGalaxyLeapfrog(nodes, [], [], options); + speedCaps += tick.speedCapped ? 1 : 0; + const next = phase(); + const delta = Math.atan2(Math.sin(next - previous), Math.cos(next - previous)); + travel += delta; + if (Math.abs(delta) < 1e-8) frozenSteps++; + previous = next; + minimumClearance = Math.min(minimumClearance, + Math.hypot(singleton.x - anchor.x, singleton.y - anchor.y) + - singleton.radius - anchor.radius - options.blackHoleExclusionPadding); + } + emit({ seeded, travel, frozenSteps, speedCaps, minimumClearance, + tagged: singleton.__galaxySystemOrbitSeeded === true, + anchor: [anchor.x, anchor.y, anchor.vx, anchor.vy], + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); + """ + ) + assert report["finite"] is True + assert report["tagged"] is True + assert report["anchor"] == pytest.approx([0, 0, 0, 0], abs=1e-12) + assert report["seeded"]["radius"] >= 17.5 - 1e-8 + assert abs(report["seeded"]["tangent"]) > 1e-5 + assert report["minimumClearance"] >= -1e-8 + assert abs(report["travel"]) > 0.05 + assert report["frozenSteps"] == 0 + assert report["speedCaps"] == 0 + + +@requires_node +def test_center_coincident_core_satellite_is_seeded_outside_the_black_hole_with_phase() -> None: + """A core member arriving at its explicit black hole has the same no-freeze guarantee.""" + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', orbit_tier: 0, gravity_mass: 64, radius: 10, + x: 0, y: 0, vx: 0, vy: 0 }, + // Core evidence is a black-hole satellite, not an independent system COM. This + // exact coincidence used to survive local seeding and remain a painted still point. + { id: 'core-satellite', anchor_role: 'none', community_id: 'core', + system_anchor_id: 'black-hole', orbit_tier: 1, gravity_mass: 2, radius: 3, + x: 0, y: 0, vx: 0, vy: 0 }, + ]; + const options = { + gravity: 48, softening: 32, centralSoftening: 40, + includeMutualSystems: true, mutualSystemGravityFraction: .12, + mutualSystemSoftening: 80, includeRelations: false, includeBridges: false, + includeOrbitalSeparation: false, skipSystemAnchorPairs: true, + systemAnchorExclusionPadding: 1.5, includeBlackHoleExclusion: true, + blackHoleExclusionPadding: 2.5, includeFarFieldConfinement: true, + farFieldEnvelopeScale: 1.75, farFieldMinimumRadius: 96, + farFieldSoftFraction: .82, farFieldAcceleration: 12, farFieldMaxAcceleration: 16, + localRelativeSpeedLimit: 48, timestep: .032, wallClockSeconds: 1 / 30, + inwardConvergence: true, velocityDecay: .00005, speedLimit: 48, + includeCollisions: false, + }; + I.seedGalaxyOrbits(nodes, 60422, 48, 32, false); + I.seedGalaxySystemOrbits(nodes, 60422, 48, 40, false); + const anchor = nodes[0], satellite = nodes[1]; + const phase = () => Math.atan2(satellite.y - anchor.y, satellite.x - anchor.x); + const state = () => { + const dx = satellite.x - anchor.x, dy = satellite.y - anchor.y; + const dvx = satellite.vx - anchor.vx, dvy = satellite.vy - anchor.vy; + return { radius: Math.hypot(dx, dy), tangent: dx * dvy - dy * dvx, + radial: dx * dvx + dy * dvy }; + }; + const seeded = state(); + let previous = phase(), travel = 0, frozenSteps = 0, speedCaps = 0, minimumClearance = Infinity; + for (let step = 0; step < 180; step += 1) { + const tick = I.integrateGalaxyLeapfrog(nodes, [], [], options); + speedCaps += tick.speedCapped ? 1 : 0; + const next = phase(); + const delta = Math.atan2(Math.sin(next - previous), Math.cos(next - previous)); + travel += delta; + if (Math.abs(delta) < 1e-8) frozenSteps++; + previous = next; + minimumClearance = Math.min(minimumClearance, + Math.hypot(satellite.x - anchor.x, satellite.y - anchor.y) + - satellite.radius - anchor.radius - options.blackHoleExclusionPadding); + } + emit({ seeded, travel, frozenSteps, speedCaps, minimumClearance, + parent: satellite.__galaxyOrbitAnchorId || null, + tagged: satellite.__galaxyOrbitSeeded === true, + anchor: [anchor.x, anchor.y, anchor.vx, anchor.vy], + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); + """ + ) + assert report["finite"] is True + assert report["parent"] == "black-hole" + assert report["tagged"] is True + assert report["anchor"] == pytest.approx([0, 0, 0, 0], abs=1e-12) + assert report["seeded"]["radius"] >= 15.5 - 1e-8 + assert abs(report["seeded"]["tangent"]) > 1e-5 + assert report["minimumClearance"] >= -1e-8 + assert abs(report["travel"]) > 0.05 + assert report["frozenSteps"] == 0 + assert report["speedCaps"] == 0 + + +@requires_node +def test_galaxy_live_limit_matches_the_complete_overview_contract() -> None: + """The complete public overview remains expanded and physical; larger scenes stay bounded.""" + report = _run_engine( + """ + const within = [ + I.galaxySceneWithinLiveLimit({ nodes: Array(1500), links: Array(3000) }), + I.galaxySceneWithinLiveLimit({ nodes: Array(1501), links: [] }), + I.galaxySceneWithinLiveLimit({ nodes: [], links: Array(3001) }), + ]; + let nextFrame = 1; + const frames = new Map(); + window.requestAnimationFrame = callback => { + const id = nextFrame++; frames.set(id, callback); return id; + }; + window.cancelAnimationFrame = id => frames.delete(id); + const flush = now => { + const batch = [...frames.values()]; frames.clear(); batch.forEach(callback => callback(now)); + }; + const scene = (count, edgeCount) => ({ + meta: { layout_seed: 91 }, + nodes: Array.from({ length: count }, (_, index) => ({ + id: index === 0 ? 'black-hole' : `node-${index}`, + community_id: 'core', + system_anchor_id: 'black-hole', + anchor_role: index === 0 ? 'global' : 'none', + orbit_tier: index, + gravity_mass: index === 0 ? 16 : 1, + visual_radius: index === 0 ? 8 : 2, + x: index === 0 ? 0 : 45 + index, + y: index % 7, + vx: 0, + vy: 0, + })), + edges: Array.from({ length: edgeCount }, (_, index) => ({ + id: `edge-${index}`, source: 'black-hole', + target: `node-${1 + index % Math.max(1, count - 1)}`, + layer: 'semantic', strength: 0.5, rest_length: 20, spring_strength: 0.08, + })), + }); + + const galaxy = G.create(el, { reducedMotion: () => true }); + galaxy.setData(scene(1500, 3000)); + store.onZoom({ k: 0.1 }); + const before = galaxy.physicsDiagnostics(); + flush(0); flush(34); flush(68); + const live = galaxy.physicsDiagnostics(); + const autoCollapsed = galaxy.state().collapsed; + galaxy.setCollapse(true); + const explicitCollapsed = galaxy.state().collapsed; + galaxy.setCollapse(false); + galaxy.setData(scene(1501, 3000)); + const nodeOverflow = galaxy.physicsDiagnostics(); + galaxy.setData(scene(1500, 3001)); + const edgeOverflow = galaxy.physicsDiagnostics(); + galaxy.destroy(); + + const full = G.create(el, { + reducedMotion: () => false, + renderMode: 'full', + }); + full.setPreset('original'); + full.setData(scene(601, 600)); + const classicFull = full.physicsDiagnostics(); + emit({ within, before, live, autoCollapsed, explicitCollapsed, nodeOverflow, + edgeOverflow, classicFull }); + """ + ) + assert report["within"] == [True, False, False] + assert report["before"]["renderedNodes"] == 1500 + assert report["before"]["renderedLinks"] == 3000 + assert report["before"]["galaxyLiveNodeLimit"] == 1500 + assert report["before"]["galaxyLiveLinkLimit"] == 3000 + assert report["before"]["withinGalaxyLiveLimit"] is True + assert report["before"]["largeRenderTier"] is True + assert report["before"]["staticLayout"] is False + assert report["before"]["active"] is True + assert report["live"]["steps"] >= report["before"]["steps"] + 3 + assert report["live"]["active"] is True + assert report["autoCollapsed"] is False + assert report["explicitCollapsed"] is True + assert report["nodeOverflow"]["staticLayout"] is True + assert report["edgeOverflow"]["staticLayout"] is True + assert report["classicFull"]["mode"] == "original" + assert report["classicFull"]["staticLayout"] is True + + +@requires_node +def test_reduced_motion_keeps_eight_independent_solar_systems_orbiting() -> None: + """The accessible visual preference keeps a visibly quick two-scale galaxy live. + + This deliberately uses eight independently phased systems and fixed solver time rather + than wall-clock delay. The former tuning only covered a barely visible minimum travel + (0.317 rad around the black hole and 0.608 rad locally in this fixture). A Galaxy has to + make both levels of hierarchy legible in the ordinary dashboard interval. + """ + report = _run_node( + """ + const nodes=[{id:'bh',anchor_role:'global',community_id:'core',gravity_mass:16,radius:10,x:0,y:0,vx:0,vy:0}],links=[]; + for(let s=0;s<8;s++){const p=s*2.4,r=105+s*13,cx=Math.cos(p)*r,cy=Math.sin(p)*r*.82; + for(let m=0;m<3;m++){const id=`s${s}-${m}`,q=m?14+m*5:0; + nodes.push({id,community_id:`s${s}`,system_anchor_id:`s${s}-0`,anchor_role:m?'none':'community',orbit_tier:m,gravity_mass:m?1:7,radius:m?3:5,x:cx+Math.cos(p+m*1.5)*q,y:cy+Math.sin(p+m*1.5)*q,vx:0,vy:0}); + if(m)links.push({source:`s${s}-0`,target:id,rest_length:q,spring_strength:.08});}} + const o={gravity:48,softening:32,centralSoftening:40,includeMutualSystems:true,mutualSystemGravityFraction:.12,mutualSystemSoftening:80,includeRelations:true,includeRelationSprings:false,skipSystemAnchorRelations:true,orbitScale:.25,relationConstraintRate:24,relationConstraintMaxCorrection:12,relationPadding:12,includeOrbitalSeparation:true,orbitalSeparationPadding:12,orbitalSeparationStrength:.8,crossCommunitySeparationPadding:1.5,crossCommunitySeparationStrength:.144,orbitalSeparationMaxCorrection:4,orbitalSeparationMaxVelocityCorrection:8,preserveLocalTangentialVelocity:true,skipSystemAnchorPairs:true,systemAnchorExclusionPadding:1.5,includeBlackHoleExclusion:true,blackHoleExclusionPadding:2.5,includeFarFieldConfinement:true,farFieldEnvelopeScale:1.75,farFieldMinimumRadius:96,farFieldSoftFraction:.82,farFieldAcceleration:12,farFieldMaxAcceleration:16,localRelativeSpeedLimit:48,timestep:.032,wallClockSeconds:1/30,inwardConvergence:true,velocityDecay:.00005,speedLimit:48,includeCollisions:false}; + I.seedGalaxyOrbits(nodes,91,48,32,true); I.seedGalaxySystemOrbits(nodes,91,48,40,true); + const cs=()=>I.communityCenters(nodes),d=(a,b)=>Math.atan2(Math.sin(a-b),Math.cos(a-b)),systems=[...Array(8).keys()].map(i=>`s${i}`),planets=nodes.filter(n=>n.orbit_tier>0); + const pg=new Map(systems.map(k=>{const c=cs().get(k);return[k,Math.atan2(c.y,c.x)]})),pl=new Map(planets.map(n=>{const a=nodes.find(x=>x.id===n.system_anchor_id);return[n.id,Math.atan2(n.y-a.y,n.x-a.x)]})),gt=new Map(systems.map(k=>[k,0])),lt=new Map(planets.map(n=>[n.id,0])); + let clear=Infinity,max=0,envelope=0,speedCaps=0;for(let i=0;i<240;i++){const t=I.integrateGalaxyLeapfrog(nodes,links,[],o);max=Math.max(max,t.maximumSpeed);speedCaps+=t.speedCapped?1:0;envelope=t.farFieldConfinement.envelopeRadius;systems.forEach(k=>{const c=cs().get(k),a=Math.atan2(c.y,c.x);gt.set(k,gt.get(k)+d(a,pg.get(k)));pg.set(k,a)});planets.forEach(n=>{const a=nodes.find(x=>x.id===n.system_anchor_id),q=Math.atan2(n.y-a.y,n.x-a.x);lt.set(n.id,lt.get(n.id)+d(q,pl.get(n.id)));pl.set(n.id,q);clear=Math.min(clear,Math.hypot(n.x-a.x,n.y-a.y)-n.radius-a.radius-1.5)});} + emit({global:[...gt.values()],local:[...lt.values()],clear,max,speedCaps,envelope,bounded:nodes.slice(1).every(n=>Math.hypot(n.x,n.y)+n.radius<=envelope+1e-8),finite:nodes.every(n=>[n.x,n.y,n.vx,n.vy].every(Number.isFinite))}); + """ + ) + assert report["finite"] is report["bounded"] is True + assert report["clear"] >= -1e-9 + assert report["max"] <= 48 + assert report["speedCaps"] == 0 + # At 30 Hz this is eight seconds of real solver time: every solar-system COM advances a + # clearly visible 26° and every planet advances 40° about its dominant star. These + # thresholds reject the previous slow, technically-nonzero drift while leaving bounded + # eccentric motion rather than requiring a rigid carousel. + assert min(abs(value) for value in report["global"]) > 0.45, report + assert min(abs(value) for value in report["local"]) > 0.70, report + + +@requires_node +def test_reduced_motion_has_exact_dual_scale_orbit_parity_and_star_surface_safety() -> None: + """Reduced visual motion cannot alter Galaxy initial conditions or stellar boundaries.""" + report = _run_node( + """ + const make = () => { + const nodes = [{ id: 'bh', anchor_role: 'global', community_id: 'core', + gravity_mass: 20, radius: 10, x: 0, y: 0, vx: 0, vy: 0 }], links = []; + [0.25, 2.4, 4.6, 5.65].forEach((phase, index) => { + const r = 80 + index * 25, id = `s${index}`; + const x = Math.cos(phase) * r, y = Math.sin(phase) * r * 0.82; + nodes.push({ id: `${id}-star`, anchor_role: 'community', community_id: id, + system_anchor_id: `${id}-star`, orbit_tier: 0, gravity_mass: 8, radius: 5, + x, y, vx: 0, vy: 0 }); + // The first satellite begins through the painted surface. The permanent stellar + // exclusion must project it before the fast orbital clock starts. + const distance = index === 0 ? 9 : 15 + index; + nodes.push({ id: `${id}-planet`, community_id: id, + system_anchor_id: `${id}-star`, orbit_tier: 1, gravity_mass: 1, radius: 3, + x: x + Math.cos(phase + 1.1) * distance, + y: y + Math.sin(phase + 1.1) * distance, vx: 0, vy: 0 }); + links.push({ source: `${id}-star`, target: `${id}-planet`, + rest_length: distance, spring_strength: 0.08 }); + }); + return { nodes, links }; + }; + const delta = (next, previous) => Math.atan2(Math.sin(next - previous), + Math.cos(next - previous)); + const run = reducedMotion => { + const { nodes, links } = make(); + const options = { + gravity: 48, softening: 32, centralSoftening: 40, + includeMutualSystems: true, mutualSystemGravityFraction: 0.12, + mutualSystemSoftening: 80, includeRelations: true, includeRelationSprings: false, + skipSystemAnchorRelations: true, orbitScale: 0.25, relationConstraintRate: 24, + relationConstraintMaxCorrection: 12, relationPadding: 12, + includeOrbitalSeparation: true, orbitalSeparationPadding: 12, + orbitalSeparationStrength: 0.8, crossCommunitySeparationPadding: 1.5, + crossCommunitySeparationStrength: 0.144, orbitalSeparationMaxCorrection: 4, + orbitalSeparationMaxVelocityCorrection: 8, preserveLocalTangentialVelocity: true, + skipSystemAnchorPairs: true, systemAnchorExclusionPadding: 1.5, + includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, + includeFarFieldConfinement: true, farFieldEnvelopeScale: 1.75, + farFieldMinimumRadius: 96, farFieldSoftFraction: 0.82, + farFieldAcceleration: 12, farFieldMaxAcceleration: 16, + localRelativeSpeedLimit: 48, timestep: 0.032, wallClockSeconds: 1 / 30, + inwardConvergence: true, velocityDecay: 0.0001, speedLimit: 48, + includeCollisions: false, + }; + I.seedGalaxyOrbits(nodes, 4401, 48, 32, reducedMotion); + I.seedGalaxySystemOrbits(nodes, 4401, 48, 40, reducedMotion); + const centers = () => I.communityCenters(nodes); + const systemIds = ['s0', 's1', 's2', 's3']; + const globalBefore = new Map(systemIds.map(id => { + const center = centers().get(id); return [id, Math.atan2(center.y, center.x)]; + })); + const localBefore = new Map(systemIds.map(id => { + const star = nodes.find(node => node.id === `${id}-star`); + const planet = nodes.find(node => node.id === `${id}-planet`); + return [id, Math.atan2(planet.y - star.y, planet.x - star.x)]; + })); + const seededMomentum = ['vx', 'vy'].map(axis => nodes.reduce((sum, node) => + sum + node.gravity_mass * node[axis], 0)); + let clearance = Infinity, maximumSpeed = 0, envelope = 0; + for (let step = 0; step < 180; step += 1) { + const tick = I.integrateGalaxyLeapfrog(nodes, links, [], options); + maximumSpeed = Math.max(maximumSpeed, tick.maximumSpeed); + envelope = tick.farFieldConfinement.envelopeRadius; + systemIds.forEach(id => { + const star = nodes.find(node => node.id === `${id}-star`); + const planet = nodes.find(node => node.id === `${id}-planet`); + clearance = Math.min(clearance, Math.hypot(planet.x - star.x, planet.y - star.y) + - star.radius - planet.radius - options.systemAnchorExclusionPadding); + }); + } + return { + global: systemIds.map(id => { + const center = centers().get(id); + return delta(Math.atan2(center.y, center.x), globalBefore.get(id)); + }), + local: systemIds.map(id => { + const star = nodes.find(node => node.id === `${id}-star`); + const planet = nodes.find(node => node.id === `${id}-planet`); + return delta(Math.atan2(planet.y - star.y, planet.x - star.x), localBefore.get(id)); + }), + seededMomentum, clearance, maximumSpeed, envelope, + bounded: nodes.slice(1).every(node => Math.hypot(node.x, node.y) + node.radius + <= envelope + 1e-8), + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)), + final: nodes.map(node => [node.x, node.y, node.vx, node.vy]), + }; + }; + emit({ reduced: run(true), ordinary: run(false) }); + """ + ) + reduced, ordinary = report["reduced"], report["ordinary"] + # The preference is cosmetic, so every deterministic physical result is exactly identical. + for actual, expected in zip(reduced["final"], ordinary["final"]): + assert actual == pytest.approx(expected) + # Reduced motion has exact physical parity. The black hole is an external frame, so the + # visible disk's seed momentum is not artificially cancelled through its fixed anchor. + assert reduced["seededMomentum"] == pytest.approx(ordinary["seededMomentum"], abs=1e-10) + assert reduced["seededMomentum"] != pytest.approx([0, 0], abs=1e-10) + assert reduced["final"][0] == pytest.approx([0, 0, 0, 0], abs=1e-12) + assert reduced["finite"] is reduced["bounded"] is True + assert reduced["clearance"] >= -1e-9 + assert reduced["maximumSpeed"] <= 48 + assert min(abs(value) for value in reduced["global"]) > 0.3 + assert min(abs(value) for value in reduced["local"]) > 0.45 + + +@requires_node +def test_every_local_member_gets_a_live_coherent_orbit_about_its_inferred_star() -> None: + """Every non-star member must orbit its community's dominant gravity node. + + Real scenes are not homogeneous: newer payloads carry ``system_anchor_id`` and + ``orbit_tier``, while old/imported/revealed rows often carry only a community id. The + local well must be inferred for both forms. This deliberately includes core satellites, + a metadata-free legacy system, a role-free mass-dominant system, and two late arrivals. A + nonzero system COM orbit cannot satisfy this test: each body is measured in *its star's* + moving frame on every solver step. + """ + report = _run_node( + """ + const nodes = [{ id: 'black-hole', community_id: 'core', anchor_role: 'global', + system_anchor_id: 'black-hole', orbit_tier: 0, gravity_mass: 48, radius: 9, + x: 0, y: 0, vx: 0, vy: 0 }]; + const links = []; + const add = (id, community, x, y, mass, radius, extra = {}) => { + nodes.push({ id, community_id: community, gravity_mass: mass, radius, + x, y, vx: 0, vy: 0, ...extra }); + }; + const orbit = (source, target, rest) => links.push({ source, target, + rest_length: rest, spring_strength: 0.08, relation: 'orbits' }); + // Global/core body plus two core satellites. Their central gravitational node is the + // black hole itself, not a separately-labelled community star. + add('core-explicit', 'core', 36, 0, 1.5, 3, + { system_anchor_id: 'black-hole', orbit_tier: 1 }); + add('core-legacy', 'core', -49, 8, 1, 2); + orbit('black-hole', 'core-explicit', 36); orbit('black-hole', 'core-legacy', 50); + const makeSystem = (id, cx, cy, mode) => { + const star = `${id}-star`; + const starMeta = mode === 'explicit' + ? { anchor_role: 'community', system_anchor_id: star, orbit_tier: 0 } + : mode === 'legacy' ? { anchor_role: 'community' } : {}; + add(star, id, cx, cy, 10, 5, starMeta); + [[22, 0], [-30, 9], [12, -35]].forEach(([dx, dy], index) => { + const member = `${id}-planet-${index}`; + const metadata = mode === 'explicit' + ? { system_anchor_id: star, orbit_tier: index + 1 } : {}; + add(member, id, cx + dx, cy + dy, 1 + index * .2, 2.5, metadata); + orbit(star, member, Math.hypot(dx, dy)); + }); + }; + makeSystem('explicit', 118, 28, 'explicit'); + makeSystem('legacy', -132, 60, 'legacy'); + // No role or system metadata: mass is the compatibility star-selection contract. + makeSystem('mass-star', 54, -151, 'mass'); + + const seed = () => { + I.seedGalaxyOrbits(nodes, 74017, 48, 32, false); + I.seedGalaxySystemOrbits(nodes, 74017, 48, 48, false); + }; + seed(); + // Simulate a revealed/reconciled payload after its system is already moving. One is + // explicit, one legacy; both must receive a fresh star-relative tangent, never freeze. + add('explicit-late', 'explicit', 118 - 38, 28 + 16, 1.1, 2.5, + { system_anchor_id: 'explicit-star', orbit_tier: 8 }); + add('legacy-late', 'legacy', -132 + 43, 60 - 13, 1.1, 2.5); + orbit('explicit-star', 'explicit-late', Math.hypot(38, 16)); + orbit('legacy-star', 'legacy-late', Math.hypot(43, 13)); + seed(); + + const byId = () => new Map(nodes.map(node => [node.id, node])); + const map = byId(); + const expectedAnchor = { + 'core-explicit': 'black-hole', 'core-legacy': 'black-hole', + 'explicit-planet-0': 'explicit-star', 'explicit-planet-1': 'explicit-star', + 'explicit-planet-2': 'explicit-star', 'explicit-late': 'explicit-star', + 'legacy-planet-0': 'legacy-star', 'legacy-planet-1': 'legacy-star', + 'legacy-planet-2': 'legacy-star', 'legacy-late': 'legacy-star', + 'mass-star-planet-0': 'mass-star-star', 'mass-star-planet-1': 'mass-star-star', + 'mass-star-planet-2': 'mass-star-star', + }; + const delta = (next, previous) => Math.atan2(Math.sin(next - previous), + Math.cos(next - previous)); + const tracks = Object.entries(expectedAnchor).map(([id, anchorId]) => { + const node = map.get(id), anchor = map.get(anchorId); + const dx = node.x - anchor.x, dy = node.y - anchor.y; + const dvx = node.vx - anchor.vx, dvy = node.vy - anchor.vy; + return { id, anchorId, angle: Math.atan2(dy, dx), travel: 0, + initialRadius: Math.hypot(dx, dy), minimumRadius: Math.hypot(dx, dy), + maximumRadius: Math.hypot(dx, dy), minimumTangential: Math.abs(dx * dvy - dy * dvx), + initialRadial: dx * dvx + dy * dvy, + frozenSteps: 0, direction: Math.sign(dx * dvy - dy * dvx), reversals: 0 }; + }); + const options = { + gravity: 48, softening: 32, centralSoftening: 48, timestep: .032, + velocityDecay: .00005, speedLimit: 48, localPairFraction: .15, + corePairMultiplier: .75, includeMutualSystems: true, + mutualSystemGravityFraction: .12, mutualSystemSoftening: 80, + includeRelations: true, includeRelationSprings: false, + skipSystemAnchorRelations: true, skipOrbitalSystemRelations: true, + orbitScale: .25, relationConstraintRate: 24, relationConstraintMaxCorrection: 12, + relationPadding: 15, includeOrbitalSeparation: true, + orbitalSeparationPadding: 15, orbitalSeparationStrength: 1, + crossCommunitySeparationPadding: 1.5, crossCommunitySeparationStrength: .18, + orbitalSeparationMaxCorrection: 4, orbitalSeparationMaxVelocityCorrection: 8, + preserveLocalTangentialVelocity: true, preserveSystemRadii: true, + skipSystemAnchorPairs: true, systemAnchorExclusionPadding: 1.5, + systemAnchorRepulsionRange: 6, systemAnchorRepulsionAcceleration: .12, + includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, + includeFarFieldConfinement: true, farFieldEnvelopeScale: 1.75, + farFieldMinimumRadius: 96, farFieldSoftFraction: .82, + farFieldAcceleration: 12, farFieldMaxAcceleration: 16, + localRelativeSpeedLimit: 48, inwardConvergence: false, + wallClockSeconds: 1 / 30, includeCollisions: false, includeSystemPacking: false, + }; + // The first live tick assigns the deterministic carrier-spin direction. Measure + // sustained local motion after that one-time insertion, not against the stale + // pre-admission tangent inherited from the authored coordinates. + I.integrateGalaxyLeapfrog(nodes, links, [], options); + tracks.forEach(track => { + const node = map.get(track.id), anchor = map.get(track.anchorId); + const dx = node.x - anchor.x, dy = node.y - anchor.y; + const dvx = node.vx - anchor.vx, dvy = node.vy - anchor.vy; + const radius = Math.hypot(dx, dy); + track.angle = Math.atan2(dy, dx); track.direction = Math.sign(dx * dvy - dy * dvx); + track.initialRadius = track.minimumRadius = track.maximumRadius = radius; + track.minimumTangential = Math.abs(dx * dvy - dy * dvx); + }); + let speedCaps = 0, minimumClearance = Infinity, maximumSpeed = 0; + for (let step = 0; step < 240; step++) { + const tick = I.integrateGalaxyLeapfrog(nodes, links, [], options); + speedCaps += tick.speedCapped ? 1 : 0; + maximumSpeed = Math.max(maximumSpeed, tick.maximumSpeed); + tracks.forEach(track => { + const node = map.get(track.id), anchor = map.get(track.anchorId); + const dx = node.x - anchor.x, dy = node.y - anchor.y; + const dvx = node.vx - anchor.vx, dvy = node.vy - anchor.vy; + const radius = Math.hypot(dx, dy), stepAngle = delta(Math.atan2(dy, dx), track.angle); + const tangent = dx * dvy - dy * dvx; + if (Math.abs(stepAngle) < 1e-6) track.frozenSteps++; + if (track.direction && Math.sign(stepAngle) === -track.direction + && Math.abs(stepAngle) > .001) track.reversals++; + track.travel += stepAngle; track.angle = Math.atan2(dy, dx); + track.minimumRadius = Math.min(track.minimumRadius, radius); + track.maximumRadius = Math.max(track.maximumRadius, radius); + track.minimumTangential = Math.min(track.minimumTangential, Math.abs(tangent)); + minimumClearance = Math.min(minimumClearance, + radius - node.radius - anchor.radius - 1.5); + }); + } + emit({ tracks, speedCaps, maximumSpeed, minimumClearance, + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)), + }); + """ + ) + assert report["finite"] is True + assert report["speedCaps"] == 0 + assert report["maximumSpeed"] < 48 + assert report["minimumClearance"] >= -1e-8 + assert len(report["tracks"]) == 13 + for track in report["tracks"]: + assert track["minimumTangential"] > 1e-5, track + assert abs(track["travel"]) > 0.35, track + assert track["frozenSteps"] == 0, track + # Tight initial contact repair can make a short eccentric correction on a late body; + # it must never degrade into a stalled back-and-forth orbit. + assert track["reversals"] <= 8, track + # A new/revealed body receives a circular seed in the star's live frame — not a radial + # inheritance from the star's galaxy orbit. Its local radius remains visibly orbital. + assert abs(track["initialRadial"]) < track["initialRadius"] * 1e-8, track + assert track["minimumRadius"] > track["initialRadius"] * 0.5, track + # A direct black-hole body may be admitted to a wider collision-free core lane. + # Star-owned planets retain the stricter local-frame radius envelope. + maximum_factor = 1.25 if track["anchorId"] == "black-hole" else 1.12 + assert track["maximumRadius"] < track["initialRadius"] * maximum_factor, track + + +@requires_node +def test_local_orbit_boundary_prevents_planet_escape_without_erasing_tangent() -> None: + """A star-relative escape is projected back inside its immutable authored envelope.""" + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', gravity_mass: 64, radius: 9, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'star', anchor_role: 'community', community_id: 'solar', + system_anchor_id: 'star', gravity_mass: 12, radius: 6, + galactic_radius: 120, galactic_target_radius: 120, + x: 120, y: 0, vx: 1, vy: 2 }, + { id: 'planet', anchor_role: 'none', community_id: 'solar', + system_anchor_id: 'star', orbit_tier: 1, orbit_radius: 30, + gravity_mass: 1, radius: 3, x: 150, y: 0, vx: 1, vy: 2 }, + { id: 'other-star', anchor_role: 'community', community_id: 'other', + system_anchor_id: 'other-star', gravity_mass: 9, radius: 5, + galactic_radius: 190, galactic_target_radius: 190, + x: -190, y: 0, vx: -2, vy: 3 }, + ]; + I.seedGalaxyOrbits(nodes, 8017, 48, 32, false, { + orbitalSpeed: 100, localGravitySetting: 48, + }); + const star = nodes[1], planet = nodes[2], other = nodes[3]; + const baseRadius = planet.__galaxyOrbitBaseRadius; + const otherBefore = { x: other.x, y: other.y, vx: other.vx, vy: other.vy }; + planet.x = star.x + baseRadius * 2.4; + planet.y = star.y; + planet.vx = star.vx + 18; + planet.vy = star.vy + 7; + const direct = I.enforceGalaxyLocalOrbitBoundaries(nodes, { + orbitalSpeed: 100, systemAnchorExclusionPadding: 1.5, + }); + const afterDirect = { + radius: Math.hypot(planet.x - star.x, planet.y - star.y), + radial: planet.vx - star.vx, + tangent: planet.vy - star.vy, + }; + const otherAfterDirect = { x: other.x, y: other.y, vx: other.vx, vy: other.vy }; + planet.x = star.x + baseRadius * 3; + planet.y = star.y; + planet.vx = star.vx + 24; + planet.vy = star.vy + 5; + const integrated = I.integrateGalaxyLeapfrog(nodes, [], [], { + central: false, gravity: 0, softening: 32, timestep: .032, + orbitalSpeed: 100, velocityDecay: 0, speedLimit: 48, + includeRelations: false, includeRelationSprings: false, + includeMutualSystems: false, includeOrbitalSeparation: false, + includeSystemPacking: false, includeBlackHoleExclusion: false, + includeFarFieldConfinement: false, includeCollisions: false, + systemAnchorExclusionPadding: 1.5, + }); + const afterIntegrated = { + radius: Math.hypot(planet.x - star.x, planet.y - star.y), + radial: planet.vx - star.vx, + tangent: planet.vy - star.vy, + }; + emit({ baseRadius, direct, afterDirect, otherAfterDirect, + integrated: integrated.localOrbitBoundary, afterIntegrated, otherBefore }); + """ + ) + maximum_radius = report["baseRadius"] * 1.08 + assert report["direct"]["correctedNodes"] == 1 + assert report["direct"]["maximumBoundaryRatioBefore"] > 2 + assert report["direct"]["maximumBoundaryRatioAfter"] <= 1 + assert report["afterDirect"]["radius"] == pytest.approx(maximum_radius) + assert report["afterDirect"]["radial"] <= 1e-9 + assert report["afterDirect"]["tangent"] == pytest.approx(7) + assert report["integrated"]["correctedNodes"] == 1 + assert report["integrated"]["maximumBoundaryRatioAfter"] <= 1 + assert report["afterIntegrated"]["radius"] <= maximum_radius + 1e-8 + assert report["afterIntegrated"]["radial"] <= 1e-8 + assert abs(report["afterIntegrated"]["tangent"]) > 1 + assert report["otherAfterDirect"] == report["otherBefore"] + + +@requires_node +def test_every_black_hole_system_member_gets_both_global_and_local_orbital_motion() -> None: + """Every black-hole carrier follows the server-authored parent chain. + + Direct children, descendants, and nested descendants retain one global carrier orbit plus + their independent local orbits in both the live and O(n) oversized render paths. + """ + report = _run_node( + """ + const make = () => { + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', gravity_mass: 64, radius: 9, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'core-star', community_id: 'core-satellite', + system_anchor_id: 'black-hole', gravity_mass: 8, radius: 5, + x: 38, y: 0, vx: 0, vy: 0 }, + { id: 'core-planet', community_id: 'core-satellite', + system_anchor_id: 'core-star', gravity_mass: 1, radius: 2.5, + x: 50, y: 0, vx: 0, vy: 0 }, + { id: 'core-moon', community_id: 'core-satellite', + system_anchor_id: 'core-planet', gravity_mass: 0.2, radius: 1.5, + x: 56, y: 0, vx: 0, vy: 0 }, + { id: 'outer-star', anchor_role: 'community', community_id: 'outer', + system_anchor_id: 'outer-star', gravity_mass: 8, radius: 5, + x: 120, y: 18, vx: 0, vy: 0 }, + { id: 'outer-planet', community_id: 'outer', system_anchor_id: 'outer-star', + gravity_mass: 1, radius: 2.5, x: 138, y: 18, vx: 0, vy: 0 }, + ]; + const links = [ + { source: 'black-hole', target: 'core-star', relation: 'orbits' }, + { source: 'core-star', target: 'core-planet', relation: 'orbits' }, + { source: 'core-planet', target: 'core-moon', relation: 'orbits' }, + { source: 'outer-star', target: 'outer-planet', relation: 'orbits' }, + ]; + return { nodes, links }; + }; + const delta = (next, previous) => Math.atan2(Math.sin(next - previous), + Math.cos(next - previous)); + const run = kinematic => { + const { nodes, links } = make(); + const options = { + layoutSeed: 501, gravity: 48, softening: 32, centralSoftening: 48, + localSoftening: 40, orbitalSpeed: 48, blackHoleMass: 1, + gravitationalConstant: 1, localGravitationalConstant: 1, + timestep: 0.032, velocityDecay: 0.0001, speedLimit: 48, + includeMutualSystems: true, mutualSystemGravityFraction: 0.12, + mutualSystemSoftening: 80, includeRelations: false, + includeOrbitalSeparation: false, includeSystemPacking: false, + includeBlackHoleExclusion: true, blackHoleExclusionPadding: 2.5, + includeFarFieldConfinement: true, farFieldEnvelopeScale: 1.75, + farFieldMinimumRadius: 96, farFieldSoftFraction: 0.82, + localRelativeSpeedLimit: 48, wallClockSeconds: 1 / 30, + includeCollisions: false, + }; + I.seedGalaxyOrbits(nodes, 501, 48, 32, false, options); + I.seedGalaxySystemOrbits(nodes, 501, 48, 40, false, options); + const groups = [...I.galaxyOrbitGroups(nodes).entries()] + .map(([id, group]) => [id, group.nodes.map(node => node.id)]); + const blackHole = nodes[0], coreStar = nodes[1], corePlanet = nodes[2]; + const coreMoon = nodes[3]; + const outerStar = nodes[4], outerPlanet = nodes[5]; + const globalNodes = [coreStar, corePlanet, coreMoon, outerStar, outerPlanet]; + const localPairs = [[corePlanet, coreStar], [coreMoon, corePlanet], + [outerPlanet, outerStar]]; + const globalPrevious = new Map(globalNodes.map(node => [node.id, + Math.atan2(node.y - blackHole.y, node.x - blackHole.x)])); + const localPrevious = new Map(localPairs.map(([node, star]) => [node.id, + Math.atan2(node.y - star.y, node.x - star.x)])); + const globalTravel = new Map(globalNodes.map(node => [node.id, 0])); + const localTravel = new Map(localPairs.map(([node]) => [node.id, 0])); + const step = () => kinematic + ? I.advanceGalaxyKinematicOrbits(nodes, options) + : I.integrateGalaxyLeapfrog(nodes, links, [], options); + for (let index = 0; index < 240; index++) { + step(); + globalNodes.forEach(node => { + const angle = Math.atan2(node.y - blackHole.y, node.x - blackHole.x); + globalTravel.set(node.id, globalTravel.get(node.id) + + delta(angle, globalPrevious.get(node.id))); + globalPrevious.set(node.id, angle); + }); + localPairs.forEach(([node, star]) => { + const angle = Math.atan2(node.y - star.y, node.x - star.x); + localTravel.set(node.id, localTravel.get(node.id) + + delta(angle, localPrevious.get(node.id))); + localPrevious.set(node.id, angle); + }); + } + return { groups, global: [...globalTravel.values()], local: [...localTravel.values()], + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)) }; + }; + emit({ live: run(false), kinematic: run(true) }); + """ + ) + for mode in ("live", "kinematic"): + result = report[mode] + assert report[mode]["finite"] is True + assert abs(min(result["global"], key=abs)) > 0.01, result + assert abs(min(result["local"], key=abs)) > 0.01, result + core_group = next(group for group in report["kinematic"]["groups"] if group[0] == "black-hole") + assert set(core_group[1]) == {"black-hole", "core-star", "core-planet", "core-moon"} + + +@requires_node +def test_reseeding_a_live_black_hole_lane_does_not_rewind_its_phase() -> None: + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', anchor_role: 'global', community_id: 'core', + system_anchor_id: 'black-hole', gravity_mass: 64, radius: 9, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'child', community_id: 'child', system_anchor_id: 'black-hole', + gravity_mass: 3, radius: 3, x: 120, y: 0, vx: 0, vy: 0 }, + ]; + const options = { gravity: 48, softening: 32, centralSoftening: 40, + localSoftening: 40, layoutSeed: 77, orbitalSpeed: 48, + timestep: 1 / 30, includeSystemPacking: false }; + I.seedGalaxyOrbits(nodes, 77, 48, 32, false, options); + for (let step = 0; step < 60; step++) I.advanceGalaxyKinematicOrbits(nodes, options); + const before = [nodes[1].x, nodes[1].y, nodes[1].__galaxyCoreLaneAngle]; + I.seedGalaxyOrbits(nodes, 77, 48, 32, false, options); + const after = [nodes[1].x, nodes[1].y, nodes[1].__galaxyCoreLaneAngle]; + emit({ before, after }); + """ + ) + assert report["after"] == pytest.approx(report["before"], abs=1e-12) + + +@requires_node +def test_tagged_local_orbit_is_repaired_when_a_render_lifecycle_zeroes_its_phase() -> None: + """An orbit-parent tag is provenance, never a permanent exemption from repair. + + The failure mode is a reused/statically-painted node whose velocity has been reset to the + star frame while its non-enumerable one-shot tag remains. Returning to Galaxy must detect + that zero relative tangent and restore the local orbit without reseeding a healthy phase. + """ + report = _run_node( + """ + const nodes = [ + { id: 'black-hole', community_id: 'core', anchor_role: 'global', + system_anchor_id: 'black-hole', orbit_tier: 0, gravity_mass: 48, radius: 9, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'star', community_id: 'solar', anchor_role: 'community', + system_anchor_id: 'star', orbit_tier: 0, gravity_mass: 10, radius: 5, + x: 120, y: 20, vx: 0, vy: 0 }, + { id: 'planet', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 1, + gravity_mass: 1, radius: 2.5, x: 151, y: 20, vx: 0, vy: 0 }, + ]; + const local = () => { + const star = nodes[1], planet = nodes[2], dx = planet.x - star.x, + dy = planet.y - star.y, dvx = planet.vx - star.vx, dvy = planet.vy - star.vy; + return { tangent: dx * dvy - dy * dvx, relativeSpeed: Math.hypot(dvx, dvy), + tag: planet.__galaxyOrbitAnchorId || null }; + }; + I.seedGalaxyOrbits(nodes, 9109, 48, 32, false); + I.seedGalaxySystemOrbits(nodes, 9109, 48, 48, false); + const healthy = local(); + // Emulate a legacy/static lifecycle that has retained object identity and its hidden + // parent tag but cleared the relative phase before re-entering Galaxy. + nodes[2].vx = nodes[1].vx; nodes[2].vy = nodes[1].vy; + const stalled = local(); + I.seedGalaxyOrbits(nodes, 9109, 48, 32, false); + I.seedGalaxySystemOrbits(nodes, 9109, 48, 48, false); + const repaired = local(); + emit({ healthy, stalled, repaired, finite: nodes.every(node => + [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); + """ + ) + assert report["finite"] is True + assert report["healthy"]["tag"] == "star" + assert report["healthy"]["relativeSpeed"] > 0.05 + assert report["stalled"]["tag"] == "star" + assert report["stalled"]["relativeSpeed"] == pytest.approx(0, abs=1e-12) + assert report["repaired"]["tag"] == "star" + assert report["repaired"]["relativeSpeed"] > 0.05 + assert abs(report["repaired"]["tangent"]) > 1e-5 + + +@requires_node +def test_explicit_star_is_the_inert_local_carrier_while_dense_planets_sweep() -> None: + """A named community star never absorbs local gravity or contact recoil. + + The star is allowed to move as a whole around the black hole. What must *not* happen is + a planet-only force, surface correction, or dense planet/planet separation translating or + accelerating that star in its own local frame. The oversized kinematic path has the same + rule: its cached black-hole carrier is the star itself, while every satellite advances a + separately visible local angle. + """ + report = _run_node( + """ + const localNodes = [ + { id: 'star', community_id: 'solar', anchor_role: 'community', + system_anchor_id: 'star', orbit_tier: 0, gravity_mass: 12, radius: 5, + x: 120, y: -32, vx: 2.5, vy: -1.25 }, + // The first body begins inside the painted stellar edge; the latter two overlap one + // another. This exercises gravity, star-surface projection, and radius-preserving + // dense pressure in one deliberately hostile local frame. + { id: 'near', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 1, + gravity_mass: 1, radius: 3, x: 124, y: -32, vx: 2.5, vy: -1.25 }, + { id: 'crowded-a', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 2, + gravity_mass: 1, radius: 2.5, x: 145, y: -32, vx: 2.5, vy: -1.25 }, + { id: 'crowded-b', community_id: 'solar', system_anchor_id: 'star', orbit_tier: 3, + gravity_mass: 1.2, radius: 2.5, x: 145.4, y: -31.8, vx: 2.5, vy: -1.25 }, + ]; + const star = localNodes[0]; + const carrier = () => [star.x, star.y, star.vx, star.vy]; + const before = carrier(); + const gravity = I.applyGalaxySystemAnchorGravity(localNodes, { + gravity: 48, softening: 18, accelerationCap: 100, + repulsionPadding: 1.5, repulsionRange: 6, repulsionAcceleration: .12, + }); + const afterGravity = carrier(); + const exclusion = I.applyGalaxySystemAnchorExclusion(localNodes, { padding: 1.5 }); + const afterExclusion = carrier(); + const separation = I.applyGalaxyOrbitalSeparation(localNodes, { + padding: 3, strength: 1, maxCorrection: 8, maxVelocityCorrection: 12, + skipSystemAnchorPairs: true, preserveSystemRadii: true, + }); + const afterSeparation = carrier(); + + const nodes = [ + { id: 'bh', community_id: 'core', anchor_role: 'global', gravity_mass: 64, radius: 9, + x: 0, y: 0, vx: 0, vy: 0 }, + { id: 'kin-star', community_id: 'kin', anchor_role: 'community', + system_anchor_id: 'kin-star', orbit_tier: 0, gravity_mass: 12, radius: 5, + x: 154, y: 48, vx: 0, vy: 0 }, + ]; + for (let index = 0; index < 6; index++) { + const angle = index * Math.PI * 2 / 6 + .17; + const radius = 18 + index * 4; + nodes.push({ id: `planet-${index}`, community_id: 'kin', system_anchor_id: 'kin-star', + orbit_tier: index + 1, gravity_mass: 1 + index * .1, radius: 2.5, + x: 154 + Math.cos(angle) * radius, y: 48 + Math.sin(angle) * radius, + vx: 0, vy: 0 }); + } + const bh = nodes[0], kinStar = nodes[1]; + const planet = nodes[2]; + const delta = (next, previous) => Math.atan2(Math.sin(next - previous), + Math.cos(next - previous)); + let previousLocal = Math.atan2(planet.y - kinStar.y, planet.x - kinStar.x); + let previousGlobal = Math.atan2(kinStar.y - bh.y, kinStar.x - bh.x); + let localTravel = 0, globalTravel = 0, maximumCarrierError = 0, maximumVelocityError = 0; + for (let step = 0; step < 180; step++) { + I.advanceGalaxyKinematicOrbits(nodes, { + layoutSeed: 451, gravity: 48, softening: 32, centralSoftening: 40, + localSoftening: 40, timestep: 1 / 30, + }); + const orbit = kinStar.__galaxyKinematicGlobalOrbit; + const expectedX = bh.x + Math.cos(orbit.angle) * orbit.radius; + const expectedY = bh.y + Math.sin(orbit.angle) * orbit.radius; + maximumCarrierError = Math.max(maximumCarrierError, + Math.hypot(kinStar.x - expectedX, kinStar.y - expectedY)); + // Tangential direction is exact even though its magnitude is implementation-owned. + maximumVelocityError = Math.max(maximumVelocityError, + Math.abs((kinStar.x - bh.x) * kinStar.vx + (kinStar.y - bh.y) * kinStar.vy)); + const nextLocal = Math.atan2(planet.y - kinStar.y, planet.x - kinStar.x); + const nextGlobal = Math.atan2(kinStar.y - bh.y, kinStar.x - bh.x); + localTravel += delta(nextLocal, previousLocal); + globalTravel += delta(nextGlobal, previousGlobal); + previousLocal = nextLocal; previousGlobal = nextGlobal; + } + emit({ before, afterGravity, afterExclusion, afterSeparation, gravity, exclusion, + separation, localTravel, globalTravel, maximumCarrierError, maximumVelocityError, + localRadius: Math.hypot(planet.x - kinStar.x, planet.y - kinStar.y), + finite: nodes.concat(localNodes).every(node => [node.x, node.y, node.vx, node.vy] + .every(Number.isFinite)), + }); + """ + ) + assert report["finite"] is True + # Local gravity, a penetrating planet, and a dense planet/planet correction are all + # one-sided about the explicit star. Its black-hole carrier is not a local momentum sink. + assert report["afterGravity"] == pytest.approx(report["before"], abs=1e-12) + assert report["afterExclusion"] == pytest.approx(report["before"], abs=1e-12) + assert report["afterSeparation"] == pytest.approx(report["before"], abs=1e-12) + assert report["gravity"]["satellites"] == 3 + assert report["exclusion"]["contacts"] > 0 + assert report["separation"]["radialPreservedContacts"] > 0 + # In the Complete-view kinematic clock the star follows its own BH carrier exactly, while + # the planet has a materially faster, independently visible star-relative orbit. + assert report["maximumCarrierError"] < 1e-9 + assert report["maximumVelocityError"] < 1e-7 + assert abs(report["globalTravel"]) > 0.1 + assert abs(report["localTravel"]) > 0.2 + assert report["localRadius"] > 8 + + +@requires_node +def test_future_singleton_waits_for_its_moving_star_before_receiving_one_local_seed() -> None: + """A singleton must not consume its orbit seed before its dominant star is revealed. + + This is the lifecycle ordering that previously left an initially unlinked/revealed member + frozen: the object survived the renderer transition, but no longer qualified for a seed once + its star arrived. The repair must be one-shot in the star's moving frame, then remain + idempotent on the next ordinary render. The named star is the local inertial carrier, so + admitting this planet must never recoil it. + """ + report = _run_node( + """ + const future = { id: 'future-planet', community_id: 'future', gravity_mass: 1, + radius: 2.5, x: 164, y: 53, vx: 3, vy: -2 }; + const nodes = [ + { id: 'black-hole', community_id: 'core', anchor_role: 'global', + system_anchor_id: 'black-hole', orbit_tier: 0, gravity_mass: 48, radius: 9, + x: 0, y: 0, vx: 0, vy: 0 }, future, + ]; + const momentum = members => ['vx', 'vy'].map(axis => members.reduce((sum, node) => + sum + node.gravity_mass * node[axis], 0)); + I.seedGalaxyOrbits(nodes, 31011, 48, 32, false); + const isolated = { + seeded: !!future.__galaxyOrbitSeeded, + parent: future.__galaxyOrbitAnchorId || null, + velocity: [future.vx, future.vy], + }; + // The scene is already moving when the star arrives; this must be seeded relative to + // the live star rather than the origin or a stale zero-velocity coordinate. + const star = { id: 'future-star', community_id: 'future', anchor_role: 'community', + system_anchor_id: 'future-star', orbit_tier: 0, gravity_mass: 10, radius: 5, + x: 140, y: 35, vx: 2, vy: -1 }; + nodes.push(star); + const starBefore = [star.x, star.y, star.vx, star.vy]; + const before = momentum([star, future]); + I.seedGalaxyOrbits(nodes, 31011, 48, 32, false); + const local = () => { + const dx = future.x - star.x, dy = future.y - star.y; + const dvx = future.vx - star.vx, dvy = future.vy - star.vy; + return { parent: future.__galaxyOrbitAnchorId || null, + seeded: !!future.__galaxyOrbitSeeded, tangent: dx * dvy - dy * dvx, + radial: dx * dvx + dy * dvy, relativeSpeed: Math.hypot(dvx, dvy), + phase: [future.vx, future.vy, star.vx, star.vy] }; + }; + const seeded = local(), after = momentum([star, future]); + I.seedGalaxyOrbits(nodes, 31011, 48, 32, false); + const repeated = local(), final = momentum([star, future]); + emit({ isolated, before, seeded, after, repeated, final, starBefore, + finite: nodes.every(node => [node.x, node.y, node.vx, node.vy].every(Number.isFinite)) }); + """ + ) + assert report["finite"] is True + assert report["isolated"]["seeded"] is False + assert report["isolated"]["parent"] is None + assert report["seeded"]["parent"] == "future-star" + assert report["seeded"]["seeded"] is True + assert report["seeded"]["relativeSpeed"] > 0.05 + assert abs(report["seeded"]["tangent"]) > 1e-5 + assert abs(report["seeded"]["radial"]) < 1e-8 + # Local admission changes the planet's velocity but does not apply an equal-and-opposite + # kick to the explicit star. The whole system can later acquire one BH-frame translation. + assert report["seeded"]["phase"][2:] == pytest.approx(report["starBefore"][2:], abs=1e-12) + assert report["after"] != pytest.approx(report["before"], abs=1e-10) + assert report["repeated"]["phase"] == pytest.approx(report["seeded"]["phase"], abs=1e-12) + assert report["final"] == pytest.approx(report["after"], abs=1e-12) + + +@requires_node +def test_galaxy_is_default_and_consumes_the_complete_scene_contract() -> None: + report = _run_engine( + """ + const linkForce = { + id(value) { this.idValue = value; return this; }, + distance(value) { this.distanceValue = value; return this; }, + strength(value) { this.strengthValue = value; return this; }, + }; + globalThis.d3 = { + forceLink: () => linkForce, + forceCollide: () => ({ iterations() { return this; } }), + }; + const api = G.create(el, { reducedMotion: () => true }); + api.setData({ + meta: { layout_seed: 73, scene_hash: 'scene' }, + communities: [{ id: 'left' }, { id: 'right' }], + community_bridges: [{ + id: 'bridge', source_community: 'left', target_community: 'right', + physics_strength: 0.8, + }], + nodes: [ + { id: 'a', x: -20, y: 0, gravity_mass: 1, visual_radius: 3, community_id: 'left' }, + { id: 'b', x: 0, y: 0, gravity_mass: 4, visual_radius: 7, community_id: 'left' }, + { id: 'c', x: 30, y: 0, gravity_mass: 2, visual_radius: 5, community_id: 'right' }, + ], + edges: [ + { id: 'internal', source: 'a', target: 'b', rest_length: 20, spring_strength: 0.16 }, + { id: 'cross', source: 'b', target: 'c', rest_length: 30, spring_strength: 0.2 }, + { id: 'ghost', source: 'a', target: 'c', rest_length: 10, spring_strength: 0.2, ghost: true, physics_strength: 0 }, + ], + }); + const exported = api.exportData(); + emit({ + mode: api.state().settings.mode, + settings: { + repel: api.state().settings.repel, + link: api.state().settings.link, + gravity: api.state().settings.gravity, + }, + sizeBy: api.state().sizeBy, + forces: { + charge: store.d3Forces.charge === null, + link: store.d3Forces.link === null, + x: store.d3Forces.x === null, + y: store.d3Forces.y === null, + galaxy: store.d3Forces.galaxy === null, + center: store.d3Forces.galaxyCenter === null, + relations: store.d3Forces.galaxyRelations === null, + defaultCenter: store.d3Forces.center === null, + bridges: store.d3Forces.communityBridges === null, + }, + radii: Object.fromEntries(store.graphData.nodes.map(node => [node.id, node.radius])), + d3Budget: [store.cooldownTime, store.cooldownTicks, store.warmupTicks], + diagnostics: api.physicsDiagnostics(), + exported: { + seed: exported.meta.layout_seed, + communities: exported.communities.length, + bridges: exported.community_bridges.length, + }, + positions: store.graphData.nodes.map(node => [node.x, node.y]), + }); + """ + ) + assert report["mode"] == "galaxy" + assert report["settings"] == {"repel": 100, "link": 8, "gravity": 96} + assert report["sizeBy"] == "mass" + assert report["forces"] == { + "charge": True, + "link": True, + "x": True, + "y": True, + "galaxy": True, + "center": True, + "relations": True, + "defaultCenter": True, + "bridges": True, + } + def radius(mass: float) -> float: + return 1.2 * (1.5 + 2.0 * mass ** (2.0 / 3.0)) + assert report["radii"]["a"] == pytest.approx(radius(1)) + assert report["radii"]["b"] == pytest.approx(radius(4)) + assert report["radii"]["c"] == pytest.approx(radius(2)) + assert report["d3Budget"] == [0, 0, 0] + assert report["diagnostics"]["timestep"] == pytest.approx(0.032) + assert report["diagnostics"]["velocityDecay"] == pytest.approx(0.0005) + assert report["diagnostics"]["gravitySetting"] == 96 + assert report["diagnostics"]["blackHoleGravity"] == pytest.approx(1615.3424319876754) + assert report["diagnostics"]["localGravity"] == pytest.approx(240) + assert report["diagnostics"]["linkSetting"] == 8 + assert report["diagnostics"]["relationOrbitScale"] == pytest.approx(0.25) + assert report["diagnostics"]["orbitalSeparationSetting"] == 100 + assert report["diagnostics"]["orbitalSeparationPadding"] == pytest.approx(15) + assert report["diagnostics"]["orbitalSeparationStrength"] == pytest.approx(1) + assert report["diagnostics"]["crossSystemRepulsionStrength"] == 0 + assert report["diagnostics"]["systemOrbitSeedSpeedLimit"] == pytest.approx(23.4) + assert report["diagnostics"]["systemAnchorExclusionPadding"] == pytest.approx(1.5) + assert report["diagnostics"]["systemAnchorRepulsionRange"] == pytest.approx(6) + assert report["diagnostics"]["systemAnchorRepulsionAcceleration"] == pytest.approx(0.12) + assert report["diagnostics"]["reducedMotion"] is True + assert report["exported"] == {"seed": 73, "communities": 2, "bridges": 1} + assert report["positions"] == [[-20, 0], [0, 0], [30, 0]] + + +@requires_node +def test_collapsed_galaxy_systems_sum_live_mass_and_use_square_root_radius() -> None: + report = _run_engine( + """ + const api = G.create(el, { reducedMotion: () => true }); + api.setData({ + communities: [{ id: 'left' }, { id: 'right' }], + nodes: [ + { id: 'a', x: 0, y: 0, gravity_mass: 4, visual_radius: 5, community_id: 'left' }, + { id: 'history', x: 5, y: 0, gravity_mass: 0, visual_radius: 9, community_id: 'left', ghost: true }, + { id: 'b', x: 30, y: 0, gravity_mass: 9, visual_radius: 8, community_id: 'right' }, + { id: 'old', x: 60, y: 0, gravity_mass: 0, visual_radius: 6, community_id: 'archive', ghost: true }, + ], + edges: [ + { source: 'a', target: 'b' }, + { source: 'a', target: 'history', ghost: true, physics_strength: 0 }, + ], + }); + api.setScope({ showUnlinked: true, minDegree: 0 }); + api.setCollapse(true); + emit(store.graphData.nodes.map(node => ({ + id: node.id, members: node.members, mass: node.gravity_mass, + visualRadius: node.visual_radius, radius: node.radius, ghost: node.ghost, + })).sort((a, b) => a.id.localeCompare(b.id))); + """ + ) + archive, left, right = report + def radius(mass: float) -> float: + return 1.2 * (1.5 + 2.0 * mass ** (2.0 / 3.0)) + assert archive == { + "id": "cluster-archive", "members": 1, "mass": 0, + "visualRadius": 0, "radius": 2.5, "ghost": True, + } + assert {key: left[key] for key in ("id", "members", "mass", "ghost")} == { + "id": "cluster-left", "members": 2, "mass": 4, "ghost": False, + } + assert left["visualRadius"] == pytest.approx(radius(4)) + assert left["radius"] == pytest.approx(radius(4)) + assert {key: right[key] for key in ("id", "members", "mass", "ghost")} == { + "id": "cluster-right", "members": 1, "mass": 9, "ghost": False, + } + assert right["visualRadius"] == pytest.approx(radius(9)) + assert right["radius"] == pytest.approx(radius(9)) + + +@requires_node +def test_oversized_galaxy_pins_deterministic_scene_positions_without_live_forces() -> None: + report = _run_engine( + """ + const api = G.create(el, { reducedMotion: () => false }); + const scene = () => { + const data = chain(1500); + data.meta = { layout_seed: 91 }; + data.nodes.forEach((node, index) => { + node.x = index - 300; node.y = (index % 7) * 3; + }); + return data; + }; + api.setData(scene()); + const first = store.graphData.nodes.map(node => [node.x, node.y, node.fx, node.fy]); + api.setData(scene()); + const nodes = store.graphData.nodes; + const repeated = nodes.map(node => [node.x, node.y, node.fx, node.fy]); + const diagnostics = api.physicsDiagnostics(); + emit({ + mode: api.state().settings.mode, + total: nodes.length, + pinned: nodes.filter(node => Number.isFinite(node.fx) && Number.isFinite(node.fy)).length, + finite: nodes.every(node => Number.isFinite(node.x) && Number.isFinite(node.y)), + same: nodes.every(node => node.fx === node.x && node.fy === node.y), + deterministic: first.every((position, index) => position.every((value, axis) => + value === repeated[index][axis])), + endpoints: [[nodes[0].x, nodes[0].y], [nodes.at(-1).x, nodes.at(-1).y]], + systemAnchorExclusion: diagnostics.systemAnchorExclusion, + cooldown: [store.cooldownTime, store.cooldownTicks, store.warmupTicks], + forces: ['galaxy', 'galaxyCenter', 'galaxyRelations', 'communityBridges', + 'charge', 'link'].map(name => store.d3Forces[name] === null), + }); + """ + ) + assert report["mode"] == "galaxy" + assert report["total"] == report["pinned"] == 1501 + assert report["finite"] is report["same"] is report["deterministic"] is True + # The selected community star may project its nearest satellite before a static paint; + # the far endpoint is unaffected and proves positions are otherwise preserved. + assert report["endpoints"][1] == [1200, 6] + assert report["systemAnchorExclusion"]["minimumClearance"] >= -1e-9 + assert report["cooldown"] == [0, 0, 0] + assert report["forces"] == [True, True, True, True, True, True] + + +@requires_node +def test_galaxy_reheat_unfreeze_and_drag_never_reseed_orbital_velocity() -> None: + report = _run_engine( + """ + const api = G.create(el, { reducedMotion: () => false }); + api.setData({ + meta: { layout_seed: 42 }, + nodes: [ + { id: 'sun', x: 0, y: 0, gravity_mass: 8, visual_radius: 8, community_id: 's' }, + { id: 'planet', x: 20, y: 0, gravity_mass: 1, visual_radius: 3, community_id: 's' }, + ], + edges: [{ source: 'sun', target: 'planet', rest_length: 20, spring_strength: 0.1 }], + }); + const planet = store.graphData.nodes.find(node => node.id === 'planet'); + const initial = [planet.vx, planet.vy]; + api.reheat(); + const reheated = [planet.vx, planet.vy]; + api.freeze(true); + api.freeze(false); + const unfrozen = [planet.vx, planet.vy]; + store.onNodeDragStart(planet); + store.onNodeDragEnd(planet); + const dragged = [planet.vx, planet.vy]; + + const full = G.create(el, { reducedMotion: () => true }); + full.setRenderMode('full'); + full.setData(chain(400)); + emit({ initial, reheated, unfrozen, dragged, + d3Calls: { + alpha: calls.d3AlphaTarget || 0, + resets: invocations.resetCountdown || 0, + reheats: invocations.d3ReheatSimulation || 0, + }, + }); + """ + ) + assert abs(report["initial"][1]) > 0 + assert report["reheated"] == pytest.approx(report["initial"]) + assert report["unfrozen"] == pytest.approx(report["initial"]) + assert report["dragged"] == pytest.approx(report["initial"]) + assert report["d3Calls"] == {"alpha": 0, "resets": 0, "reheats": 0} + + +@requires_node +def test_live_galaxy_fills_only_missing_compatibility_coordinates_once() -> None: + report = _run_engine( + """ + const scene = { + meta: { layout_seed: 321 }, + nodes: [ + { id: 'server', x: 120, y: -30, gravity_mass: 8, community_id: 'system' }, + { id: 'missing-a', gravity_mass: 2, community_id: 'system' }, + { id: 'missing-b', gravity_mass: 1, community_id: 'other' }, + ], + edges: [ + { source: 'server', target: 'missing-a' }, + { source: 'missing-a', target: 'missing-b' }, + ], + }; + const snapshot = nodes => nodes.map(node => [node.id, node.x, node.y, node.vx, node.vy]); + const api = G.create(el, { reducedMotion: () => false }); + api.setData(scene); + const initial = snapshot(store.graphData.nodes); + api.reheat(); + api.freeze(true); + api.freeze(false); + const afterExplicitActions = snapshot(store.graphData.nodes); + + const second = G.create(el, { reducedMotion: () => false }); + second.setData(scene); + emit({ + initial, + afterExplicitActions, + repeated: snapshot(store.graphData.nodes), + allFinite: initial.every(item => item.slice(1).every(Number.isFinite)), + d3Budget: [store.cooldownTime, store.cooldownTicks, store.warmupTicks], + d3Wakes: { + alpha: calls.d3AlphaTarget || 0, + resets: invocations.resetCountdown || 0, + reheats: invocations.d3ReheatSimulation || 0, + }, + }); + """ + ) + assert report["allFinite"] is True + assert report["initial"][0][1:3] == [120, -30] + for initial, after, repeated in zip( + report["initial"], report["afterExplicitActions"], report["repeated"] + ): + assert initial[0] == after[0] == repeated[0] + assert initial[1:] == pytest.approx(after[1:]) + assert initial[1:] == pytest.approx(repeated[1:]) + assert report["d3Budget"] == [0, 0, 0] + assert report["d3Wakes"] == {"alpha": 0, "resets": 0, "reheats": 0} + + +@requires_node +def test_galaxy_phase_is_isolated_from_legacy_layouts_and_restores_server_seed() -> None: + report = _run_engine( + """ + const scene = { + meta: { layout_seed: 17 }, + nodes: [ + { id: 'sun', x: -40, y: 3, gravity_mass: 8, community_id: 's' }, + { id: 'planet', x: 25, y: -4, gravity_mass: 1, community_id: 's' }, + ], + edges: [{ source: 'sun', target: 'planet' }], + }; + + const first = G.create(el, { reducedMotion: () => false }); + first.setPreset('compact'); + first.setData(scene); + const legacyDiscardedServer = store.graphData.nodes.map(node => node.x == null); + first.setPreset('galaxy'); + const firstGalaxy = store.graphData.nodes.map(node => [node.id, node.x, node.y]); + + const api = G.create(el, { reducedMotion: () => false }); + api.setData(scene); + const byId = Object.fromEntries(store.graphData.nodes.map(node => [node.id, node])); + byId.sun.x = -22; byId.sun.y = 11; byId.sun.vx = 1.25; byId.sun.vy = -0.5; + byId.planet.x = 31; byId.planet.y = 9; byId.planet.vx = -2; byId.planet.vy = 0.75; + api.setPreset('compact'); + store.graphData.nodes.forEach((node, index) => { + node.x = 700 + index * 100; node.y = -900; node.vx = 40; node.vy = -40; + }); + api.setPreset('galaxy'); + emit({ + legacyDiscardedServer, + firstGalaxy, + restored: store.graphData.nodes.map(node => [ + node.id, node.x, node.y, node.vx, node.vy, + ]), + d3Budget: [store.cooldownTime, store.cooldownTicks, store.warmupTicks], + }); + """ + ) + assert report["legacyDiscardedServer"] == [True, True] + assert report["firstGalaxy"] == [["sun", -40, 3], ["planet", 25, -4]] + assert report["restored"] == [ + ["sun", -22, 11, 1.25, -0.5], + ["planet", 31, 9, -2, 0.75], + ] + assert report["d3Budget"] == [0, 0, 0] + + +@requires_node +def test_auto_fit_cap_does_not_limit_manual_graph_inspection() -> None: + """The auto-fit guard must not become a global force-graph zoom limit.""" + report = _run_engine( + """ + G.create(el, {}); + emit({ maxZoom: store.maxZoom === undefined ? null : store.maxZoom }); + """ + ) + assert report["maxZoom"] is None + source = ASSET.read_text(encoding="utf-8") + assert "function autoFit(" in source + assert "api.fit = () => { if (!destroyed) fg.zoomToFit" in source + + +def test_dashboard_falls_back_to_the_classic_renderer_when_the_engine_throws() -> None: + source = DASHBOARD.read_text(encoding="utf-8") + # The opt-in flag must be latched off after a failure, and the render path must catch. + assert "GRAPH_ENGINE_FAILED" in source + assert "if(GRAPH_ENGINE_FAILED)return false" in source + assert "graphEngineFallback(error)" in source + engine_path = source[source.index("function graphRenderEngine"):] + engine_path = engine_path[: engine_path.index("\nfunction ")] + assert "try{" in engine_path and "}catch(error){" in engine_path + + +# ── XSS: untrusted entity labels reaching force-graph ─────────────────────────────── + + +def test_force_graph_tooltip_is_still_an_inner_html_sink() -> None: + """Guards the *reason* the engine sets its own label accessors. + + force-graph defaults ``nodeLabel``/``linkLabel`` to the accessor ``"name"`` and renders a + string label through ``innerHTML``. Node names here are entity labels extracted from + ingested memories, i.e. untrusted. If a vendor bump ever changes this, revisit whether + the explicit escaped accessors below are still the right shape. + """ + vendor = VENDOR.read_text(encoding="utf-8", errors="ignore") + assert 'nodeLabel:{default:"name"' in vendor + assert 'linkLabel:{default:"name"' in vendor + + +def test_engine_never_relies_on_the_default_label_accessor() -> None: + source = ASSET.read_text(encoding="utf-8") + assert ".nodeLabel(node => esc(nodeName(node)))" in source + assert ".linkLabel(" in source + assert "eval(" not in source + # The engine paints to canvas; the only markup sink it may use is clearing its own + # container on teardown. Anything else would be a route for an unescaped entity label. + writes = re.findall(r"\w+\.(?:inner|outer)HTML\s*=\s*[^;]+", source) + assert writes == ["el.innerHTML = ''"], writes + assert not re.search(r"insertAdjacentHTML|document\.write|createContextualFragment", source) + + +@requires_node +@pytest.mark.parametrize( + "payload", + [ + "", + "", + "\" onmouseover=\"alert(1)", + "", + ], +) +def test_entity_labels_are_escaped_before_they_can_reach_a_dom_sink(payload: str) -> None: + report = _run_node( + "emit({ escaped: I.esc(%s), named: I.nodeName({ label: %s }) });" + % (json.dumps(payload), json.dumps(payload)) + ) + escaped = report["escaped"] + assert "<" not in escaped and ">" not in escaped + assert '"' not in escaped and "'" not in escaped + assert "<" in escaped or """ in escaped + # nodeName is the raw value; escaping is the accessor's job, so this documents the split. + assert report["named"] == payload + + +# ── payload compatibility with the shipped /graph endpoint ────────────────────────── + + +@requires_node +def test_engine_accepts_both_the_api_and_renderer_link_shapes() -> None: + report = _run_node( + """ + const api = { from: 'a', to: 'b' }; + const renderer = { source: { id: 'c' }, target: 'd' }; + emit({ + apiSource: I.linkEndpoint(api, 'source'), + apiTarget: I.linkEndpoint(api, 'target'), + rendererSource: I.linkEndpoint(renderer, 'source'), + rendererTarget: I.linkEndpoint(renderer, 'target'), + label: I.nodeName({ label: 'Ada' }), + name: I.nodeName({ name: 'Grace' }), + fallback: I.nodeName({ id: 'ent_1' }), + }); + """ + ) + assert report["apiSource"] == "a" and report["apiTarget"] == "b" + assert report["rendererSource"] == "c" and report["rendererTarget"] == "d" + assert report["label"] == "Ada" + assert report["name"] == "Grace" + assert report["fallback"] == "ent_1" + + +@requires_node +def test_valid_time_accepts_seconds_milliseconds_and_iso_strings() -> None: + report = _run_node( + """ + emit({ + seconds: I.asOfValue(1700000000), + millis: I.asOfValue(1700000000000), + iso: I.asOfValue('2023-11-14T22:13:20Z'), + blank: I.asOfValue(''), + junk: I.asOfValue('not a date'), + }); + """ + ) + assert report["seconds"] == report["millis"] == 1700000000000 + assert report["iso"] == 1700000000000 + assert report["blank"] is None and report["junk"] is None + + +# ── client-side analysis: correctness and cost ────────────────────────────────────── + + +@requires_node +def test_bridge_detection_matches_a_known_graph() -> None: + """A triangle has no bridges; the tail hanging off it is all bridges.""" + report = _run_node( + """ + const nodes = ['a', 'b', 'c', 'd', 'e'].map(id => ({ id })); + const links = [['a','b'], ['b','c'], ['c','a'], ['c','d'], ['d','e']] + .map(([source, target]) => ({ source, target })); + const adj = I.communities(nodes, links); + I.findBridges(nodes, links, adj); + emit({ + bridges: links.filter(l => l.bridge).map(l => l.source + '-' + l.target), + communities: new Set(nodes.map(n => n.community)).size, + }); + """ + ) + assert report["bridges"] == ["c-d", "d-e"] + assert report["communities"] == 1 + + +@requires_node +def test_parallel_edges_are_not_reported_as_bridges() -> None: + report = _run_node( + """ + const nodes = [{ id: 'a' }, { id: 'b' }]; + const links = [{ source: 'a', target: 'b' }, { source: 'a', target: 'b' }]; + const adj = I.communities(nodes, links); + I.findBridges(nodes, links, adj); + emit({ bridges: links.filter(l => l.bridge).length }); + """ + ) + assert report["bridges"] == 0 + + +@requires_node +def test_explorer_exports_its_visible_data_and_reports_bridge_metrics() -> None: + """Filtering and analysis controls must affect the user-facing export/readout, + rather than only changing paint on an otherwise stale payload.""" + report = _run_engine( + """ + const reports = []; + const api = G.create(el, { reducedMotion: () => true, onMetrics: value => reports.push(value) }); + api.setData({ + nodes: [ + { id: 'a', repo: 'engraphis' }, { id: 'b', repo: 'engraphis' }, + { id: 'c', repo: 'elsewhere' }, + ], + links: [ + { source: 'a', target: 'b', valid_from: 100, valid_to: 200 }, + { source: 'b', target: 'c', valid_from: 100 }, + ], + }); + api.setBridges(true); + api.setRepoFilter('engraphis'); + const filtered = api.exportData(); + api.focus('a'); + api.clearFocus(); + api.setRepoFilter(''); + api.setAsOf(250); + api.setGhosts(false); + const withoutGhosts = api.exportData(); + api.setGhosts(true); + const withGhosts = api.exportData(); + emit({ + bridges: reports[reports.length - 1].bridges, + filtered, state: api.state(), withoutGhosts, withGhosts, + }); + """ + ) + assert report["bridges"] == 2 + assert [node["id"] for node in report["filtered"]["nodes"]] == ["a", "b"] + assert [(link["source"], link["target"]) for link in report["filtered"]["links"]] == [ + ("a", "b") + ] + assert report["state"]["focusId"] is None and report["state"]["highlight"] is None + assert len(report["withoutGhosts"]["links"]) == 1 + assert len(report["withGhosts"]["links"]) == 2 + + +@requires_node +def test_disconnected_entities_are_labelled_as_separate_communities() -> None: + report = _run_node( + """ + const nodes = ['a', 'b', 'c', 'd'].map(id => ({ id })); + const links = [{ source: 'a', target: 'b' }, { source: 'c', target: 'd' }]; + const adj = I.communities(nodes, links); + emit({ groups: new Set(nodes.map(n => n.community)).size }); + """ + ) + assert report["groups"] == 2 + + +@requires_node +def test_graph_analysis_is_stack_safe_and_bounded_on_a_large_store() -> None: + """A long chain of entities is the worst case for both analyses. + + A recursive Tarjan overflows the call stack here, and exact Brandes betweenness is + O(V*E) — minutes of blocked main thread. Both are guarded, so this must finish well + inside the bound even on a slow machine. + """ + report = _run_node( + """ + const N = 40000; + const nodes = [], links = []; + for (let i = 0; i < N; i++) { + nodes.push({ id: 'n' + i }); + if (i) links.push({ source: 'n' + (i - 1), target: 'n' + i }); + } + const adj = I.communities(nodes, links); + const started = Date.now(); + I.findBridges(nodes, links, adj); + I.betweenness(nodes, adj); + const scores = nodes.map(n => n.betweenness); + emit({ + ms: Date.now() - started, + allBridges: links.every(l => l.bridge), + finite: scores.every(Number.isFinite), + peak: Math.max.apply(null, scores.slice(0, 1000).concat(scores.slice(-1000))), + }); + """ + ) + assert report["allBridges"] is True + assert report["finite"] is True + # Ends of a chain are never on a shortest path between others. + assert report["peak"] < 0.5 + assert report["ms"] < 30000, f"graph analysis took {report['ms']}ms on 40k entities" + + +@requires_node +def test_influence_relations_do_not_merge_two_topics_into_one_community() -> None: + """Community Islands must not fuse two topics over a single cross-topic relation. + + ``influences`` edges routinely span otherwise separate bodies of work. The classic + renderer keeps them drawn and traversable but builds its clustering adjacency without + them (``GCOMM_ADJ``); adding every link to one adjacency gives both topics the same + colour and the same force centre. + """ + report = _run_node( + """ + const nodes = ['a', 'b', 'c', 'd'].map(id => ({ id })); + const links = [ + { source: 'a', target: 'b', label: 'mentions' }, + { source: 'c', target: 'd', label: 'mentions' }, + { source: 'b', target: 'c', label: 'influences' }, + ]; + const adj = I.communities(nodes, links); + I.findBridges(nodes, links, adj); + emit({ + groups: new Set(nodes.map(n => n.community)).size, + merged: nodes[1].community === nodes[2].community, + neighbours: (adj.b || []).slice().sort(), + bridges: links.filter(l => l.bridge).length, + }); + """ + ) + assert report["groups"] == 2 + assert report["merged"] is False + # The relation itself stays in the traversal adjacency: hover neighbourhood, focus depth + # and bridge detection all still see it. Only the clustering ignores it. + assert report["neighbours"] == ["a", "c"] + assert report["bridges"] == 3 + + +@requires_node +def test_community_ids_are_ranked_by_size_so_the_legend_describes_the_right_nodes() -> None: + """Legend labels and canvas swatches must agree about which cluster is "Cluster 1". + + ``graphRenderLegend()`` sorts communities by size and calls the largest "Cluster 1", but + node colour indexes the palette by the community *id* (``commPal()[community % n]``). + Assigning ids in raw payload order therefore made the legend describe one component with + another's colour whenever a smaller component appeared first — which the payload order + alone decides. The classic ``graphComputeCommunities()`` sorts before assigning; so must + this. + """ + report = _run_node( + """ + // Payload order is deliberately worst-case: the singleton comes first, the largest + // component last, so raw iteration order and size order disagree completely. + const nodes = ['solo', 'm1', 'm2', 'a', 'b', 'c'].map(id => ({ id })); + const links = [ + { source: 'm1', target: 'm2' }, + { source: 'a', target: 'b' }, + { source: 'b', target: 'c' }, + ]; + I.communities(nodes, links); + const byId = {}; + nodes.forEach(n => { byId[n.id] = n.community; }); + emit({ byId, distinct: new Set(nodes.map(n => n.community)).size }); + """ + ) + assert report["distinct"] == 3 + # Largest component (3 nodes) owns palette slot 0, i.e. the legend's "Cluster 1". + assert report["byId"]["a"] == 0 + assert report["byId"]["b"] == 0 + assert report["byId"]["c"] == 0 + # Then the 2-node component, then the singleton — strictly by size, not by payload order. + assert report["byId"]["m1"] == 1 + assert report["byId"]["m2"] == 1 + assert report["byId"]["solo"] == 2 + + +@requires_node +def test_max_helper_survives_arrays_past_the_spread_limit() -> None: + """``Math.max(...array)`` throws RangeError long before a store is unrenderable.""" + report = _run_node("emit({ max: I.maxOf(new Array(400000).fill(7), 1) });") + assert report["max"] == 7 + + +@requires_node +def test_colour_helpers_handle_the_shorthand_hex_the_palettes_may_carry() -> None: + report = _run_node( + """ + emit({ + short: I.hexRgb('#abc'), + long: I.hexRgb('#8c83e8'), + empty: I.hexRgb(''), + light: I.contrastOn('#ffffff'), + dark: I.contrastOn('#000000'), + }); + """ + ) + assert report["short"] == [170, 187, 204] + assert report["long"] == [140, 131, 232] + assert report["empty"] == [140, 131, 232] + assert report["light"] == "#111827" + assert report["dark"] == "#f8fafc" + + +# ── render configuration: what the engine actually installs on force-graph ────────── + + +@requires_node +def test_flow_particles_are_capped_on_a_large_relation_set() -> None: + """Three animated particles per relation does not survive a real ``/graph`` response. + + force-graph advances every particle on every frame, so a few thousand relations is tens + of thousands of animated objects and an unusable canvas. The classic renderer refuses to + draw them past 800 links; the opt-in engine must use the same cutoff rather than trusting + that no store is big. + """ + report = _run_engine( + """ + const api = G.create(el, {}); + const particlesFor = link => store.linkDirectionalParticles(link || { layer: 'semantic' }); + api.setStyle('cyber'); + api.setSettings({ flow: true }); + api.setData(chain(40)); + const small = particlesFor(); + api.setData(chain(800)); + const atLimit = particlesFor(); + api.setData(chain(801)); + const overLimit = particlesFor(); + api.setData(chain(4000)); + emit({ small, atLimit, overLimit, realistic: particlesFor() * 4000, + particleWidth: store.linkDirectionalParticleWidth, + particleArrow: typeof store.linkDirectionalParticleCanvasObject === 'function' }); + """ + ) + assert report["small"] == 3 + assert report["atLimit"] == 3 + assert report["overLimit"] == 0 + # The number this guards: 4k relations x 3 particles was 12,000 animated objects a frame. + assert report["realistic"] == 0 + assert report["particleWidth"] == 1 + assert report["particleArrow"] is True + + +@requires_node +def test_unfreezing_reapplies_enabled_relation_flow_after_a_frozen_render() -> None: + """Freeze must not leave a still-enabled relation-flow switch visually inert.""" + + report = _run_engine( + """ + const api = G.create(el, {}); + const particles = () => store.linkDirectionalParticles({ layer: 'semantic' }); + api.setSettings({ flow: true }); + api.setData(chain(2)); + const live = particles(); + api.freeze(true); + api.setData(chain(3)); + const frozen = particles(); + api.freeze(false); + emit({ live, frozen, resumed: particles() }); + """ + ) + assert report == {"live": 3, "frozen": 0, "resumed": 3} + + +@requires_node +def test_a_dashboard_sync_that_turns_freeze_off_reheats_the_renderer() -> None: + """Classic redraws send the full settings object, so ``frozen:false`` must be actionable.""" + + report = _run_engine( + """ + const api = G.create(el, {}); + api.setPreset('compact'); + api.setData(chain(2)); + api.freeze(true); + const before = invocations.d3ReheatSimulation || 0; + api.setSettings({ frozen: false }); + emit({ + state: api.state().settings.frozen, + alpha: store.d3AlphaDecay, + reheats: (invocations.d3ReheatSimulation || 0) - before, + cooldown: store.cooldownTime, + }); + """ + ) + assert report == {"state": False, "alpha": 0.035, "reheats": 1, "cooldown": 2200} + + +@requires_node +def test_reduced_motion_keeps_auto_fit_instant_while_physics_stays_live() -> None: + """OS visual-motion preferences suppress camera animation, not layout physics.""" + + report = _run_engine( + """ + const timers = []; + globalThis.setTimeout = (callback, delay) => { timers.push(delay); callback(); return timers.length; }; + globalThis.clearTimeout = () => {}; + store.getGraphBbox = { x: [-10, 10], y: [-10, 10] }; + const api = G.create(el, { reducedMotion: () => true }); + api.setData(chain(2)); + emit({ timers, center: store.centerAt, zoom: store.zoom, + cooldown: [store.cooldownTime, store.cooldownTicks, store.warmupTicks], + reduced: api.physicsDiagnostics().reducedMotion, + }); + """ + ) + assert report["timers"] == [0] + assert report["center"][-1] == 0 + assert report["zoom"][-1] == 0 + assert report["cooldown"] == [0, 0, 0] + assert report["reduced"] is True + + +def test_legacy_flow_particles_use_small_directional_arrows() -> None: + """Classic and its static compatibility copy must not regress to round flow dots.""" + for path in (DASHBOARD, CLASSIC_DASHBOARD): + source = path.read_text(encoding="utf-8") + assert "linkDirectionalArrowLength(GPERF.dense?0:.625)" in source + assert ( + "linkDirectionalParticleWidth(.85).linkDirectionalParticleCanvasObject" + "(graphPaintFlowArrow)" in source + ) + + +#: A canvas 2D stand-in that counts the fills the galaxy starfield performs. The engine wraps +#: ``onRenderFramePre`` in a try/catch, so a stub too thin to survive the real paint would read +#: as "no stars drawn"; the small-graph leg of the test below is what proves it is thick enough. +CANVAS_STUB = """ +let fills = 0; +const ctx = { + globalAlpha: 1, globalCompositeOperation: '', fillStyle: '', strokeStyle: '', lineWidth: 1, + save() {}, restore() {}, beginPath() {}, arc() {}, ellipse() {}, stroke() {}, + fill() { fills += 1; }, + createRadialGradient() { return { addColorStop() {} }; }, +}; +""" + + +@requires_node +def test_galaxy_stops_animating_once_the_graph_is_large() -> None: + """A settled graph must fall off the CPU, and galaxy was the one style that never did. + + The starfield lives in ``onRenderFramePre``, which force-graph's change detection cannot + see, so the engine holds ``autoPauseRedraw(false)`` for it — repainting every node and link + every frame, forever, even after particles and the simulation have stopped. The classic + path simply drops the starfield past ``GPERF.large`` (``if(GPERF.large)return``); with the + stars gone there is nothing left that needs a frame the vendor would not schedule itself. + """ + report = _run_engine( + CANVAS_STUB + + """ + const api = G.create(el, {}); + api.setStyle('galaxy'); + + api.setData(chain(40)); + const smallAutoPause = store.autoPauseRedraw; + fills = 0; store.onRenderFramePre(ctx, 1); + const smallStars = fills; + + // 3001 entities / 3000 relations — past the classic renderer's 600-node signal. + api.setData(chain(3000)); + const bigAutoPause = store.autoPauseRedraw; + fills = 0; store.onRenderFramePre(ctx, 1); + const bigStars = fills; + + // Style is what costs the frames, not size alone: cyber never asked for them. + api.setStyle('cyber'); + api.setData(chain(40)); + emit({ smallAutoPause, bigAutoPause, smallStars, bigStars, + cyberAutoPause: store.autoPauseRedraw }); + """ + ) + # The custom 30 Hz physical clock invalidates only when it advances; force-graph's separate + # full-rate redraw loop remains parked even while the affordable starfield is present. + assert report["smallAutoPause"] is True + assert report["smallStars"] > 0, "canvas stub never reached the starfield" + # Large galaxy graph: no starfield, and the redraw loop is handed back to force-graph. + assert report["bigStars"] == 0 + assert report["bigAutoPause"] is True, "a large galaxy graph repaints every frame forever" + assert report["cyberAutoPause"] is True + + +@requires_node +def test_type_colours_follow_the_active_theme_not_a_hard_coded_dark_palette() -> None: + """``applyTheme()`` recolours the canvas, but the engine had no theme to recolour to. + + The legend and controls read the ``--entity-*`` custom properties, so switching to Light, + Midnight, Solarized or Sepia moved them while the canvas kept the dark-theme constants — + an inconsistent palette and, on the light themes, poor contrast. The engine cannot read + CSS variables from a canvas, so the dashboard supplies the resolved values. + """ + report = _run_engine( + """ + const api = G.create(el, {}); + // setData first: the force-graph stand-in only starts answering graphData() once the + // engine has pushed data into it, where the real vendor seeds an empty graph. + // Linked, because the default scope hides degree-zero entities. + api.setData({ + nodes: [{ id: 'a', etype: 'person_or_concept' }, { id: 'b', etype: 'person_or_concept' }], + links: [{ source: 'a', target: 'b', layer: 'entity' }], + }); + api.setColorBy('type'); + api.setStyle('classic'); + // `store` holds the values handed to force-graph, so this is the node object the + // engine actually painted from — recoloured in place by refreshColors()/render(). + const colour = () => store.graphData.nodes[0].color; + + const fallback = colour(); + api.setThemeColors({ person_or_concept: '#112233' }); + const themed = colour(); + + // A style palette still outranks the theme, exactly as classic graphTypeColor() does. + api.setStyle('cyber'); + const styled = colour(); + + // ...and an explicit user override still outranks both. + api.setStyle('classic'); + api.setTypeColor('person_or_concept', '#abcdef'); + const overridden = colour(); + + // A theme with no entry for the type must not strand the previous theme's colour. + api.setThemeColors({}); + emit({ fallback, themed, styled, overridden, cleared: colour() }); + """ + ) + assert report["fallback"] == "#8c83e8" + assert report["themed"] == "#112233", "the engine ignores the active theme" + assert report["styled"] == "#ff3ea5" + assert report["overridden"] == "#abcdef" + # The override survives; only the theme tier was replaced. + assert report["cleared"] == "#abcdef" + + +@requires_node +def test_hovering_a_node_asks_for_a_redraw() -> None: + """A highlight nobody repaints is invisible. + + ``onNodeHover`` mutates closure state the paint callbacks read. With reduced motion on, + flow disabled, or a settled simulation, force-graph's ``autoPauseRedraw`` loop has nothing + left to animate and will not repaint just because the callback fired. + """ + report = _run_engine( + """ + const api = G.create(el, { reducedMotion: () => true }); + api.setData({ nodes: [{ id: 'a' }, { id: 'b' }], links: [{ source: 'a', target: 'b' }] }); + const settled = calls.nodeCanvasObject; + store.onNodeHover({ id: 'a' }); + const hovered = calls.nodeCanvasObject; + store.onNodeHover(null); + emit({ + settled, hovered, cleared: calls.nodeCanvasObject, + particles: store.linkDirectionalParticles({ layer: 'semantic' }), + }); + """ + ) + # Reduced motion: nothing is in flight, so an unrequested redraw would never arrive. + assert report["particles"] == 0 + assert report["hovered"] > report["settled"] + assert report["cleared"] > report["hovered"] + + +@requires_node +def test_unlinked_entities_are_shown_by_default_and_can_be_hidden() -> None: + """The default graph is complete, while the user can still request a linked-only view.""" + report = _run_engine( + """ + const seen = []; + const api = G.create(el, { onStats: stats => seen.push(stats.nodes) }); + api.setData({ + nodes: [{ id: 'a' }, { id: 'b' }, { id: 'lonely' }], + links: [{ source: 'a', target: 'b' }], + }); + const shown = seen[seen.length - 1]; + api.setScope({ showUnlinked: false }); + const hidden = seen[seen.length - 1]; + api.setScope({ showUnlinked: true }); + emit({ hidden, shown, restored: seen[seen.length - 1] }); + """ + ) + assert report["hidden"] == 2 + assert report["shown"] == 3 + assert report["restored"] == 3 + + +#: Executes the *real* ``graphRenderEngine`` source against stubs. Only its collaborators are +#: faked; the function itself is a verbatim slice, so what it forwards to the engine — and when +#: it parks a freshly created renderer — is observed rather than asserted about the source text. +RENDER_HARNESS = """ +const fs = require('fs'); +const src = fs.readFileSync(process.argv.slice(1).find(a => a.endsWith('dashboard.js')), 'utf8'); +const scenario = JSON.parse(process.argv[process.argv.length - 1]); +const start = src.indexOf('function graphRenderEngine('); +const slice = src.slice(start, src.indexOf('/* Nav away from the graph view', start)); + +/* The theme-colour lookup is sliced verbatim too, not stubbed: the property under test is + that the dashboard resolves the *active* CSS custom properties and hands them over, so + faking the resolver would assert nothing. Only `getComputedStyle` below is synthetic. */ +const between = (from, to) => src.slice(src.indexOf(from), src.indexOf(to, src.indexOf(from))); +const themeSrc = between('const ETYPE_TOKEN=', 'const GRAPH_PALETTES=') + + between('function cssvar(', 'function graphValidColor(') + + between('function graphThemeTypeColors(', 'function graphContrastColor('); + +/* A stand-in for a non-dark theme: every --entity-* token differs from the engine's + hard-coded THEME_ETYPE constants, so a renderer that ignored these would be visible. */ +const THEME_VARS = { + '--entity-concept': '#112233', '--entity-mention': '#223344', '--entity-hashtag': '#334455', + '--entity-email': '#445566', '--entity-organization': '#556677', '--entity-location': '#667788', + '--color-accent': '#778899', '--color-panel': '#9a7654', '--color-canvas': '#345678', + '--color-text-dim': '#123456', +}; +globalThis.getComputedStyle = () => ({ getPropertyValue: name => THEME_VARS[name] || '' }); + +const log = { created: 0, paused: 0, seeded: 0, scope: null, themeColors: null, error: null }; +const checkbox = { checked: scenario.showUnlinked }; +const element = { classList: { toggle() {} }, setAttribute() {}, set textContent(value) {} }; +globalThis.document = { + getElementById: id => (id === 'graph-show-iso' ? checkbox : element), + querySelectorAll: () => [], + body: {}, +}; +const engine = { + setSettings() {}, setStyle() {}, setColorBy() {}, setPalette() {}, setTypeColors() {}, + setLayers() {}, setScope(patch) { log.scope = patch; }, + setThemeColors(map) { log.themeColors = map; }, + setData(data) { log.seeded = data.nodes.length; }, +}; +const api = { + apply(fn, fit, reheat) { fn(engine); log.apply = { fit: !!fit, reheat: !!reheat }; }, communityMap: () => ({}), + freeze() {}, destroy() {}, resume() {}, pause() { log.paused += 1; }, +}; +globalThis.EngraphisGraph = { create() { log.created += 1; return api; } }; +globalThis.window = { GSET: { mode: 'compact', frozen: false } }; +globalThis.GRAPH = { nodes: [] }; +globalThis.GRAPH_ENGINE = null; +globalThis.GACTIVE_DATA = null; +globalThis.GCOLOR_OVERRIDES = {}; +/* The state the nav-away pause recorded while GRAPH_ENGINE was still null. */ +globalThis.GRAPH_ENGINE_PARKED = scenario.parked; +globalThis.showAs = () => {}; +globalThis.prefersReducedMotion = () => !!scenario.reducedMotion; +for (const name of ['graphSetLayoutStatus', 'graphSyncReadouts', 'graphUpdateEditedBadge', + 'graphUpdateHud', 'graphRenderLegend', 'graphSetHighlight', + 'graphSetSimulationStatus', 'syncGraphExplorerSelection', 'graphNodeClick', + 'graphEngineEmptyMessage']) globalThis[name] = () => {}; +globalThis.graphEngineFallback = error => { + log.error = String((error && error.message) || error); +}; + +const graphRenderEngine = new Function(themeSrc + slice + '\\nreturn graphRenderEngine;')(); +const rendered = graphRenderEngine({ + nodes: [{ id: 'a' }, { id: 'b' }, { id: 'lonely' }], + links: [{ source: 'a', target: 'b' }], +}, true, true); +console.log(JSON.stringify(Object.assign({ rendered }, log))); +""" + + +def _run_render( + *, show_unlinked: bool = False, parked: bool = False, reduced_motion: bool = False +) -> dict: + source = DASHBOARD.read_text(encoding="utf-8") + # The harness slices real source; keep its landmarks honest. + assert "function graphRenderEngine(" in source + assert "/* Nav away from the graph view" in source + scenario = json.dumps({ + "showUnlinked": show_unlinked, + "parked": parked, + "reducedMotion": reduced_motion, + }) + result = subprocess.run( + [NODE, "-e", RENDER_HARNESS, str(DASHBOARD), scenario], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + report = json.loads(result.stdout.strip().splitlines()[-1]) + assert report["error"] is None, report["error"] + assert report["rendered"] is True + return report + + +@requires_node +@pytest.mark.parametrize("checked", [False, True]) +def test_dashboard_tells_the_engine_whether_to_show_unlinked_entities(checked: bool) -> None: + """"Show unlinked nodes" is filtered twice, and only one half was wired up. + + ``graphData()`` starts supplying degree-zero entities when the box is ticked, but the + engine re-filters on its own ``showUnlinked``/``minDegree`` state — which stays at the + defaults that drop exactly those entities — unless the dashboard says otherwise. + """ + report = _run_render(show_unlinked=checked) + + assert report["scope"] is not None, "the engine never learns the checkbox state" + assert report["scope"]["showUnlinked"] is checked + # minDegree matters just as much: showUnlinked alone still loses to `degree >= 1`. + assert report["scope"]["minDegree"] == (0 if checked else 1) + + +@requires_node +def test_dashboard_hands_the_engine_the_active_themes_entity_colours() -> None: + """The other half of the theme fix: the engine can only use what it is given.""" + report = _run_render() + + assert report["themeColors"] is not None, "the engine never learns the active theme" + # Resolved from the stubbed --entity-* custom properties, not from any JS constant. + assert report["themeColors"]["person_or_concept"] == "#112233" + assert report["themeColors"]["organization"] == "#556677" + assert report["themeColors"]["accent"] == "#778899" + assert report["themeColors"]["surface"] == "#9a7654" + assert report["themeColors"]["canvas"] == "#345678" + assert report["themeColors"]["relation_label"] == "#123456" + assert report["themeColors"]["label"] == "#e7e9ee" + # Every type the legend can show must be covered, or the canvas falls back per type. + assert set(report["themeColors"]) == { + "person_or_concept", "mention", "hashtag", "email", "organization", "location", + "accent", "surface", "canvas", "relation_label", "label", + } + + +def test_a_theme_switch_repaints_the_opt_in_canvas() -> None: + """``applyTheme()`` is the only place a theme change is observable. + + It already calls ``graphRecolor()``; that path has to reach the engine, or the canvas keeps + the previous theme until the next full graph render. + """ + source = DASHBOARD.read_text(encoding="utf-8") + assert "if(typeof graphRecolor==='function')graphRecolor()" in source + recolor = source[source.index("function graphRecolor()"):] + recolor = recolor[: recolor.index("\nfunction graphFit")] + assert "engine.setThemeColors(graphThemeTypeColors())" in recolor + + +@requires_node +def test_a_renderer_created_after_leaving_the_graph_view_is_born_paused() -> None: + """The rAF leak this PR already fixed once, reached by a different route. + + ``/graph`` and both lazy scripts resolve asynchronously. Leaving Graph before they do runs + the pause while ``GRAPH_ENGINE`` is still null, so the pending callback would create and + start a renderer against a hidden pane that nothing ever pauses again. + """ + parked = _run_render(parked=True) + assert parked["created"] == 1 + assert parked["paused"] == 1, "a renderer created off-view keeps repainting forever" + + # On the view, the same path must not park a renderer the user is looking at. + live = _run_render(parked=False) + assert live["created"] == 1 + assert live["paused"] == 0 + + +@requires_node +def test_classic_graph_starts_live_even_when_the_os_prefers_reduced_motion() -> None: + """Reduced visual motion cannot suppress the explicit physics default.""" + + report = _run_render(reduced_motion=True) + assert report["apply"] == {"fit": True, "reheat": True} + + source = CLASSIC_DASHBOARD.read_text(encoding="utf-8") + assert "window.GSET.frozen=false;" in source + engine = source[source.index("function graphRenderEngine("):] + engine = engine[:engine.index("/* Nav away from the graph view")] + assert "},fit,reheat);" in engine + assert "reheat&&!prefersReducedMotion()" not in engine + + +def test_classic_freeze_switch_keeps_the_status_readout_in_sync() -> None: + source = CLASSIC_DASHBOARD.read_text(encoding="utf-8") + start = source.index("function graphToggleFreeze(") + handler = source[start:source.index("\nfunction graphToggleLabels", start)] + assert "GRAPH_ENGINE.freeze(control.checked);graphSetSimulationStatus(control.checked?'Layout frozen':'Adaptive layout',false);return" in handler + + +def test_leaving_the_graph_view_records_the_pause_as_well_as_applying_it() -> None: + source = DASHBOARD.read_text(encoding="utf-8") + assert "if(v==='graph')graphEngineResume();else graphEnginePause()" in source + pause = source[source.index("function graphEnginePause()"):] + pause = pause[: pause.index("\nfunction graphInvalidateData")] + assert "GRAPH_ENGINE_PARKED=true" in pause + assert "GRAPH_ENGINE_PARKED=false" in pause + + +#: Force-graph resolves each link's ``source``/``target`` from an id to the node object once it +#: owns the data, and the paint callbacks read ``.x``/``.y`` off those objects. The recording +#: stand-in stores the arrays untouched, so a test that wants to *drive* a link painter has to +#: do that resolution — and give the nodes coordinates — itself. +LAY_OUT = """ +const layOut = () => { + const data = store.graphData; + const byId = new Map(data.nodes.map(n => [n.id, n])); + data.nodes.forEach((n, i) => { n.x = i * 10; n.y = i; }); + data.links.forEach(l => { + const s = byId.get(l.source && l.source.id !== undefined ? l.source.id : l.source); + const t = byId.get(l.target && l.target.id !== undefined ? l.target.id : l.target); + if (s) l.source = s; + if (t) l.target = t; + }); + return data; +}; +let painted = []; +const linkCtx = { + font: '', fillStyle: '', textAlign: '', textBaseline: '', + fillText(text) { painted.push(String(text)); }, +}; +const paintLinks = (scale, links) => { + painted = []; + const mode = store.linkCanvasObjectMode ? store.linkCanvasObjectMode() : undefined; + const draw = store.linkCanvasObject; + if (mode === 'after' && draw) (links || store.graphData.links).forEach(l => draw(l, linkCtx, scale)); + return painted.slice(); +}; +""" + + +@requires_node +def test_relation_labels_are_painted_when_the_labels_box_is_ticked() -> None: + """**Labels** turns on two label layers on the classic path; the engine only had one. + + ``graphToggleLabels`` forwards the checkbox straight to ``setSettings({labels})``, and the + classic renderer answers it with *both* entity names and a ``linkCanvasObject`` that paints + each meaningful ``link.label``. Implicit ``co_occurs`` links are structural and deliberately + excluded. The opt-in engine configured no link painter at all, so relation names silently + disappeared under ``?graph-engine=next`` and could only be read by hovering one edge at a + time. + """ + report = _run_engine( + LAY_OUT + + """ + const api = G.create(el, { reducedMotion: () => true }); + api.setData({ + nodes: [{ id: 'a' }, { id: 'b' }], + links: [ + { source: 'a', target: 'b', layer: 'entity', label: 'mentions' }, + { source: 'b', target: 'a', layer: 'semantic', label: 'co_occurs' }, + ], + }); + layOut(); + const unticked = paintLinks(4); + api.setSettings({ labels: true }); + api.setThemeColors({ relation_label: '#123456' }); + const ticked = paintLinks(4); + const labelColor = linkCtx.fillStyle; + // Relation labels are the noisiest layer: they stay off until the user zooms in. + const zoomedOut = paintLinks(1); + emit({ unticked, ticked, zoomedOut, labelColor }); + """ + ) + assert report["unticked"] == [] + assert report["ticked"] == ["mentions"], "the Labels checkbox never paints relation names" + assert report["labelColor"] == "#123456", "relation labels ignore the active theme" + assert report["zoomedOut"] == [] + + +def test_classic_graph_hides_implicit_co_occurrence_edge_labels() -> None: + """The Labels toggle keeps meaningful relation names but omits structural co-occurrences.""" + static = DASHBOARD.read_text(encoding="utf-8") + classic = CLASSIC_DASHBOARD.read_text(encoding="utf-8") + assert static == classic, "the classic dashboard assets must remain synchronized" + label_guard = "function graphShowRelationLabel(label){return !!label&&String(label).toLowerCase()!=='co_occurs'}" + assert label_guard in static + assert "if(scale<2.4||!graphShowRelationLabel(link.label)||!link.source.x" in static + + +@requires_node +def test_node_labels_are_capped_at_the_configured_density() -> None: + """A high density setting must still bound per-frame node-label painting.""" + report = _run_engine( + """ + let labels = []; + const ctx = { + globalAlpha: 1, fillStyle: '', strokeStyle: '', lineWidth: 1, font: '', textBaseline: '', + save() {}, restore() {}, beginPath() {}, arc() {}, stroke() {}, fill() {}, + createLinearGradient() { return { addColorStop() {} }; }, + createRadialGradient() { return { addColorStop() {} }; }, + fillText(text) { labels.push(String(text)); }, + }; + const api = G.create(el, { reducedMotion: () => true }); + api.setData(chain(20)); + api.setSettings({ labels: true, labelDensity: 3 }); + store.graphData.nodes.forEach((node, index) => { + node.x = index * 10; node.y = 0; + }); + const beforePost = labels.slice(); + store.onRenderFramePost(ctx, 1); + const names = labels.filter(value => value.startsWith('n')); + emit({ beforePost, names, distinct: [...new Set(names)] }); + """ + ) + assert report["beforePost"] == [], "node labels must wait until every node body is painted" + assert len(report["distinct"]) == 3 + assert len(report["names"]) == 6 # shadow + foreground per selected node + + +def test_collapsed_cluster_labels_use_the_active_theme_text_colour() -> None: + source = ASSET.read_text(encoding="utf-8") + cluster_label = source[source.index("if (label.cluster)"):source.index("} else {", source.index("if (label.cluster)"))] + assert "state.themeColors.label || '#e7e9ee'" in cluster_label + + +@requires_node +def test_node_labels_use_the_active_theme_text_colour() -> None: + """Classic labels paint onto the canvas, so near-white is unreadable on light themes.""" + + report = _run_engine( + LAY_OUT + + """ + const api = G.create(el, { reducedMotion: () => true }); + api.setData(chain(2)); + const data = layOut(); + api.setStyle('classic'); + api.setThemeColors({ label: '#123456' }); + api.setHighlight('n0'); + const styles = []; + const ctx = { + set fillStyle(value) { styles.push(value); }, get fillStyle() { return ''; }, + font: '', textBaseline: '', lineWidth: 0, strokeStyle: '', globalAlpha: 1, + beginPath() {}, arc() {}, fill() {}, stroke() {}, fillText() {}, save() {}, restore() {}, + createRadialGradient() { return { addColorStop() {} }; }, + createLinearGradient() { return { addColorStop() {} }; }, + }; + store.onRenderFramePost(ctx, 1); + emit({ styles }); + """ + ) + assert "#123456" in report["styles"], "node labels ignored the active theme text colour" + + +@requires_node +def test_drag_release_is_kinematic_and_never_wakes_unrelated_systems() -> None: + """Pointer placement changes one node without touching global alpha or other bodies.""" + report = _run_engine( + """ + const linkForce = { + id() { return this; }, distance() { return this; }, strength() { return this; }, + }; + globalThis.d3 = { + forceLink: () => linkForce, + forceCollide: () => ({ iterations() { return this; } }), + }; + store.d3Forces = { center: { vendorDefault: true } }; + const api = G.create(el, { reducedMotion: () => true }); + api.setData({ + nodes: [ + { id: 'dragged', x: -20, y: 0, gravity_mass: 4, community_id: 'local' }, + { id: 'neighbour', x: 0, y: 0, gravity_mass: 2, community_id: 'local' }, + { id: 'orphan', x: 80, y: 30, gravity_mass: 7, community_id: 'remote' }, + ], + edges: [{ source: 'dragged', target: 'neighbour', rest_length: 20, spring_strength: 0.1 }], + }); + api.setScope({ showUnlinked: true, minDegree: 0 }); + const byId = Object.fromEntries(store.graphData.nodes.map(node => [node.id, node])); + byId.dragged.vx = 9; byId.dragged.vy = -7; + byId.neighbour.vx = 3; byId.neighbour.vy = 4; + byId.orphan.vx = -5; byId.orphan.vy = 6; + const untouched = () => ['neighbour', 'orphan'].map(id => { + const node = byId[id]; + return [id, node.x, node.y, node.vx, node.vy, node.fx, node.fy]; + }); + const wakes = () => ({ + alphaTarget: calls.d3AlphaTarget || 0, + alphaDecay: calls.d3AlphaDecay || 0, + resets: invocations.resetCountdown || 0, + reheats: invocations.d3ReheatSimulation || 0, + }); + const before = { untouched: untouched(), wakes: wakes() }; + store.onNodeDragStart(byId.dragged); + const duringForces = ['charge', 'galaxy', 'galaxyCenter', 'galaxyRelations', + 'communityBridges', 'link', 'x', 'y', 'radial', 'collide', 'center', + 'velocityGuard'] + .map(name => store.d3Forces[name] === null); + byId.dragged.x = byId.dragged.fx = 35; + byId.dragged.y = byId.dragged.fy = 12; + const during = { untouched: untouched(), wakes: wakes() }; + store.onNodeDragEnd(byId.dragged); + setTimeout(() => emit({ + before, during, + after: { untouched: untouched(), wakes: wakes() }, + duringForces, + dragged: [byId.dragged.x, byId.dragged.y, byId.dragged.vx, byId.dragged.vy, + byId.dragged.fx, byId.dragged.fy], + restored: { + linkRemoved: store.d3Forces.link === null, + galaxy: typeof store.d3Forces.galaxy, + galaxyCenter: typeof store.d3Forces.galaxyCenter, + relations: typeof store.d3Forces.galaxyRelations, + bridges: typeof store.d3Forces.communityBridges, + guard: typeof store.d3Forces.velocityGuard, + centerRemoved: store.d3Forces.center === null, + }, + }), 0); + """ + ) + assert all(report["duringForces"]) + assert report["before"]["untouched"] == report["during"]["untouched"] + assert report["before"]["untouched"] == report["after"]["untouched"] + assert report["during"]["wakes"]["alphaTarget"] == report["before"]["wakes"]["alphaTarget"] + assert report["after"]["wakes"] == report["during"]["wakes"] + for key in ("alphaDecay", "resets", "reheats"): + assert report["during"]["wakes"][key] == report["before"]["wakes"][key] + assert report["dragged"] == [35, 12, 9, -7, None, None] + assert report["restored"] == { + "linkRemoved": True, + "galaxy": "object", + "galaxyCenter": "object", + "relations": "object", + "bridges": "object", + "guard": "object", + "centerRemoved": True, + } + + +@requires_node +def test_galaxy_drag_never_touches_d3_alpha_or_countdown() -> None: + report = _run_engine( + """ + globalThis.d3 = {}; + const api = G.create(el, { reducedMotion: () => true }); + api.setData({ + nodes: [ + { id: 'a', x: 0, y: 0, gravity_mass: 4, community_id: 'a' }, + { id: 'b', x: 80, y: 0, gravity_mass: 2, community_id: 'b' }, + ], + edges: [], + }); + api.setScope({ showUnlinked: true, minDegree: 0 }); + const dragged = store.graphData.nodes[0]; + api.reheat(); + const before = { + alpha: calls.d3AlphaTarget || 0, + resets: invocations.resetCountdown || 0, + reheats: invocations.d3ReheatSimulation || 0, + }; + store.onNodeDragStart(dragged); + store.onNodeDragEnd(dragged); + emit({ + alphaStops: (calls.d3AlphaTarget || 0) - before.alpha, + countdownResets: (invocations.resetCountdown || 0) - before.resets, + reheats: (invocations.d3ReheatSimulation || 0) - before.reheats, + }); + """ + ) + assert report == {"alphaStops": 0, "countdownResets": 0, "reheats": 0} + + +def test_drag_keeps_galaxy_live_without_any_d3_reheat_path() -> None: + """Dragging fixes one moving source; it must not detach or wake global physics.""" + source = ASSET.read_text(encoding="utf-8") + assert "function isolateDragPhysics()" not in source + assert "function restoreDragPhysics()" not in source + assert "if (activeDragNode) return false" not in source + assert "fixedNodeId: activeDragNode ? activeDragNode.id : null" in source + assert "GALAXY_DRAG_GRAVITY_CAPTURE_RADIUS" in source + assert "GALAXY_DRAG_GRAVITY_MULTIPLIER = 2" in source + assert "dragSource: activeDragNode" in source + begin = source[source.index("function beginNodeDrag(node) {"):] + begin = begin[: begin.index(" function finishNodeDrag", 1)] + finish = source[source.index("function finishNodeDrag(node) {"):] + finish = finish[: finish.index(" /* A drag uses", 1)] + forbidden = ("prepareReheat(", "softReheat(", "resetCountdown(", + "d3AlphaTarget(", "d3AlphaDecay(", "d3ReheatSimulation(") + assert not any(call in begin for call in forbidden) + assert not any(call in finish for call in forbidden) + assert "cancelGalaxyDynamics(" not in begin + assert "setSimulationBudget(false" not in begin + follow = source[source.index("function followDraggedNode(node) {"):] + follow = follow[: follow.index(" function beginNodeDrag", 1)] + assert "applyDraggedNodeGravity(" not in follow + assert "dragFollowers = captureDragFollowers(node)" in follow + assert "reheatLiveLayout" not in source + assert "makeDragFollowForce" not in source + + +@requires_node +def test_galaxy_freeze_keeps_d3_fully_stopped_before_and_after_unfreeze() -> None: + """Galaxy resumes its own clock; it must never reactivate D3's position integrator.""" + + report = _run_engine( + """ + const api = G.create(el, {}); + api.setData(chain(2)); + api.freeze(true); + api.setData(chain(3)); + const frozen = { + time: store.cooldownTime, ticks: store.cooldownTicks, warmup: store.warmupTicks, + }; + api.freeze(false); + emit({ + frozen, + resumed: { + time: store.cooldownTime, ticks: store.cooldownTicks, warmup: store.warmupTicks, + }, + }); + """ + ) + assert report["frozen"] == {"time": 0, "ticks": 0, "warmup": 0} + assert report["resumed"] == {"time": 0, "ticks": 0, "warmup": 0} + + +@requires_node +def test_freeze_is_the_physics_gate_even_with_reduced_motion() -> None: + """The switch must never claim physics is live while an OS preference disables it.""" + + report = _run_engine( + """ + const reheats = () => invocations.d3ReheatSimulation || 0; + const api = G.create(el, { reducedMotion: () => true }); + api.setData(chain(2)); + const started = { budget: [store.cooldownTime, store.cooldownTicks], + diagnostics: api.physicsDiagnostics(), reheats: reheats() }; + api.freeze(true); + const frozen = { diagnostics: api.physicsDiagnostics(), reheats: reheats() }; + api.freeze(false); + emit({ started, frozen, + resumed: { diagnostics: api.physicsDiagnostics(), reheats: reheats() } }); + """ + ) + assert report["started"]["budget"] == [0, 0] + assert report["started"]["diagnostics"]["reducedMotion"] is True + assert report["frozen"]["diagnostics"]["frozen"] is True + assert report["resumed"]["diagnostics"]["frozen"] is False + assert report["started"]["reheats"] == report["frozen"]["reheats"] == report["resumed"]["reheats"] == 0 + + +@requires_node +def test_persistent_galaxy_clock_is_fixed_bounded_and_lifecycle_safe() -> None: + report = _run_engine( + """ + let nextFrame = 1; + const frameQueue = new Map(); + window.requestAnimationFrame = callback => { + const id = nextFrame++; + frameQueue.set(id, callback); + return id; + }; + window.cancelAnimationFrame = id => frameQueue.delete(id); + const flush = timestamp => { + const batch = [...frameQueue.values()]; + frameQueue.clear(); + batch.forEach(callback => callback(timestamp)); + }; + let hidden = false, visibilityHandler = null; + globalThis.document = { + get hidden() { return hidden; }, + addEventListener(name, handler) { + if (name === 'visibilitychange') visibilityHandler = handler; + }, + removeEventListener(name, handler) { + if (name === 'visibilitychange' && visibilityHandler === handler) visibilityHandler = null; + }, + }; + + const api = G.create(el, { reducedMotion: () => false }); + api.setData({ + nodes: [ + { id: 'heavy', x: -20, y: 0, gravity_mass: 4, community_id: 'one' }, + { id: 'light', x: 20, y: 0, gravity_mass: 1, community_id: 'one' }, + ], + edges: [{ source: 'heavy', target: 'light' }], + }); + const actualNodes = store.graphData.nodes; + const expectedNodes = actualNodes.map(node => ({ ...node })); + I.integrateGalaxyLeapfrog(expectedNodes, store.graphData.links, [], { + gravity: 48, + softening: 38.4, + centralSoftening: 48, + bridgeSoftening: 38.4, + exactLimit: 64, + theta: 0.85, + localPairFraction: 0.15, + corePairMultiplier: 0.75, + includeBridges: false, + includeRelations: true, + includeRelationSprings: false, + skipSystemAnchorRelations: true, + skipOrbitalSystemRelations: true, + orbitScale: 0.25, + relationStrengthMultiplier: 2, + relationForceCap: 1.6, + relationAccelerationCap: 3.2, + relationConstraintStrengthMultiplier: 2, + relationConstraintResponseMultiplier: 1, + relationConstraintRate: 24, + relationConstraintMaxCorrection: 12, + relationPadding: 15, + includeOrbitalSeparation: true, + orbitalSeparationPadding: 15, + orbitalSeparationStrength: 1, + crossCommunitySeparationPadding: 1.5, + crossCommunitySeparationStrength: 0.18, + orbitalSeparationMaxCorrection: 4, + orbitalSeparationMaxVelocityCorrection: 8, + preserveLocalTangentialVelocity: true, + preserveSystemRadii: true, + skipSystemAnchorPairs: true, + systemAnchorExclusionPadding: 1.5, + systemAnchorRepulsionRange: 6, + systemAnchorRepulsionAcceleration: 0.12, + includeMutualSystems: true, + mutualSystemGravityFraction: 0.12, + mutualSystemSoftening: 80, + localRelativeSpeedLimit: 48, + timestep: 0.032, + inwardConvergence: true, + wallClockSeconds: 1 / 30, + velocityDecay: 0.0001, + speedLimit: 48, + includeCollisions: false, + collisionPadding: 1.5, + collisionStrength: 0.7, + collisionIterations: 1, + }); + flush(100); + const first = { + actual: actualNodes.map(node => [node.x, node.y, node.vx, node.vy]), + expected: expectedNodes.map(node => [node.x, node.y, node.vx, node.vy]), + diagnostics: api.physicsDiagnostics(), + budget: [store.cooldownTime, store.cooldownTicks, store.warmupTicks], + d3ForcesOff: ['charge', 'link', 'center', 'galaxy', 'galaxyCenter', + 'galaxyRelations', 'communityBridges', 'collide', 'velocityGuard'] + .every(name => store.d3Forces[name] === null), + }; + + api.freeze(true); + const frozenPositions = actualNodes.map(node => [node.x, node.y, node.vx, node.vy]); + flush(5000); + const frozen = { + positions: actualNodes.map(node => [node.x, node.y, node.vx, node.vy]), + diagnostics: api.physicsDiagnostics(), + queued: frameQueue.size, + }; + api.freeze(false); + flush(9000); + const resumed = api.physicsDiagnostics(); + + hidden = true; + visibilityHandler(); + const hiddenPositions = actualNodes.map(node => [node.x, node.y, node.vx, node.vy]); + flush(50000); + const whileHidden = { + positions: actualNodes.map(node => [node.x, node.y, node.vx, node.vy]), + diagnostics: api.physicsDiagnostics(), + }; + hidden = false; + visibilityHandler(); + flush(100000); + const visibleAgain = api.physicsDiagnostics(); + + const dragged = actualNodes[0], unrelated = actualNodes[1]; + store.onNodeDragStart(dragged); + const unrelatedBeforeDrag = [unrelated.x, unrelated.y, unrelated.vx, unrelated.vy]; + dragged.x = dragged.fx = 75; + dragged.y = dragged.fy = 25; + flush(100100); + const duringDrag = [unrelated.x, unrelated.y, unrelated.vx, unrelated.vy]; + const stepsBeforeRelease = api.physicsDiagnostics().steps; + store.onNodeDragEnd(dragged); + flush(100200); + const releaseFrame = { + unrelated: [unrelated.x, unrelated.y, unrelated.vx, unrelated.vy], + steps: api.physicsDiagnostics().steps, + dragged: [dragged.x, dragged.y, dragged.vx, dragged.vy, dragged.fx, dragged.fy], + }; + flush(100234); + const afterDragEvolution = api.physicsDiagnostics(); + + api.pause(); + const pausedSteps = api.physicsDiagnostics().steps; + flush(200000); + const paused = api.physicsDiagnostics(); + api.resume(); + flush(300000); + const resumedAfterPause = api.physicsDiagnostics(); + api.destroy(); + emit({ + first, + frozenPositions, + frozen, + resumed, + hiddenPositions, + whileHidden, + visibleAgain, + unrelatedBeforeDrag, + duringDrag, + stepsBeforeRelease, + releaseFrame, + afterDragEvolution, + pausedSteps, + paused, + resumedAfterPause, + queuedAfterDestroy: frameQueue.size, + d3Wakes: { + alpha: calls.d3AlphaTarget || 0, + resets: invocations.resetCountdown || 0, + reheats: invocations.d3ReheatSimulation || 0, + }, + }); + """ + ) + assert report["first"]["actual"][0] == pytest.approx([0, 0, 0, 0]) + assert all( + math.isfinite(value) + for body in report["first"]["actual"] + for value in body + ) + assert report["first"]["diagnostics"]["steps"] == 1 + assert report["first"]["diagnostics"]["lastSubsteps"] == 1 + first = report["first"]["diagnostics"] + assert report["first"]["budget"] == [0, 0, 0] + assert report["first"]["d3ForcesOff"] is True + assert first["frames"] == first["steps"] == first["lastSubsteps"] == 1 + assert first["timestep"] == pytest.approx(0.032) + assert first["velocityDecay"] == pytest.approx(0.0005) + assert first["reducedMotion"] is False + assert first["kineticEnergy"] > 0 + assert first["speedCapActivations"] == 0 + + assert report["frozen"]["positions"] == report["frozenPositions"] + assert report["frozen"]["diagnostics"]["frozen"] is True + assert report["frozen"]["diagnostics"]["steps"] == 1 + assert report["frozen"]["queued"] == 0 + # Resuming after a long wall-clock gap performs one ordinary step, never three catch-up steps. + assert report["resumed"]["steps"] == 2 + assert report["resumed"]["lastSubsteps"] == 1 + + assert report["whileHidden"]["positions"] == report["hiddenPositions"] + assert report["whileHidden"]["diagnostics"]["steps"] == 2 + assert report["whileHidden"]["diagnostics"]["hidden"] is True + assert report["visibleAgain"]["steps"] == 3 + assert report["visibleAgain"]["lastSubsteps"] == 1 + + # Dragging owns only the primary node. The custom clock keeps integrating its related + # body around that moving mass source, without waking D3 or running catch-up substeps. + assert report["duringDrag"] != report["unrelatedBeforeDrag"] + assert report["releaseFrame"]["unrelated"] != report["unrelatedBeforeDrag"] + assert 3 < report["stepsBeforeRelease"] <= 6 + assert report["stepsBeforeRelease"] < report["releaseFrame"]["steps"] \ + <= report["stepsBeforeRelease"] + 3 + assert report["afterDragEvolution"]["steps"] \ + == report["releaseFrame"]["steps"] + 1 + assert all(value is not None for value in report["releaseFrame"]["dragged"][:4]) + assert report["releaseFrame"]["dragged"][4:] == [None, None] + + assert report["paused"]["steps"] == report["pausedSteps"] \ + == report["afterDragEvolution"]["steps"] + assert report["paused"]["running"] is False + assert report["resumedAfterPause"]["steps"] == report["pausedSteps"] + 1 + assert report["queuedAfterDestroy"] == 0 + assert report["d3Wakes"] == {"alpha": 0, "resets": 0, "reheats": 0} + + +@requires_node +def test_explicit_galaxy_reheat_never_adds_bonus_physical_slices() -> None: + report = _run_engine( + """ + let nextFrame = 1; + const frameQueue = new Map(); + window.requestAnimationFrame = callback => { + const id = nextFrame++; + frameQueue.set(id, callback); + return id; + }; + window.cancelAnimationFrame = id => frameQueue.delete(id); + const flush = timestamp => { + const batch = [...frameQueue.values()]; + frameQueue.clear(); + batch.forEach(callback => callback(timestamp)); + }; + const api = G.create(el, { reducedMotion: () => false }); + api.setData({ + nodes: [ + { id: 'black-hole', x: 0, y: 0, vx: 0, vy: 0, gravity_mass: 20, + community_id: 'core', anchor_role: 'global' }, + { id: 'unlinked-star', x: 140, y: 0, vx: 0, vy: 2, gravity_mass: 6, + community_id: 'outer' }, + ], + edges: [], + }); + flush(100); + flush(134); + const star = store.graphData.nodes.find(node => node.id === 'unlinked-star'); + const before = { + phase: [star.x, star.y, star.vx, star.vy], + diagnostics: api.physicsDiagnostics(), + }; + api.reheat(); + const queued = api.physicsDiagnostics(); + [200, 234, 268, 302, 336].forEach(flush); + const after = { + phase: [star.x, star.y, star.vx, star.vy], + diagnostics: api.physicsDiagnostics(), + }; + api.reheat(); + const recoalesced = api.physicsDiagnostics(); + api.freeze(true); + emit({ + before, queued, after, recoalesced, + frozen: api.physicsDiagnostics(), + d3: { + alpha: calls.d3AlphaTarget || 0, + resets: invocations.resetCountdown || 0, + reheats: invocations.d3ReheatSimulation || 0, + }, + }); + """ + ) + assert report["queued"]["reheatActivations"] == 1 + assert report["queued"]["reheatStepsRemaining"] == 0 + assert report["queued"]["reheatStepsApplied"] == 0 + assert report["after"]["diagnostics"]["reheatStepsApplied"] == 0 + assert report["after"]["diagnostics"]["reheatStepsRemaining"] == 0 + assert report["after"]["diagnostics"]["lastReheatSubsteps"] == 0 + assert report["after"]["diagnostics"]["steps"] \ + == report["before"]["diagnostics"]["steps"] + 5 + assert report["after"]["diagnostics"]["frames"] \ + == report["before"]["diagnostics"]["frames"] + 5 + assert report["after"]["diagnostics"]["lastSubsteps"] == 1 + assert report["after"]["phase"] != pytest.approx(report["before"]["phase"]) + assert report["recoalesced"]["reheatActivations"] == 2 + assert report["recoalesced"]["reheatStepsRemaining"] == 0 + assert report["recoalesced"]["reheatStepsApplied"] == 0 + assert report["frozen"]["reheatStepsRemaining"] == 0 + assert report["d3"] == {"alpha": 0, "resets": 0, "reheats": 0} + + +@requires_node +def test_manual_drag_keeps_clock_live_and_nearby_bodies_follow_fixed_source() -> None: + """Pointer ownership never freezes the graph; one source stays fixed while neighbours move.""" + + report = _run_engine( + """ + let nextFrame = 1; + const frameQueue = new Map(); + window.requestAnimationFrame = callback => { + const id = nextFrame++; + frameQueue.set(id, callback); + return id; + }; + window.cancelAnimationFrame = id => frameQueue.delete(id); + const flush = timestamp => { + const batch = [...frameQueue.values()]; + frameQueue.clear(); + batch.forEach(callback => callback(timestamp)); + }; + const manualWindowListeners = Object.create(null); + window.addEventListener = (name, handler) => { manualWindowListeners[name] = handler; }; + window.removeEventListener = (name, handler) => { + if (manualWindowListeners[name] === handler) delete manualWindowListeners[name]; + }; + const elementListeners = Object.create(null); + el.addEventListener = (name, handler) => { elementListeners[name] = handler; }; + el.removeEventListener = (name, handler) => { + if (elementListeners[name] === handler) delete elementListeners[name]; + }; + el.querySelector = selector => selector === 'canvas' ? { + getBoundingClientRect: () => ({ left: 0, top: 0 }), + } : null; + store.screen2GraphCoords = (x, y) => ({ x, y }); + + const api = G.create(el, { reducedMotion: () => false }); + api.setData({ + nodes: [ + { id: 'black-hole', anchor_role: 'global', x: 0, y: 0, + gravity_mass: 8, community_id: 'core' }, + { id: 'heavy', x: -30, y: 0, gravity_mass: 4, community_id: 'one' }, + { id: 'light', x: 30, y: 0, gravity_mass: 1, community_id: 'one' }, + { id: 'moon', x: 50, y: 20, gravity_mass: 1, community_id: 'one' }, + { id: 'remote', x: 140, y: -35, gravity_mass: 1, community_id: 'two' }, + ], + edges: [{ source: 'heavy', target: 'light' }], + }); + api.setScope({ showUnlinked: true, minDegree: 0 }); + flush(100); + const nodes = Object.fromEntries(store.graphData.nodes.map(node => [node.id, node])); + const pointer = (type, x, y) => ({ + type, button: 0, isPrimary: true, pointerId: 7, clientX: x, clientY: y, + preventDefault() {}, stopPropagation() {}, + }); + const unrelatedPhase = () => [nodes.remote.x, nodes.remote.y, nodes.remote.vx, nodes.remote.vy]; + const followerPhase = () => [nodes.light.x, nodes.light.y, nodes.light.vx, nodes.light.vy]; + const moonPhase = () => [nodes.moon.x, nodes.moon.y, nodes.moon.vx, nodes.moon.vy]; + const candidatePhase = () => [nodes.heavy.x, nodes.heavy.y, nodes.heavy.vx, nodes.heavy.vy]; + + const beforeDown = { + unrelated: unrelatedPhase(), follower: followerPhase(), moon: moonPhase(), + candidate: candidatePhase(), + steps: api.physicsDiagnostics().steps, + }; + elementListeners.pointerdown(pointer('pointerdown', nodes.heavy.x, nodes.heavy.y)); + const afterDown = { + unrelated: unrelatedPhase(), follower: followerPhase(), moon: moonPhase(), + candidate: candidatePhase(), + steps: api.physicsDiagnostics().steps, + }; + // Pointer-down alone is not a drag, and it must not suspend the Galaxy clock. + flush(5000); + const heldBeforeMove = { + unrelated: unrelatedPhase(), follower: followerPhase(), moon: moonPhase(), + candidate: candidatePhase(), + steps: api.physicsDiagnostics().steps, + }; + manualWindowListeners.pointermove(pointer('pointermove', nodes.heavy.x + 90, nodes.heavy.y + 45)); + const placedCandidate = candidatePhase(); + flush(6000); + const duringDrag = { + unrelated: unrelatedPhase(), follower: followerPhase(), moon: moonPhase(), + candidate: candidatePhase(), followers: api.physicsDiagnostics().dragFollowers, + steps: api.physicsDiagnostics().steps, + dragging: api.physicsDiagnostics().dragging, + }; + manualWindowListeners.pointerup(pointer('pointerup', nodes.heavy.x, nodes.heavy.y)); + const releaseSteps = api.physicsDiagnostics().steps; + flush(7000); // physics continues immediately; no restore/isolation frame exists + const releaseFrame = { unrelated: unrelatedPhase(), steps: api.physicsDiagnostics().steps }; + flush(7034); + const evolvedSteps = api.physicsDiagnostics().steps; + + // A click also leaves the ordinary clock live. + const clickBefore = candidatePhase(); + const clickBeforeSteps = api.physicsDiagnostics().steps; + elementListeners.pointerdown(pointer('pointerdown', nodes.heavy.x, nodes.heavy.y)); + flush(9000); + const clickHeld = candidatePhase(); + const clickHeldSteps = api.physicsDiagnostics().steps; + manualWindowListeners.pointerup(pointer('pointerup', nodes.heavy.x, nodes.heavy.y)); + const clickReleased = candidatePhase(); + const clickReleaseSteps = api.physicsDiagnostics().steps; + flush(9034); + const clickEvolvedSteps = api.physicsDiagnostics().steps; + + emit({ + beforeDown, afterDown, heldBeforeMove, duringDrag, + placedCandidate, releaseSteps, releaseFrame, evolvedSteps, + clickBefore, clickHeld, clickReleased, clickBeforeSteps, clickHeldSteps, + clickReleaseSteps, clickEvolvedSteps, + d3Wakes: { + alpha: calls.d3AlphaTarget || 0, + resets: invocations.resetCountdown || 0, + reheats: invocations.d3ReheatSimulation || 0, + }, + }); + """ + ) + assert report["afterDown"] == report["beforeDown"] + assert report["heldBeforeMove"]["steps"] > report["beforeDown"]["steps"] + assert report["heldBeforeMove"]["unrelated"] != report["beforeDown"]["unrelated"] + assert report["duringDrag"]["unrelated"] != report["heldBeforeMove"]["unrelated"] + assert report["duringDrag"]["follower"] != report["beforeDown"]["follower"] + assert report["duringDrag"]["moon"] != report["beforeDown"]["moon"] + assert report["duringDrag"]["candidate"] == pytest.approx(report["placedCandidate"]) + assert report["duringDrag"]["steps"] > report["heldBeforeMove"]["steps"] + assert report["duringDrag"]["dragging"] == "heavy" + assert set(report["duringDrag"]["followers"]) == {"light", "moon", "remote"} + assert report["releaseFrame"]["unrelated"] != report["duringDrag"]["unrelated"] + assert report["releaseFrame"]["steps"] > report["releaseSteps"] + assert report["evolvedSteps"] > report["releaseSteps"] + assert report["clickHeldSteps"] > report["clickBeforeSteps"] + assert report["clickHeld"] != pytest.approx(report["clickBefore"]) + assert report["clickReleased"] == pytest.approx(report["clickHeld"]) + assert report["clickEvolvedSteps"] > report["clickReleaseSteps"] + assert report["d3Wakes"] == {"alpha": 0, "resets": 0, "reheats": 0} + + +def test_primary_graph_dependencies_are_lazy_retryable_and_csp_clean() -> None: + """The primary Ledger must not pay for graph assets before Graph opens.""" + + markup = PRIMARY_INDEX.read_text(encoding="utf-8") + source = PRIMARY_LEDGER.read_text(encoding="utf-8") + vendor = PRIMARY_VENDOR.read_text(encoding="utf-8") + styles = PRIMARY_CSS.read_text(encoding="utf-8") + for asset in ("d3.min.js", "force-graph.min.js", "engraphis-graph.js"): + assert asset not in markup + assert 'id="graph-repel" type="range" min="0" max="400" value="100"' in markup + assert 'id="graph-link" type="range" min="4" max="80" value="8"' in markup + assert 'id="graph-gravity" type="range" min="0" max="400" value="96"' in markup + assert "{ id: 'graph-repel', key: 'repel', fallback: 100 }" in source + assert "{ id: 'graph-link', key: 'link', fallback: 8 }" in source + assert "{ id: 'graph-gravity', key: 'gravity', fallback: 96 }" in source + + loader_start = source.index("function ensureGraphAssets") + loader = source[ + loader_start:source.index("function showNotice", loader_start) + ] + 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'" + ) + assert d3 < force_graph < renderer + assert '/v2-assets/ledger.js?v=20260815-merge-ready-1' in markup + assert "if (graphAssetsPromise === attempt) releaseGraphAssetsAttempt(attempt)" in loader + assert "graphAssetsRetry = Math.min(graphAssetsRetry + 1, 10)" in loader + all_loader = source[source.index("function ensureGraphAllAsset()"): + source.index("function ensureGraphAssets(")] + assert "engraphis-graph-every.js?" in all_loader # cache-buster version intentionally unpinned + assert "engraphis-graph-every.js" not in loader.split("function releaseGraphAssetsAttempt", 1)[0] + assert not re.search(r'document\.createElement\(["\']style["\']\)', vendor) + assert ".force-graph-container canvas {" in styles + assert ".force-graph-container .grabbable:active {" in styles + assert ".float-tooltip-kap {" in styles + + +def test_primary_graph_starts_unfrozen_so_the_force_controls_take_effect() -> None: + """A fresh graph must settle, rather than make every tuning control look inert.""" + + assert "graphFrozen: false" in PRIMARY_LEDGER.read_text(encoding="utf-8") + assert "state.graphFrozen = false;" in PRIMARY_LEDGER.read_text(encoding="utf-8") + assert 'id="graph-freeze" class="graph-switch"' in PRIMARY_INDEX.read_text(encoding="utf-8") + freeze_control = PRIMARY_INDEX.read_text(encoding="utf-8").split('id="graph-freeze"', 1)[1] + assert 'aria-checked="false"' in freeze_control + + +def test_primary_dashboard_has_no_visible_notice_popup() -> None: + """Action feedback must not cover the dashboard with a dismissible toast.""" + + markup = PRIMARY_INDEX.read_text(encoding="utf-8") + source = PRIMARY_LEDGER.read_text(encoding="utf-8") + styles = (ROOT / "engraphis" / "dashboard_assets" / "ledger.css").read_text(encoding="utf-8") + assert 'id="notice"' not in markup + assert ">Dismiss<" not in markup + assert 'id="notice-text" class="sr-only"' in markup + assert "byId('notice').hidden" not in source + assert "notice-close" not in source + assert ".notice {" not in styles + + +def test_primary_layout_choices_resume_a_frozen_graph_including_full_mode() -> None: + """An explicit layout choice must visibly apply rather than merely change its selected chip.""" + + source = PRIMARY_LEDGER.read_text(encoding="utf-8") + handler = source.split("all('[data-graph-preset-choice]')", 1)[1].split( + "all('[data-graph-style-choice]')", 1 + )[0] + assert "const resumeLayout = state.graphFrozen;" in handler + assert "state.graphFrozen = false;" in handler + assert "state.graphEngine.freeze(false);" in handler + assert "state.graphEngine.setPreset(preset);" in handler + + +@requires_node +def test_focusing_an_entity_the_canvas_is_not_showing_does_not_report_success() -> None: + """``zoomToNode`` is the dashboard's visibility oracle, and it was answering from memory. + + ``graphFocus`` treats ``false`` as "offer the recovery path" — tick *Show unlinked*, retry, + and otherwise say *Entity not in view*. The engine answered from ``raw.nodes``, which keeps + the coordinates force-graph left on a node from an earlier render, so a node hidden by the + auto-collapsed view (only ``cluster-*`` bubbles are drawn below zoom 0.42) or by a scope + filter still reported success — the camera moved to nothing and the user got no explanation. + """ + report = _run_engine( + """ + const collapses = []; + const api = G.create(el, { + reducedMotion: () => true, onCollapseChange: value => collapses.push(value), + }); + api.setData({ + nodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'lonely' }], + links: [{ source: 'a', target: 'b' }, { source: 'b', target: 'c' }], + }); + const shownIds = () => (store.graphData.nodes || []).map(n => n.id); + // Everything visible once, so every entity carries real coordinates from here on. + api.setScope({ showUnlinked: true, minDegree: 0 }); + store.graphData.nodes.forEach((n, i) => { n.x = i * 10; n.y = i; }); + + // 1. Hidden by the scope filter, but still remembered with valid coordinates. + api.setScope({ showUnlinked: false, minDegree: 1 }); + const filtered = { found: api.zoomToNode('lonely'), shown: shownIds() }; + + // 2. Hidden by the collapsed view, which paints cluster bubbles instead of entities. + api.setCollapse(true); + const whileCollapsed = shownIds(); + const expanding = api.zoomToNode('c'); + // Galaxy preserves the coordinates from the expanded scene instead of throwing them + // away and waiting for a fresh simulation tick. + const rendered = (store.graphData.nodes || []).find(n => n.id === 'c'); + rendered.x = 20; rendered.y = 2; + const focused = api.zoomToNode('c'); + emit({ + filtered, whileCollapsed, expanding, focused, collapses, + afterFocus: shownIds(), collapsed: api.state().collapsed, + }); + """ + ) + # A filtered-out entity is not in view, so the dashboard must be told to recover. + assert report["filtered"]["found"] is False, "a filtered-out entity reported as visible" + assert "lonely" not in report["filtered"]["shown"] + # A collapsed view really is showing only bubbles... + assert report["whileCollapsed"] == ["cluster-0"] + # ...so focusing a named entity expands it. Galaxy retains its known scene coordinate and + # can center immediately instead of waiting for a second simulation frame. + assert report["expanding"] is True + assert report["focused"] is True + assert report["collapsed"] is False + assert "c" in report["afterFocus"], "the entity is still not on the canvas" + assert report["collapses"][-1] is False, "the dashboard was never told the view expanded" + + +@requires_node +def test_revealing_a_graph_fact_centers_the_rendered_entity_without_a_fit_race() -> None: + """A Graph facts row must reveal one stable entity, not restart and fit a subgraph. + + The camera must use the coordinates ForceGraph is currently painting. That avoids stale + raw-node coordinates and, by cancelling pending ``zoomToFit``, prevents the delayed global + fit that used to pull the selected entity off-screen after the row click. + """ + report = _run_engine( + """ + const api = G.create(el, { reducedMotion: () => true }); + api.setData({ + nodes: [{ id: 'a' }, { id: 'selected' }, { id: 'c' }], + links: [{ source: 'a', target: 'selected' }, { source: 'selected', target: 'c' }], + }); + const seeded = calls.graphData; + // Deliberately differ from raw data: `reveal` must follow what the canvas renders. + store.graphData = { nodes: [{ id: 'selected', x: 37, y: -53 }], links: [] }; + const revealed = api.reveal('selected'); + emit({ + revealed, seeded, after: calls.graphData, + centerAt: store.centerAt, zoom: store.zoom, + fits: calls.zoomToFit || 0, + }); + """ + ) + assert report["revealed"] is True + assert report["after"] == report["seeded"], "revealing a fact reseeded the graph" + assert report["centerAt"] == [37, -53, 0] + assert report["zoom"] == [3, 0] + assert report["fits"] == 0, "a global fit competed with the selected-node camera move" + + +@requires_node +def test_appearance_only_changes_do_not_restart_the_layout() -> None: + """Style, Color by, Labels and Flow repaint the graph; they must not re-run it. + + ``visible()`` allocates fresh arrays on every call, and force-graph treats any ``graphData`` + call as a data update: it re-copies the nodes and d3 resets the simulation alpha to 1. So + every appearance-only setter threw the settled layout away and made the whole graph move. + The classic renderer guards the same seed with ``if(dataChanged)FG.graphData(data)``. + """ + report = _run_engine( + """ + const api = G.create(el, { reducedMotion: () => true }); + const nodes = [{ id: 'lonely', etype: 'organization' }], links = []; + for (let i = 0; i < 12; i++) nodes.push({ id: 'n' + i, etype: 'person_or_concept' }); + for (let i = 0; i < 11; i++) links.push({ source: 'n' + i, target: 'n' + (i + 1) }); + api.setData({ nodes, links }); + const seeded = calls.graphData; + const before = store.graphData.nodes[0].color; + const repaintsBefore = calls.nodeCanvasObject; + + api.setStyle('galaxy'); + api.setColorBy('type'); + api.setSettings({ labels: true }); + api.setSettings({ flow: false }); + const paintOnly = calls.graphData; + const recoloured = store.graphData.nodes[0].color; + const repaintsAfter = calls.nodeCanvasObject; + + // A genuine change to the visible set still has to reach force-graph. + api.setScope({ showUnlinked: false, minDegree: 1 }); + emit({ + seeded, paintOnly, afterScope: calls.graphData, before, recoloured, + repaintsBefore, repaintsAfter, shown: store.graphData.nodes.length, + }); + """ + ) + assert report["paintOnly"] == report["seeded"], "an appearance change restarted the layout" + assert report["afterScope"] > report["seeded"], "a real view change never reached the canvas" + assert report["shown"] == 12 + # Skipping the reseed must not mean skipping the paint. + assert report["recoloured"] != report["before"] + assert report["repaintsAfter"] > report["repaintsBefore"] + + +@requires_node +def test_simulation_time_is_bounded_on_a_large_graph() -> None: + """force-graph's default cooldown is 15 seconds; nothing here was overriding it. + + The classic path caps a large graph at 1.1s / 80 ticks precisely because running the layout + — and therefore repainting every node and link — for the full default window is what makes a + big store feel broken on load and after every reheat. + """ + report = _run_engine( + """ + const api = G.create(el, {}); + api.setPreset('compact'); + api.setData(chain(40)); + const small = { + time: store.cooldownTime, ticks: store.cooldownTicks, warmup: store.warmupTicks, + alpha: store.d3AlphaDecay, velocity: store.d3VelocityDecay, + }; + // 3001 entities / 3000 relations — past the classic renderer's 600-node signal. + api.setData(chain(3000)); + const big = { + time: store.cooldownTime, ticks: store.cooldownTicks, warmup: store.warmupTicks, + alpha: store.d3AlphaDecay, velocity: store.d3VelocityDecay, + }; + const frozen = G.create(el, { reducedMotion: () => true }); + frozen.setData(chain(40)); + frozen.freeze(true); + emit({ + small, big, + frozen: { time: store.cooldownTime, ticks: store.cooldownTicks }, + }); + """ + ) + assert report["small"]["time"] == 2200 + assert report["small"]["ticks"] == 160 + # The number this guards: the vendor default left a 3k-relation store simulating for 15s. + assert report["big"]["time"] == 1100 + assert report["big"]["ticks"] == 80 + assert report["big"]["warmup"] == 18 + # A large graph also settles harder, exactly as GPERF.large does on the classic path. + assert report["big"]["alpha"] > report["small"]["alpha"] + assert report["big"]["velocity"] > report["small"]["velocity"] + # Freeze, not the OS visual-motion preference, is the explicit static-layout control. + assert report["frozen"]["time"] == 0 + assert report["frozen"]["ticks"] == 0 + + +@requires_node +def test_physics_sliders_reheat_the_simulation_the_way_the_classic_renderer_does() -> None: + """Installing a new force on a settled graph moves nothing without a reheat. + + ``graphSet`` (dashboard.js) routes Repel/Link/Gravity/Size/Font/Link-width/Label-density + through ``setSettings`` under ``?graph-engine=next``. The classic branch of that same + function treats ``repel|link|gravity|size`` as *layout* changes: it re-applies the forces + and then reheats unless the user explicitly froze the graph. The engine's ``applyForces()`` + only swaps the charge/link/forceX-forceY/collide values into the running simulation — and a + settled graph sits at alpha~0 — so without the reheat those four sliders are inert until + the user finds the Reheat button. The paint-only settings must *not* reheat: restarting + the layout because a label got bigger throws away the arrangement the user is reading. + """ + report = _run_engine( + """ + const reheats = () => invocations.d3ReheatSimulation || 0; + const bump = (api, patch) => { const before = reheats(); api.setSettings(patch); return reheats() - before; }; + + const api = G.create(el, {}); + api.setPreset('compact'); + api.setData(chain(40)); + const layout = { + repel: bump(api, { repel: 260 }), + link: bump(api, { link: 90 }), + gravity: bump(api, { gravity: 12 }), + size: bump(api, { size: 5 }), + mode: bump(api, { mode: 'radial' }), + }; + const paint = { + font: bump(api, { font: 11 }), + linkw: bump(api, { linkw: 2.4 }), + labelDensity: bump(api, { labelDensity: 40 }), + labels: bump(api, { labels: true }), + flow: bump(api, { flow: false }), + }; + + const reduced = G.create(el, { reducedMotion: () => true }); + reduced.setPreset('compact'); + reduced.setData(chain(40)); + const reducedMotion = bump(reduced, { repel: 260 }); + emit({ layout, paint, reducedMotion }); + """ + ) + # The four sliders the classic renderer calls a layout change, plus the preset itself. + assert report["layout"] == { + "repel": 1, "link": 1, "gravity": 1, "size": 1, "mode": 1 + }, "a physics slider installed new forces on a settled graph and nothing moved" + # Appearance-only settings keep the arrangement the user is looking at. + assert report["paint"] == { + "font": 0, "linkw": 0, "labelDensity": 0, "labels": 0, "flow": 0 + }, "an appearance change restarted the layout" + assert report["reducedMotion"] == 1, "reduced motion silently disabled live physics" + + +@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. + + The screenshot regression occurred at a few thousand relationships: the UI showed a + centre-gravity value, but the full-graph branch had removed every D3 force and fixed every + node's coordinates. It is safe to run a bounded simulation at this size, so the same + centre force and reheat contract as Overview must remain observable in Full mode. + """ + report = _run_engine( + """ + const axes = { x: [], y: [] }; + const bodyForce = () => ({ strength(value) { this.value = value; return this; } }); + globalThis.d3 = { + forceManyBody: bodyForce, + forceLink: () => ({ id(value) { this.idValue = value; return this; }, distance(value) { this.value = value; return this; } }), + forceX: target => { const force = { target, strength(value) { this.value = value; return this; } }; axes.x.push(force); return force; }, + forceY: target => { const force = { target, strength(value) { this.value = value; return this; } }; axes.y.push(force); return force; }, + forceCollide: () => ({ iterations(value) { this.value = value; return this; } }), + }; + const api = G.create(el, {}); + api.setPreset('compact'); + api.setRenderMode('full'); + // Keep this below the responsive full-graph ceiling. Larger full graphs deliberately + // take the deterministic, centred layout so a complete workspace cannot lock the UI. + api.setData(chain(400)); + api.setSettings({ gravity: 98 }); + const nodes = store.graphData.nodes; + emit({ + mode: api.state().renderMode, + x: { target: typeof axes.x.at(-1).target === 'function' ? axes.x.at(-1).target(nodes[0]) : axes.x.at(-1).target, value: axes.x.at(-1).value }, + y: { target: typeof axes.y.at(-1).target === 'function' ? axes.y.at(-1).target(nodes[0]) : axes.y.at(-1).target, value: axes.y.at(-1).value }, + reheat: invocations.d3ReheatSimulation || 0, + cooldown: store.cooldownTime, + pinned: nodes.filter(node => node.fx !== undefined || node.fy !== undefined).length, + }); + """ + ) + assert report["mode"] == "full" + assert report["x"] == {"target": 0, "value": 0.98} + assert report["y"] == {"target": 0, "value": 0.98} + assert report["reheat"] == 0, "soft alpha updates must not invoke the unbounded full reheat path" + assert report["cooldown"] == 1100 + assert report["pinned"] == 0 + + +@requires_node +def test_full_graph_beyond_responsive_force_budget_is_centred_and_responds_to_gravity() -> None: + """A complete graph past the responsive budget takes the centred static fallback. + + Above the live-force ceiling the deterministic layout protects responsiveness. Its + geometry is nevertheless a centred grid whose compactness follows the same gravity input, + so the user retains a meaningful correction even for a very large workspace. + """ + report = _run_engine( + """ + const span = nodes => Math.max(...nodes.map(node => node.x)) - Math.min(...nodes.map(node => node.x)); + const api = G.create(el, {}); + api.setPreset('compact'); + api.setRenderMode('full'); + // `chain` supplies N+1 nodes, so this is one past the live-force ceiling. + api.setData(chain(600)); + const before = span(store.graphData.nodes); + const reheatBefore = invocations.d3ReheatSimulation || 0; + api.setSettings({ gravity: 400 }); + const nodes = store.graphData.nodes; + emit({ + before, after: span(nodes), + reheat: (invocations.d3ReheatSimulation || 0) - reheatBefore, + pinned: nodes.filter(node => Number.isFinite(node.fx) && Number.isFinite(node.fy)).length, + total: nodes.length, + cooldown: store.cooldownTime, + }); + """ + ) + assert report["after"] < report["before"] * 0.5 + assert report["reheat"] == 0 + assert report["pinned"] == report["total"] == 601 + assert report["cooldown"] == 0 + + +@requires_node +def test_curves_arrows_and_relation_labels_are_dropped_on_a_dense_graph() -> None: + """Three per-edge costs the classic path turns off past ``GPERF.dense`` (links > 1500). + + A curved link is a quadratic bezier instead of a straight line, an arrowhead is a filled + triangle, and a relation label is a text layout — each per relation, each every frame. At + this density they are unreadable anyway, so the classic renderer pays for none of them. + """ + report = _run_engine( + LAY_OUT + + """ + const api = G.create(el, { reducedMotion: () => true }); + api.setSettings({ labels: true }); + + api.setData(chain(1500)); + const atLimit = { + curve: store.linkCurvature, arrow: store.linkDirectionalArrowLength, + }; + + api.setData(chain(1501)); + const overLimit = { + curve: store.linkCurvature, arrow: store.linkDirectionalArrowLength, + }; + // One laid-out relation is enough to drive the label painter at this size. + const data = layOut(); + data.links[0].label = 'mentions'; + const denseUnhighlighted = paintLinks(4, [data.links[0]]); + store.onNodeHover(data.nodes[0]); + const denseHighlighted = paintLinks(4, [data.links[0]]); + emit({ atLimit, overLimit, denseUnhighlighted, denseHighlighted }); + """ + ) + # 1500 links is the classic threshold itself, so nothing is dropped yet. + assert report["atLimit"]["curve"] == 0.12 + assert report["atLimit"]["arrow"] == 0.625 + assert report["overLimit"]["curve"] == 0 + assert report["overLimit"]["arrow"] == 0 + # Relation labels come back for the one neighbourhood the user is actually pointing at. + assert report["denseUnhighlighted"] == [] + assert report["denseHighlighted"] == ["mentions"] + + +#: A ``d3`` stand-in for the force constructors ``applyForces()`` reaches for. The asset reads +#: ``d3`` as a free variable, so assigning it on ``globalThis`` is what the browser's global +#: script tag does; without it ``applyForces()`` returns before it ever configures collision. +D3_STUB = """ +let collide = null; +globalThis.d3 = { + forceX: () => ({ strength: () => ({}) }), + forceY: () => ({ strength: () => ({}) }), + forceRadial: () => ({ strength: () => ({}) }), + forceCollide: radius => ({ radius, iterations(n) { collide = { radius, iterations: n }; return this; } }), +}; +""" + + +@requires_node +def test_layout_presets_use_distinct_force_geometry() -> None: + """Each layout button must install a visibly different arrangement strategy.""" + + for dashboard in (DASHBOARD, CLASSIC_DASHBOARD): + classic_forces = dashboard.read_text(encoding="utf-8") + forces = classic_forces[classic_forces.index("function graphApplyForces()") : classic_forces.index("function graphSetHighlight(")] + assert "if(mode==='communities')" in forces + assert "else if(mode==='radial'&&d3.forceRadial)" in forces + assert "else if(mode==='constellation')" in forces + + report = _run_engine( + """ + const targets = { x: [], y: [], radial: [] }; + const force = target => ({ target, strengthValue: null, strength(value) { + if (arguments.length) { this.strengthValue = value; return this; } + return this.strengthValue; + } }); + globalThis.d3 = { + forceX: target => { targets.x.push(target); return force(target); }, + forceY: target => { targets.y.push(target); return force(target); }, + forceRadial: target => { targets.radial.push(target); return force(target); }, + forceCollide: () => ({ iterations: () => ({}) }), + }; + const api = G.create(el, { reducedMotion: () => true }); + api.setData({ + nodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }, { id: 'e' }, { id: 'f' }], + links: [ + { source: 'a', target: 'b' }, { source: 'a', target: 'c' }, { source: 'a', target: 'd' }, + { source: 'e', target: 'f' }, + ], + }); + const read = mode => { + targets.x = []; targets.y = []; targets.radial = []; + api.setPreset(mode); + const xForce = store.d3Forces.x, radialForce = store.d3Forces.radial; + const nodes = store.graphData.nodes; + const point = node => typeof xForce.target === 'function' ? xForce.target(node) : xForce.target; + return { + xKind: typeof xForce.target, + xStrength: xForce.strengthValue, + first: point(nodes[0]), + second: point(nodes[nodes.length - 1]), + radial: radialForce ? radialForce.target(nodes[0]) : null, + radialOuter: radialForce ? radialForce.target(nodes[nodes.length - 1]) : null, + }; + }; + emit({ + compact: read('compact'), original: read('original'), communities: read('communities'), + radial: read('radial'), constellation: read('constellation'), + }); + """ + ) + assert report["compact"]["first"] == 0 + assert report["original"]["first"] == 0 + assert report["compact"]["xStrength"] > report["original"]["xStrength"] + # Communities mode keeps a gentle origin-based centering: a function target at a + # distant grid slot would fight an explicit drag (the e2e drag-release contract), + # so the mode's visible grouping comes from the charge/repel geometry instead. + assert report["communities"]["xKind"] == "number" + assert report["communities"]["first"] == 0 + assert report["radial"]["radial"] is not None + assert report["radial"]["radial"] < report["radial"]["radialOuter"] + assert report["constellation"]["xKind"] == "function" + assert report["constellation"]["first"] != 0 + + +@requires_node +def test_collision_runs_one_pass_on_a_large_graph_like_the_classic_renderer() -> None: + """``forceCollide().iterations(2)`` is a second full quadtree traversal per node per tick. + + ``graphApplyForces()`` on the classic path spends it only when it is affordable + (``.iterations(GPERF.large?1:2)``). The opt-in engine computes the same ``large`` signal for + its cooldown and alpha-decay constants but was pinning two iterations regardless, so the one + case where the extra pass hurts most — the initial layout and every reheat of a big store — + was the case that paid for it twice over. + """ + report = _run_engine( + D3_STUB + + """ + const api = G.create(el, { reducedMotion: () => true }); + api.setPreset('compact'); + + api.setData(chain(40)); + const small = collide.iterations; + + // 601 entities / 600 relations — one past the classic renderer's 600-node cutoff. + api.setData(chain(600)); + const big = collide.iterations; + + // A slider move re-runs applyForces() on the running simulation; it must not undo this. + api.setSettings({ repel: 90 }); + const afterSlider = collide.iterations; + emit({ small, big, afterSlider, radiusIsAFunction: typeof collide.radius === 'function' }); + """ + ) + assert report["small"] == 2 + assert report["big"] == 1, "a large graph still runs two collision passes per tick" + assert report["afterSlider"] == 1, "a slider move restored the expensive collision pass" + # Guards the whole call rather than the argument in isolation: a per-node radius, not a + # constant, is what makes collision agree with the sizes the renderer actually painted. + assert report["radiusIsAFunction"] is True + + +#: Counts the gradient and blur primitives independently. They are per node, per frame, so the +#: large-graph branch must never rebuild them hundreds of times during a layout tick. +GLOW_CANVAS_STUB = """ +let gradients = 0, blurs = 0, fills = 0; +const ctx = { + globalAlpha: 1, globalCompositeOperation: '', strokeStyle: '', lineWidth: 1, font: '', + textBaseline: '', shadowColor: '', + set shadowBlur(v) { if (v) blurs += 1; }, + get shadowBlur() { return 0; }, + set fillStyle(v) {}, get fillStyle() { return ''; }, + save() {}, restore() {}, beginPath() {}, arc() {}, ellipse() {}, stroke() {}, + setLineDash() {}, fillText() {}, + fill() { fills += 1; }, + createRadialGradient() { gradients += 1; return { addColorStop() {} }; }, + createLinearGradient() { gradients += 1; return { addColorStop() {} }; }, +}; +const paintNodes = () => { + gradients = 0; blurs = 0; fills = 0; + const draw = store.nodeCanvasObject; + store.graphData.nodes.forEach((n, i) => { n.x = i * 10; n.y = i; draw(n, ctx, 4); }); + return { gradients, blurs, fills }; +}; +""" + + +@requires_node +@pytest.mark.parametrize("style", ["galaxy", "solar"]) +def test_per_node_glow_is_dropped_on_a_large_graph(style: str) -> None: + """Every ``rich`` node was getting a bloom or a gradient on every frame, at any size. + + The classic renderer gates all three of them on ``!GPERF.large`` — the galaxy halo, the solar + corona and its sphere shading. A radial gradient is a fresh object per node; at the >600-node + cutoff that is hundreds rebuilt per tick, on top of the layout, which is what made a dense + workspace crawl even after the other large-graph optimisations kicked in. + + ``fills`` is the control: the nodes are still being drawn, so a zero glow count means the + effect was skipped, not that the paint never ran. + """ + report = _run_engine( + GLOW_CANVAS_STUB + + f""" + const api = G.create(el, {{ reducedMotion: () => true }}); + api.setStyle("{style}"); + + api.setData(chain(40)); + const small = paintNodes(); + + api.setData(chain(600)); + const big = paintNodes(); + emit({{ small, big }}); + """ + ) + small, big = report["small"], report["big"] + assert small["fills"] > 0 and big["fills"] > 0, "canvas stub never reached the node painter" + assert small["gradients"] + small["blurs"] > 0, "the small graph lost its glow entirely" + assert big["gradients"] == 0, f"{style} still builds a radial gradient per node when large" + assert big["blurs"] == 0, f"{style} still shadow-blurs every node when large" + + +@requires_node +def test_material_recipes_keep_four_fixed_families_and_only_react_at_the_edges() -> None: + """A graph palette is an identity accent, not a licence to repaint every alloy the same. + + This replaces the old gradient-stop counts: those merely documented one shared thin-film + painter. The pure recipe seam makes the intended material contract directly testable. + """ + report = _run_node( + """ + const slate = { accent: '#a39bf1', surface: '#16191f', canvas: '#0b0d13' }; + const matrix = { accent: '#3ce072', surface: '#04140a', canvas: '#020703' }; + const make = (theme, palette, identity) => Object.fromEntries( + ['cyber', 'galaxy', 'solar', 'classic'].map(style => + [style, I.materialRecipe(style, theme, palette, identity)])); + emit({ slate: make(slate, 'ocean', '#37bde4'), matrix: make(matrix, 'ember', '#f59e55') }); + """ + ) + slate, matrix = report["slate"], report["matrix"] + assert {recipe["family"] for recipe in slate.values()} == { + "iridescent-pvd", "anodized-alloy", "brushed-copper", "satin-gunmetal" + } + assert slate["cyber"]["film"] == slate["cyber"]["fixedPalette"] + assert len(slate["cyber"]["film"]) >= 4 + # Fixed material signatures survive a theme/palette switch; only the substrate/identity + # inputs may react. Solar must never inherit Cyber's cyan/magenta spectrum. + for style in slate: + assert slate[style]["family"] == matrix[style]["family"] + assert slate[style]["fixedPalette"] == matrix[style]["fixedPalette"] + assert slate[style]["substrate"] != matrix[style]["substrate"] + assert slate[style]["identity"] != matrix[style]["identity"] + assert "#19d8ed" not in {value.lower() for value in slate["solar"]["fixedPalette"]} + + +@requires_node +def test_material_tiers_are_screen_space_not_graph_size_heuristics() -> None: + report = _run_node( + """ + emit({ + tiny: I.materialTier(4), bezel: I.materialTier(8), full: I.materialTier(16), + exactLow: I.materialTier(5.99), exactBezel: I.materialTier(6), + exactFull: I.materialTier(12), forced: I.materialTier(32, true), + }); + """ + ) + assert report == { + "tiny": "signature", "bezel": "bezel", "full": "full", + "exactLow": "signature", "exactBezel": "bezel", "exactFull": "full", + "forced": "signature", + } + + +@requires_node +def test_galaxy_parent_bodies_keep_full_material_without_promoting_small_systems_to_stars() -> None: + report = _run_node( + """ + const gradient = () => ({ addColorStop() {} }); + const ctx = { + save() {}, restore() {}, beginPath() {}, closePath() {}, arc() {}, fill() {}, stroke() {}, + moveTo() {}, lineTo() {}, drawImage() {}, scale() {}, + createLinearGradient: gradient, createRadialGradient: gradient, + createConicGradient: gradient, setLineDash() {}, + globalAlpha: 1, globalCompositeOperation: 'source-over', + lineWidth: 1, fillStyle: '', strokeStyle: '', shadowBlur: 0, shadowColor: '', + }; + I.setMaterialCanvasFactory(() => null); + const recipe = I.materialRecipe( + 'solar', { accent: '#a39bf1', surface: '#16191f' }, 'ember', '#d78242' + ); + const lanes = [ + { anchorId: 'star', members: 3 }, + { anchorId: 'planet-with-moon', members: 1 }, + { anchorId: 'leaf', members: 0 }, + ]; + emit({ + parentTier: I.paintMaterialSurface(ctx, 0, 0, 4, 1, recipe, true, true), + leafTier: I.paintMaterialSurface(ctx, 0, 0, 4, 1, recipe, true, false), + primaries: [...I.galaxyPrimaryAnchorIds(lanes)].sort(), + stars: [...I.galaxyStarAnchorIds(lanes)].sort(), + }); + """ + ) + + assert report == { + "parentTier": "full", + "leafTier": "signature", + "primaries": ["planet-with-moon", "star"], + "stars": ["star"], + } + source = ASSET.read_text(encoding="utf-8") + style_node = source[source.index("function styleNode"): + source.index("function paintNodeLabel")] + assert "materialLow, galaxyPrimary" in style_node + assert "materialLow, true" in style_node + + +@requires_node +def test_material_colour_invariants_are_distinct_and_deterministic() -> None: + """Pin visual intent in RGB rather than vendor-specific gradient primitive counts.""" + report = _run_node( + """ + const theme = { accent: '#a39bf1', surface: '#16191f', canvas: '#0b0d13' }; + const sample = style => ['top', 'center', 'bottom'].map(position => + I.sampleMaterialColour(style, position, '#37bde4', theme)); + emit({ once: Object.fromEntries(['cyber', 'galaxy', 'solar', 'classic'].map(s => [s, sample(s)])), + twice: Object.fromEntries(['cyber', 'galaxy', 'solar', 'classic'].map(s => [s, sample(s)])) }); + """ + ) + assert report["once"] == report["twice"], "static materials must not rotate or flicker" + cyber_top, _, cyber_bottom = report["once"]["cyber"] + galaxy = report["once"]["galaxy"][1] + solar = report["once"]["solar"][1] + classic = report["once"]["classic"][1] + assert cyber_top[0] > cyber_bottom[0] and cyber_bottom[1] > cyber_top[1], ( + "Cyber must retain the fixed warm/magenta-top, cyan-lower iridescent direction" + ) + assert galaxy[2] > galaxy[0] and galaxy[2] > galaxy[1], "Galaxy must read blue/violet" + assert solar[0] > solar[1] > solar[2], "Solar must read as warm copper, never cyan" + assert max(classic[:3]) - min(classic[:3]) <= 55, "Classic must remain low-saturation steel" + + +@requires_node +def test_material_cache_is_bounded_and_warm_repaints_allocate_nothing() -> None: + report = _run_node( + """ + const gradient = () => ({ addColorStop() {} }); + const ctx = { + save() {}, restore() {}, beginPath() {}, closePath() {}, arc() {}, fill() {}, stroke() {}, + clearRect() {}, fillRect() {}, translate() {}, rotate() {}, scale() {}, clip() {}, + createLinearGradient: gradient, createRadialGradient: gradient, createConicGradient: gradient, + setLineDash() {}, drawImage() {}, globalAlpha: 1, globalCompositeOperation: 'source-over', + lineWidth: 1, fillStyle: '', strokeStyle: '', shadowBlur: 0, shadowColor: '', + }; + I.setMaterialCanvasFactory(() => ({ width: 0, height: 0, getContext: () => ctx })); + I.clearMaterialCache(true); + const options = { style: 'cyber', radius: 16, dpr: 2, + identity: '#37bde4', themeColors: { accent: '#a39bf1', surface: '#16191f' } }; + I.renderMaterialSample(options); + const cold = I.materialCacheStats(); + I.renderMaterialSample(options); + const warm = I.materialCacheStats(); + for (let n = 0; n < cold.limit + 3; n += 1) { + I.renderMaterialSample({ ...options, identity: '#' + n.toString(16).padStart(6, '0') }); + } + const saturated = I.materialCacheStats(); + I.setMaterialCanvasFactory(null); + emit({ cold, warm, saturated }); + """ + ) + assert report["cold"]["allocations"] == 1 + assert report["warm"]["allocations"] == report["cold"]["allocations"] + assert report["warm"]["hits"] > report["cold"]["hits"] + assert report["saturated"]["size"] <= report["saturated"]["limit"] + assert report["saturated"]["evictions"] > 0 + + +@requires_node +def test_material_cache_is_invalidated_by_theme_palette_style_and_dpr_changes() -> None: + report = _run_engine( + """ + const gradient = () => ({ addColorStop() {} }); + const ctx = { + save() {}, restore() {}, beginPath() {}, closePath() {}, arc() {}, fill() {}, stroke() {}, + clearRect() {}, fillRect() {}, translate() {}, rotate() {}, scale() {}, clip() {}, + createLinearGradient: gradient, createRadialGradient: gradient, createConicGradient: gradient, + setLineDash() {}, drawImage() {}, globalAlpha: 1, globalCompositeOperation: 'source-over', + lineWidth: 1, fillStyle: '', strokeStyle: '', shadowBlur: 0, shadowColor: '', + }; + I.setMaterialCanvasFactory(() => ({ width: 0, height: 0, getContext: () => ctx })); + I.clearMaterialCache(true); + const sample = dpr => I.renderMaterialSample({ style: 'cyber', radius: 16, dpr, + identity: '#37bde4', themeColors: { accent: '#a39bf1', surface: '#16191f' } }); + sample(1); const populated = I.materialCacheStats(); + const api = G.create(el, { reducedMotion: () => true }); + api.setData(chain(2)); + api.setThemeColors({ accent: '#3ce072', surface: '#04140a' }); + const themed = I.materialCacheStats(); + sample(1); api.setPalette('ember'); const paletted = I.materialCacheStats(); + sample(1); api.setStyle('solar'); const styled = I.materialCacheStats(); + sample(1); sample(2); const dprChanged = I.materialCacheStats(); + I.setMaterialCanvasFactory(null); + emit({ populated, themed, paletted, styled, dprChanged }); + """ + ) + assert report["populated"]["size"] > 0 + for name in ("themed", "paletted", "styled"): + assert report[name]["size"] == 0, f"{name} material update retained stale sprites" + assert report["dprChanged"]["size"] == 1 + assert report["dprChanged"]["clears"] >= 4 + + +@requires_node +def test_material_fallback_without_conic_gradient_still_paints() -> None: + report = _run_node( + """ + const gradient = () => ({ addColorStop() {} }); + let fills = 0; + const ctx = { + save() {}, restore() {}, beginPath() {}, closePath() {}, arc() {}, stroke() {}, + fill() { fills += 1; }, clearRect() {}, fillRect() {}, translate() {}, rotate() {}, clip() {}, + createLinearGradient: gradient, createRadialGradient: gradient, + lineWidth: 1, fillStyle: '', strokeStyle: '', globalAlpha: 1, shadowBlur: 0, shadowColor: '', + }; + const recipe = I.materialRecipe('cyber', { accent: '#a39bf1', surface: '#16191f' }, 'ocean', '#37bde4'); + I.paintMaterialDirect(ctx, 20, 20, 16, recipe, 'full'); + emit({ fills }); + """ + ) + assert report["fills"] > 0 + + +@requires_node +@pytest.mark.parametrize("style", ["cyber", "galaxy", "solar", "classic"]) +def test_all_metal_styles_keep_the_large_graph_canvas_path_cheap(style: str) -> None: + """Material richness must not turn into a per-node shader workload above the cutoff.""" + report = _run_engine( + GLOW_CANVAS_STUB + + f""" + const api = G.create(el, {{ reducedMotion: () => true }}); + api.setStyle('{style}'); + api.setData(chain(600)); + emit(paintNodes()); + """ + ) + assert report["fills"] > 0 + assert report["gradients"] == 0, f"{style} creates per-node gradients in a large graph" + assert report["blurs"] == 0, f"{style} creates per-node blur in a large graph" + + +def test_legacy_classic_canvas_uses_the_same_nonwhite_material_profiles_as_ledger() -> None: + """Classic's no-flag renderer is distinct from Ledger's engine and must not drift. + + The user can switch between Ledger and `/classic`, while Classic also retains a direct + force-graph path for installations that do not opt into the newer engine. Both copies need + the material profile rather than Classic silently returning to white-centred flat discs. + """ + def material_block(path: Path) -> str: + source = path.read_text(encoding="utf-8") + start = source.index("function graphRgb(") + return source[start:source.index("function graphApplyStyleChrome()", start)] + + static = material_block(DASHBOARD) + classic = material_block(CLASSIC_DASHBOARD) + assert static == classic, "the classic dashboard material painter drifted from its fallback" + assert "function graphMaterialProfile(style,col)" in classic + assert "function graphPaintMaterialSurface(" in classic + assert "function graphMaterialTier(" in classic + assert "function graphMaterialSprite(" in classic + assert "graphMaterialProfile('cyber',col)" in classic + assert "graphMaterialProfile('galaxy',col)" in classic + assert "graphMaterialProfile('solar'" in classic + assert "graphMaterialProfile('classic',col)" in classic + assert "GRAPH_MATERIAL_CACHE_LIMIT=192" in classic + assert "ctx.drawImage(sprite.canvas" in classic + assert "#eafcff" not in classic + assert "rgba(255,255,255" not in classic + assert "graphIridescent(" not in classic + for marker in ( + "family:'iridescent-pvd'", + "family:'anodized-alloy'", + "family:'brushed-copper'", + "family:'satin-gunmetal'", + ): + assert marker in classic + assert marker.replace(":'", ": '") in ASSET.read_text(encoding="utf-8") + # The fallback selects the gradient-free signature recipe before building/painting a + # sprite, so hundreds of nodes keep their material identity without per-node shaders. + paint = classic[ + classic.index("function graphPaintMaterialSurface("): + classic.index("function graphStyleBackground(") + ] + assert "graphMaterialTier(screenRadius,large)" in paint + assert "paintDirect&&tier==='full'&&screenRadius>GRAPH_MATERIAL_RADIUS.full" in paint + assert "directMaterial=node.id===GHILITE||node.rank===0" in classic + full_classic = CLASSIC_DASHBOARD.read_text(encoding="utf-8") + style_node = full_classic[full_classic.index("function graphStyleNode("):full_classic.index("function graphApplyStyleChrome()")] + assert "graphPaintMaterialSurface(ctx,node.x,node.y,r,scale,profile,GPERF.large,directMaterial)" in style_node + assert "graphPaintMaterialSurface(ctx,node.x,node.y,r,scale,profile,GPERF.large)" not in style_node + assert classic.count("if(tier==='signature')") >= 4 + + +def test_legacy_node_geometry_is_bounded_like_ledger_for_all_styles() -> None: + """Classic must not resurrect the degree-squared visual blow-up behind the style switch. + + The material painter is shared across four styles, so a geometry regression here affects + every theme even when the newer Ledger engine is correct. Keep the two legacy copies in + lockstep and pin the compact radius contract: normalized degree emphasis, a 0.8 minimum, + and a size-slider-relative 1.1 maximum. + """ + classic = CLASSIC_DASHBOARD.read_text(encoding="utf-8") + static = DASHBOARD.read_text(encoding="utf-8") + helper_start = classic.index("function graphNodeRadius(") + helper_end = classic.index("const ETYPE_TOKEN", helper_start) + assert static[static.index("function graphNodeRadius("):static.index("const ETYPE_TOKEN", static.index("function graphNodeRadius("))] == classic[helper_start:helper_end] + assert "const maxDegree=Math.max(1,...nodes.map(node=>node.degree||0));" in classic + assert "graphNodeRadius(node,window.GSET.size,(node.degree||0)/maxDegree)" in classic + assert "return Math.max(.8,Math.min(size*1.1,radius));" in classic + assert "Math.sqrt(node.val)" not in classic + assert "Math.sqrt(node.val)" not in static + + + +def test_classic_dashboard_uses_the_every_node_asset_not_the_removed_all_asset() -> None: + """Classic may opt into Every-node, but must not reference the removed asset.""" + source = CLASSIC_DASHBOARD.read_text(encoding="utf-8") + assert "loadAllGraphEngine" in source + assert "ALL_GRAPH_ENGINE_LOADING" in source + assert "EngraphisEveryGraph" in source + assert "engraphis-graph-every.js" in source + assert "EngraphisAllGraph" not in source + assert "engraphis-graph-all.js" not in source + + +def test_classic_graph_controls_have_no_freeze_or_orbit_pause_in_full_mode() -> None: + """Full-mode quality-only: Freeze and orbit-pause controls are hidden; Relation flow remains. + + Classic never enters All mode, so this is a belt-and-braces guard: if the + All-mode concept ever leaks into Classic, the controls must not appear. + """ + source = CLASSIC_DASHBOARD.read_text(encoding="utf-8") + # Relation flow toggle must remain available in Classic. + assert "graph-show-iso" in source or "Show unlinked" in source + + +def test_ledger_recovery_copy_names_reload_data_and_real_filters_only() -> None: + """Recovery UI must say 'Reload data' and name only real, actionable filters.""" + source = PRIMARY_LEDGER.read_text(encoding="utf-8") + assert "Reload data" in source + assert "reload" in source.lower() + # Recovery must not reference phantom filters or placeholder actions. + assert "try something else" not in source.lower() + assert "check your settings" not in source.lower() + + +def test_ledger_renderer_transition_is_transactional_with_candidate_staging() -> None: + """Renderer swaps stage a candidate, await readiness, then atomically commit. + + Failure preserves the prior renderer and mode; success destroys the old one + only after the candidate is live. + """ + source = PRIMARY_LEDGER.read_text(encoding="utf-8") + assert "graph-canvas-candidate" in source + assert "candidateEngine" in source + assert "candidateHost" in source + assert "whenReady" in source + # The old host is retired only after the candidate is confirmed. + assert "graph-canvas-retired" in source + # Failure path restores the prior state. + assert "state.graphEngine.freeze(true)" in source + + +def test_ledger_toggle_labels_are_fixed_with_state_attributes() -> None: + """Toggle buttons keep fixed visible labels; ARIA state carries their value.""" + markup = PRIMARY_INDEX.read_text(encoding="utf-8") + assert 'id="graph-freeze"' in markup + freeze_section = markup.split('id="graph-freeze"', 1)[1][:500] + assert 'role="switch"' in freeze_section + assert 'aria-checked=' in freeze_section + + +def test_force_graph_and_engine_loaders_support_retry_after_failure() -> None: + """A failed asset load must not permanently memoize a rejected promise. + + The retry counter bumps the query string so the next attempt cannot join a + stalled browser request. A successful second load after a first failure must + reach the render loop. + """ + source = PRIMARY_LEDGER.read_text(encoding="utf-8") + loader = source[source.index("function ensureGraphAssets"): + source.index("function showNotice", + source.index("function ensureGraphAssets"))] + # Retry counter advances on failure. + assert "graphAssetsRetry = Math.min(graphAssetsRetry + 1, 10)" in loader + # Stale attempts are released so the next load gets a fresh fetch. + assert "releaseGraphAssetsAttempt" in loader + # The query string incorporates the retry count. + assert "graphAssetSource" in loader or "retry=" in loader + + + +def _community_palettes(source: str) -> dict: + """Parse a ``COMMUNITY_PALS`` literal out of either renderer.""" + # Anchor on the declaration: both files also name the table in prose comments. + match = re.search(r"COMMUNITY_PALS\s*=\s*\{", source) + assert match is not None, "COMMUNITY_PALS is not declared here" + block = source[match.end():source.index("};", match.end())] + return { + name: re.findall(r"#[0-9a-fA-F]{3,8}", body) + for name, body in re.findall(r"(\w+)\s*:\s*\[([^\]]*)\]", block) + } + + +def test_community_colours_match_the_dashboard_and_the_legend_swatches() -> None: + """The cluster legend is painted from CSS, so palette *order* is a contract, not a taste. + + ``graphRenderLegend`` sorts communities by size and gives the largest a + ``.graph-cluster-0`` swatch, while the canvas colours that same community with palette slot + 0. The swatch colours live in ``dashboard.css`` and encode the Cyber palette — the default + style — so a renderer whose slot 0 is a different colour makes the legend describe cluster 1 + with cluster 2's colour, on the default style, for every workspace. + """ + engine = _community_palettes(ASSET.read_text(encoding="utf-8")) + classic = _community_palettes(DASHBOARD.read_text(encoding="utf-8")) + assert engine, "COMMUNITY_PALS could not be parsed out of the engine" + assert engine == classic, "the opt-in renderer paints communities a different colour" + + swatches = dict( + re.findall(r"\.graph-cluster-(\d+)\{background:(#[0-9a-fA-F]{3,8})\}", + CSS.read_text(encoding="utf-8")) + ) + assert swatches, "the cluster legend swatches are missing from the stylesheet" + for index, colour in sorted(swatches.items()): + assert engine["cyber"][int(index)].lower() == colour.lower(), ( + f"legend swatch {index} does not match the canvas colour for that cluster" + ) + + +# ── CSP, styling and lifecycle ────────────────────────────────────────────────────── + + +def test_pane_backgrounds_are_owned_by_css_not_by_the_asset() -> None: + """``style-src-attr 'none'`` forbids writing these onto the element.""" + css = CSS.read_text(encoding="utf-8") + source = ASSET.read_text(encoding="utf-8") + for style in ("galaxy", "solar", "cyber"): + assert f'#graph-net[data-graph-style="{style}"]' in css + assert "data-graph-style" in source + # The gradients must exist in exactly one place, or the two copies drift. + assert "radial-gradient" not in source + assert "linear-gradient" not in source + + +def test_hover_cursor_class_the_asset_toggles_exists_in_css() -> None: + css = CSS.read_text(encoding="utf-8") + source = ASSET.read_text(encoding="utf-8") + assert "engraphis-graph-node-hover" in source + assert ".engraphis-graph-node-hover" in css + + +def test_csp_gate_covers_the_graph_asset() -> None: + from scripts.externalize_dashboard_assets import EXTRA_SCRIPTS, check + + assert ASSET in EXTRA_SCRIPTS, "the graph engine must be inside the CSP drift gate" + check() + + +def test_engine_exposes_a_teardown_and_the_dashboard_drives_it() -> None: + source = ASSET.read_text(encoding="utf-8") + dashboard = DASHBOARD.read_text(encoding="utf-8") + for member in ("api.destroy", "api.pause", "api.resume", "api.resize"): + assert member in source + # force-graph keeps a rAF alive while resumed; leaving the view must park it. + assert "if(v==='graph')graphEngineResume();else graphEnginePause()" in dashboard + assert "GRAPH_ENGINE.destroy()" in dashboard + + +def test_manual_drag_controller_detaches_with_the_graph() -> None: + """Reopening Ledger must not leave stale pointer controllers on the shared pane.""" + source = ASSET.read_text(encoding="utf-8") + assert "let detachManualDrag = null;" in source + assert "el.addEventListener('pointerdown', beginManualDrag, true);" in source + assert "el.removeEventListener('pointerdown', beginManualDrag, true);" in source + assert "window.removeEventListener('pointermove', moveManualDrag, true);" in source + assert "event.type !== 'pointercancel'" in source + direct_click = source[source.index("} else if (event.type !== 'pointercancel') {"):] + direct_click = direct_click[:direct_click.index(" };", 1)] + assert direct_click.index("handleNodeClick(current.node);") < direct_click.index("suppressNodeClick();") + move = source[source.index("const moveManualDrag = event => {"):] + move = move[:move.index(" const beginManualDrag", 1)] + assert "if (!manualDrag.dragged)" in move + assert move.index("if (Math.hypot(dx, dy) < 3)") < move.index("const node = manualDrag.node;") + assert "node.x = node.fx = point.x + manualDrag.offsetX;" in move + assert "node.vx = 0;" not in move + begin = source[source.index("function beginNodeDrag(node) {"): + source.index("function finishNodeDrag(node) {")] + assert "node.vx = 0;" in begin + assert "node.vy = 0;" not in move + assert "node.vy = 0;" in begin + assert "node.fx = undefined;" in source + assert "node.fy = undefined;" in source + assert "activeDragLinks" not in source + assert "other.vx" not in move + assert "other.vy" not in move + teardown = source[source.index("api.destroy = () => {"):] + assert "if (detachManualDrag) { detachManualDrag(); detachManualDrag = null; }" in teardown + + +def test_graph_physics_updates_are_bounded_and_coalesced() -> None: + """Explicit slider changes coalesce while pointer placement has no wake mechanism.""" + source = ASSET.read_text(encoding="utf-8") + vendor = VENDOR.read_text(encoding="utf-8") + primary_vendor = PRIMARY_VENDOR.read_text(encoding="utf-8") + assert "const MIN_NODE_SPEED = 8;" in source + assert "const MAX_NODE_SPEED = 48;" in source + assert "function makeVelocityGuardForce()" in source + assert "fg.d3Force('velocityGuard', velocityGuardForce);" in source + assert ".enableNodeDrag(false)" in source + assert "node.fx = undefined;" in source + assert "node.fy = undefined;" in source + assert "function schedulePhysicsUpdate()" in source + assert "physicsReheatPending" in source + assert "cancelAutoFit();" in source + assert "function prepareReheat()" in source + assert "function supportsSoftAlpha()" in source + assert "function softReheat()" in source + assert "fg.d3AlphaTarget(SETTINGS_ALPHA_TARGET);" in source + assert "fg.resetCountdown();" in source + assert "softReheat();" in source + assert "DRAG_ALPHA_TARGET" not in source + assert "DRAG_SETTLE_DELAY_MS" not in source + assert "d3AlphaTarget" in vendor and "resetCountdown" in vendor + assert "d3AlphaTarget" in primary_vendor and "resetCountdown" in primary_vendor + + +def test_reduced_motion_is_honoured_by_the_opt_in_renderer() -> None: + source = ASSET.read_text(encoding="utf-8") + dashboard = DASHBOARD.read_text(encoding="utf-8") + assert "prefers-reduced-motion: reduce" in source + assert "opts.reducedMotion" in source + assert "reducedMotion:prefersReducedMotion" in dashboard + + +def test_graph_engine_is_syntactically_valid_when_node_is_installed() -> None: + if NODE is None: + pytest.skip("node is not installed") + result = subprocess.run( + [NODE, "--check", str(ASSET)], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + + +@requires_node +def test_repo_scope_is_case_insensitive_and_cached_outside_exports() -> None: + report = _run_engine( + """ + const api = G.create(el, { reducedMotion: () => true }); + api.setPreset('compact'); + api.setData({ + nodes: [ + { id: 'match', repo: 'Owner/Project', name: 'Target' }, + { id: 'other', repo: 'Elsewhere', name: 'Other' }, + ], + links: [{ source: 'match', target: 'other' }], + }); + api.setScope({ repo: ' OWNER/PROJECT ' }); + const exported = api.exportData(); + emit({ ids: exported.nodes.map(node => node.id), + stateRepo: api.state().repo, + serialized: JSON.stringify(exported) }); + """ + ) + assert report["ids"] == ["match"] + assert report["stateRepo"] == "owner/project" + assert "_searchText" not in report["serialized"] + + +@requires_node +def test_hidden_labels_skip_large_scene_ranking_work() -> None: + report = _run_engine( + """ + const api = G.create(el, { reducedMotion: () => true }); + api.setPreset('compact'); + api.setData(chain(120)); + api.setSettings({ labels: false }); + const originalSort = Array.prototype.sort; + let sorts = 0; + Array.prototype.sort = function (...args) { sorts += 1; return originalSort.apply(this, args); }; + api.setStyle('solar'); + const hidden = sorts; + api.setSettings({ labels: true }); + const visible = sorts - hidden; + Array.prototype.sort = originalSort; + emit({ hidden, visible }); + """ + ) + assert report["hidden"] == 0 + assert report["visible"] >= 1 + + +def test_pointer_hit_area_rejects_unpositioned_nodes() -> None: + source = ASSET.read_text(encoding="utf-8") + pointer = source[source.index(".nodePointerAreaPaint((node, color, ctx) => {"):] + pointer = pointer[:pointer.index(" })", 1)] + assert "!Number.isFinite(node.x)" in pointer + assert "!Number.isFinite(node.y)" in pointer + assert "Number.isFinite(node.radius)" in pointer diff --git a/tests/test_recall_arm_candidate_k_cap.py b/tests/test_recall_arm_candidate_k_cap.py new file mode 100644 index 00000000..33f78bd3 --- /dev/null +++ b/tests/test_recall_arm_candidate_k_cap.py @@ -0,0 +1,211 @@ +"""Tests for the optional ``ENGRAPHIS_RECALL_ARM_CANDIDATE_K`` latency knob. + +PR #171 widened the prompt-only first arm to ``candidate_k + min(250, candidate_k*3)`` +so a 49-fact corpus pays ~5x more matrix-vector cost on the new k=50 default. The +opt-in ``ENGRAPHIS_RECALL_ARM_CANDIDATE_K`` env var (and the matching constructor +kwarg ``arm_candidate_k_cap=``) lets an operator clamp that first-page widening +*and* the second-page ceiling for latency-sensitive deployments. + +Default behavior (no env, no kwarg) is unchanged. +""" +from __future__ import annotations + +import time + +from engraphis.backends import DeterministicEmbedder, NumpyVectorIndex +from engraphis.backends.reranker import IdentityReranker +from engraphis.core.interfaces import MemoryRecord, SearchFilter +from engraphis.core.recall import RecallEngine +from engraphis.core.store import Store + + +class _SemanticTestEmbedder(DeterministicEmbedder): + supports_semantic_search = True + embedding_mode = "semantic" + + +def _add(store, emb, wid, rid, text, **kw): + provenance = dict(kw.get("provenance") or { + "source": "test", "trusted": True, "review_state": "approved", + }) + if provenance.get("trusted") is True: + provenance.setdefault("review_state", "approved") + kw["provenance"] = provenance + return store.add_memory(MemoryRecord( + id="", content=text, workspace_id=wid, repo_id=rid, + embedding=emb.embed([text])[0], **kw, + )) + + +class _RecordingIndex: + """Vector-index double that records every arm size it was queried with.""" + + def __init__(self): + self.requested: list[int] = [] + self.records: list[tuple[str, float]] = [] + + def search(self, query, k, *, filter=None): + self.requested.append(int(k)) + # Return synthetic (id, score) pairs so the prompt-eligible path has + # something to score. Use distinct ids so the loop tests candidate count. + return [(f"mem_{i}", float(k - i)) for i in range(min(k, 4))] + + +def test_arm_candidate_k_cap_default_is_none(monkeypatch): + """Without the env var or kwarg the cap is unset and PR #171 is preserved.""" + monkeypatch.delenv("ENGRAPHIS_RECALL_ARM_CANDIDATE_K", raising=False) + eng = RecallEngine(Store(":memory:"), _SemanticTestEmbedder(256), + _RecordingIndex(), IdentityReranker()) + assert eng._arm_candidate_k_cap is None + + +def test_arm_candidate_k_cap_reads_env_var(monkeypatch): + """Operator-set env var populates the cap; whitespace and bad values are ignored.""" + monkeypatch.setenv("ENGRAPHIS_RECALL_ARM_CANDIDATE_K", " 50 ") + eng = RecallEngine(Store(":memory:"), _SemanticTestEmbedder(256), + _RecordingIndex(), IdentityReranker()) + assert eng._arm_candidate_k_cap == 50 + + monkeypatch.setenv("ENGRAPHIS_RECALL_ARM_CANDIDATE_K", "not-a-number") + eng = RecallEngine(Store(":memory:"), _SemanticTestEmbedder(256), + _RecordingIndex(), IdentityReranker()) + assert eng._arm_candidate_k_cap is None + + +def test_arm_candidate_k_cap_constructor_kwarg_overrides_env(monkeypatch): + monkeypatch.setenv("ENGRAPHIS_RECALL_ARM_CANDIDATE_K", "50") + eng = RecallEngine(Store(":memory:"), _SemanticTestEmbedder(256), + _RecordingIndex(), IdentityReranker(), arm_candidate_k_cap=64) + assert eng._arm_candidate_k_cap == 64 + + +def test_arm_candidate_k_cap_clamps_first_arm(monkeypatch): + """With cap=50, k=50 prompt-only first arm is 50 (was 200).""" + monkeypatch.setenv("ENGRAPHIS_RECALL_ARM_CANDIDATE_K", "50") + index = _RecordingIndex() + eng = RecallEngine(Store(":memory:"), _SemanticTestEmbedder(256), + index, IdentityReranker()) + store = eng.store + wid = store.get_or_create_workspace("w") + for i in range(60): + _add(store, eng.embedder, wid, None, f"fact {i}") + + result = eng.recall("fact 5", SearchFilter(workspace_id=wid), k=50, + candidate_k=50, prompt_only=True) + + # First arm is clamped to 50; without the cap it would be 200. + assert index.requested[0] == 50 + # candidate_k_used reflects the actual first-page widening. + assert result.candidate_k_used == 50 + # The result must still be non-empty: the cap must not regress recall on + # a trusted-only corpus. + assert result.count >= 1 + + +def test_arm_candidate_k_cap_clamps_ceiling_when_first_page_insufficient(monkeypatch): + """The second page must also be clamped so the escalation loop does not + silently undo the savings by jumping to PROMPT_ONLY_MIN_CANDIDATES.""" + monkeypatch.setenv("ENGRAPHIS_RECALL_ARM_CANDIDATE_K", "8") + index = _RecordingIndex() + eng = RecallEngine(Store(":memory:"), _SemanticTestEmbedder(256), + index, IdentityReranker()) + store = eng.store + wid = store.get_or_create_workspace("w") + for i in range(20): + _add(store, eng.embedder, wid, None, f"fact {i}") + + eng.recall("fact 0", SearchFilter(workspace_id=wid), k=1, + candidate_k=1, prompt_only=True) + + # First arm is 1 + min(250, 1*3) = 4, ceiling would normally escalate to + # PROMPT_ONLY_MIN_CANDIDATES=256; with cap=8 the ceiling must also be 8. + assert max(index.requested) <= 8 + assert all(requested <= 8 for requested in index.requested) + # Without the cap the index would have been queried with [4, 256]. + + +def test_arm_candidate_k_cap_floor_protects_one_fact_corpus(monkeypatch): + """The cap must not shrink the first arm below the caller's requested + candidate_k — that would silently under-search a one-fact scope.""" + monkeypatch.setenv("ENGRAPHIS_RECALL_ARM_CANDIDATE_K", "2") + index = _RecordingIndex() + eng = RecallEngine(Store(":memory:"), _SemanticTestEmbedder(256), + index, IdentityReranker()) + store = eng.store + wid = store.get_or_create_workspace("w") + for i in range(20): + _add(store, eng.embedder, wid, None, f"fact {i}") + + eng.recall("fact 0", SearchFilter(workspace_id=wid), k=1, + candidate_k=10, prompt_only=True) + + # First arm = max(formula=10+30=40, candidate_k=10) capped at 2 = max(2, 10) = 10. + assert index.requested[0] == 10 + + +def test_arm_candidate_k_cap_reduces_latency_at_k_50(monkeypatch): + """End-to-end latency check: cap=50 should be measurably faster than + the uncapped default at k=50, on a trusted 49-fact corpus, while still + returning the expected number of chunks. + + The 1.5x threshold is conservative; the actual speedup on the bundled + rebench was 1.9x (201ms -> 103ms) at cap=50. We deliberately use a + loose bound so this test stays stable across hardware and numpy builds. + """ + monkeypatch.delenv("ENGRAPHIS_RECALL_ARM_CANDIDATE_K", raising=False) + store = Store(":memory:") + emb = _SemanticTestEmbedder(256) + index = NumpyVectorIndex(store) + eng_uncapped = RecallEngine(store, emb, index, IdentityReranker()) + wid = store.get_or_create_workspace("w") + base = ( + "Project Aurora uses Postgres for durable storage. Authentication uses PASETO. " + "The deploy pipeline runs unit and integration tests with a canary release." + ) + for i in range(49): + _add(store, emb, wid, None, f"{base} fact_index={i} workstream={i % 5}") + flt = SearchFilter(workspace_id=wid) + query = "What storage and auth systems does Project Aurora use?" + + def mean_ms(eng): + # Two warmups then 11 timed samples to smooth GC and embedder warmup. + for _ in range(2): + eng.recall(query, flt, k=50, candidate_k=50, prompt_only=True) + samples = [] + for _ in range(11): + t0 = time.perf_counter() + eng.recall(query, flt, k=50, candidate_k=50, prompt_only=True) + samples.append((time.perf_counter() - t0) * 1000.0) + samples.sort() + return sum(samples[2:-2]) / 7.0 # trimmed mean, drop 2 best and 2 worst + + uncapped_ms = mean_ms(eng_uncapped) + + # Capped engine on a fresh store; rebuilding the corpus keeps the latencies + # independent so the embedder cache state of the uncapped run cannot bias + # the timed mean. + monkeypatch.setenv("ENGRAPHIS_RECALL_ARM_CANDIDATE_K", "50") + store2 = Store(":memory:") + emb2 = _SemanticTestEmbedder(256) + eng_capped = RecallEngine(store2, emb2, NumpyVectorIndex(store2), + IdentityReranker()) + wid2 = store2.get_or_create_workspace("w") + for i in range(49): + _add(store2, emb2, wid2, None, f"{base} fact_index={i} workstream={i % 5}") + flt2 = SearchFilter(workspace_id=wid2) + capped_ms = mean_ms(eng_capped) + + # Sanity: the uncapped recall returns the full k=50 trusted chunks. + uncapped_result = eng_uncapped.recall(query, flt, k=50, candidate_k=50, + prompt_only=True) + capped_result = eng_capped.recall(query, flt2, k=50, candidate_k=50, + prompt_only=True) + assert uncapped_result.candidate_k_used == 200 + assert capped_result.candidate_k_used == 50 + # Recall quality must not regress on a trusted-only corpus. + assert capped_result.count == uncapped_result.count + # And latency must drop by at least 1.5x. + assert capped_ms < uncapped_ms / 1.5, ( + f"cap=50 did not yield the expected speedup: uncapped={uncapped_ms:.1f}ms " + f"capped={capped_ms:.1f}ms" + )