diff --git a/Makefile b/Makefile
index 2529909a..95856894 100644
--- a/Makefile
+++ b/Makefile
@@ -1,8 +1,41 @@
SHELL := /bin/bash
-.PHONY: hooks
+# Default docs port; override with: make docs PORT=5180
+PORT ?= 5173
+
+.PHONY: hooks docs docs-build docs-ci
+
hooks:
@git config core.hooksPath .githooks
@chmod +x .githooks/* 2>/dev/null || true
@echo "[hooks] Installed git hooks from .githooks (core.hooksPath)"
+# Start VitePress dev server and open the browser automatically.
+docs:
+ @echo "[docs] Ensuring deps..."
+ @npm install --silent
+ @echo "[docs] Starting VitePress on http://localhost:$(PORT) ..."
+ # Start server in background and record PID
+ @ (npm run --silent docs:dev -- --port $(PORT) &) ; \
+ server_pid=$$! ; \
+ echo "[docs] Waiting for server to become ready..." ; \
+ for i in {1..80}; do \
+ if curl -sSf "http://localhost:$(PORT)/" >/dev/null ; then \
+ echo "[docs] Server is up at http://localhost:$(PORT)/" ; \
+ scripts/docs-open.sh "http://localhost:$(PORT)/" ; \
+ wait $$server_pid ; \
+ exit 0 ; \
+ fi ; \
+ sleep 0.25 ; \
+ done ; \
+ echo "[docs] Timed out waiting for VitePress." ; \
+ exit 1
+
+# Build static site
+docs-build:
+ @npm run --silent docs:build
+
+# Build docs without installing dependencies (for CI caches)
+docs-ci:
+ @echo "[docs] CI build (no npm install)"
+ @npm run --silent docs:build
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
new file mode 100644
index 00000000..008c31a8
--- /dev/null
+++ b/docs/.vitepress/config.ts
@@ -0,0 +1,24 @@
+import { defineConfig } from 'vitepress'
+
+export default defineConfig({
+ title: 'Echo',
+ description: 'Deterministic, multiverse-aware ECS',
+ cleanUrls: true,
+ themeConfig: {
+ nav: [
+ { text: 'Home', link: '/' },
+ { text: 'Collision Tour', link: '/guide/collision-tour' },
+ ],
+ sidebar: {
+ '/guide/': [
+ {
+ text: 'Guide',
+ items: [
+ { text: 'Collision Tour', link: '/guide/collision-tour' },
+ ]
+ }
+ ]
+ }
+ }
+})
+
diff --git a/docs/assets/collision/README.md b/docs/assets/collision/README.md
new file mode 100644
index 00000000..b038b3fb
--- /dev/null
+++ b/docs/assets/collision/README.md
@@ -0,0 +1,33 @@
+# Collision/CCD DPO Diagrams
+
+- Base CSS: `diagrams.css`
+- Static SVGs:
+ - `dpo_build_temporal_proxy.svg`
+ - `dpo_broad_phase_pairing.svg`
+ - `dpo_narrow_phase_discrete.svg`
+ - `dpo_narrow_phase_ccd.svg`
+ - `dpo_contact_events.svg`
+ - `dpo_gc_ephemeral.svg`
+ - `scheduler_phase_mapping.svg`
+ - `legend.svg`
+
+Each SVG uses semantic classes:
+- Nodes: `node`, variants: `interfaceK`, `added`, `removed`
+- Edges: `edge`, variants: `added`, `removed`
+- Other: `title`, `label`, `caption`, `scope`
+- Optional animations: `pulse-add`, `pulse-remove`
+
+## Animations
+- Extend `diagrams.css` with your own `@keyframes` and apply classes to elements.
+- For step-by-step tutorials, use the `_step1.svg`, `_step2.svg`, `_step3.svg` variants or overlay multiple SVGs in the docs site.
+
+## Mermaid Sources
+- Source `.mmd` files live alongside these SVGs. You can generate SVGs locally:
+
+```
+# Requires Node.js
+npm i -g @mermaid-js/mermaid-cli
+mmdc -i build_temporal_proxy.mmd -o build_temporal_proxy.svg
+```
+
+CI also compiles `.mmd` files to SVG artifacts (see `.github/workflows/ci.yml` job `diagrams`).
diff --git a/docs/assets/collision/animate.js b/docs/assets/collision/animate.js
new file mode 100644
index 00000000..2f1ad95f
--- /dev/null
+++ b/docs/assets/collision/animate.js
@@ -0,0 +1,154 @@
+(function(){
+ const els = Array.from(document.querySelectorAll('.fade-seed'));
+ if (!('IntersectionObserver' in window)) {
+ els.forEach(el => el.classList.add('fade-in'));
+ return;
+ }
+ const io = new IntersectionObserver((entries) => {
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ entry.target.classList.add('fade-in');
+ io.unobserve(entry.target);
+ }
+ });
+ }, { rootMargin: '0px 0px -10% 0px', threshold: 0.1 });
+ els.forEach(el => io.observe(el));
+})();
+
+// Carousel pager for each rule's step-grid
+(function(){
+ function buildPager(rule) {
+ const grid = rule.querySelector('.step-grid');
+ if (!grid) return;
+ const slides = Array.from(grid.querySelectorAll('figure'));
+ if (slides.length < 2) return;
+
+ // Build overlay captions with step counts from figcaptions
+ slides.forEach((fig, i) => {
+ const cap = fig.querySelector('figcaption');
+ const text = cap ? cap.textContent.trim() : '';
+ const ov = document.createElement('div');
+ ov.className = 'overlay';
+ ov.innerHTML = `Step ${i + 1} of ${slides.length}
${text}
`;
+ fig.appendChild(ov);
+ fig.classList.add('has-overlay');
+ });
+
+ const nav = document.createElement('div');
+ nav.className = 'pager';
+ const prev = document.createElement('button');
+ prev.className = 'btn'; prev.textContent = '◀ Prev';
+ const next = document.createElement('button');
+ next.className = 'btn'; next.textContent = 'Next ▶';
+ const toggle = document.createElement('button');
+ toggle.className = 'btn'; toggle.textContent = 'Show all';
+ const world = document.createElement('button');
+ world.className = 'btn'; world.textContent = 'World view: On';
+ nav.append(prev, next, toggle, world);
+ grid.after(nav);
+
+ let mode = 'all'; // default to all frames visible
+ let idx = 0;
+
+ // Helpers to find neighbor rule blocks
+ const prevRule = (el) => { let r = el.previousElementSibling; while (r && !r.classList.contains('rule')) r = r.previousElementSibling; return r; };
+ const nextRule = (el) => { let r = el.nextElementSibling; while (r && !r.classList.contains('rule')) r = r.nextElementSibling; return r; };
+
+ function render() {
+ if (mode === 'all') {
+ slides.forEach(el => el.classList.remove('hidden'));
+ toggle.textContent = 'Carousel mode';
+ } else {
+ slides.forEach((el, i) => {
+ el.classList.toggle('hidden', i !== idx);
+ // ensure visible slide is faded in
+ if (!el.classList.contains('fade-in')) el.classList.add('fade-in');
+ });
+ toggle.textContent = 'Show all';
+ }
+ // Enable/disable edges. If at first slide and no previous rule, disable Prev.
+ // If at last slide and no next rule, disable Next.
+ if (mode === 'all') {
+ // Keep navigation enabled in 'all' mode so users/tests can enter carousel via Prev/Next.
+ prev.disabled = false; next.disabled = false;
+ } else {
+ const atFirst = idx === 0;
+ const atLast = idx === slides.length - 1;
+ prev.disabled = atFirst && !prevRule(rule);
+ next.disabled = atLast && !nextRule(rule);
+ }
+ }
+
+ prev.addEventListener('click', () => {
+ // If in all mode, enter carousel at first slide
+ if (mode === 'all') { mode = 'one'; idx = 0; render(); return; }
+ if (idx > 0) { idx -= 1; render(); return; }
+ // At first slide: navigate to previous rule, show its first slide
+ const pr = prevRule(rule);
+ if (pr && pr._pager) {
+ pr._pager.setIndex(0);
+ pr.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ render();
+ });
+ next.addEventListener('click', () => {
+ if (mode === 'all') { mode = 'one'; idx = 0; render(); return; }
+ if (idx < slides.length - 1) { idx += 1; render(); return; }
+ // At last slide: navigate to next rule, show its first slide
+ const nr = nextRule(rule);
+ if (nr && nr._pager) {
+ nr._pager.setIndex(0);
+ nr.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ render();
+ });
+ toggle.addEventListener('click', () => {
+ mode = (mode === 'all') ? 'one' : 'all';
+ render();
+ });
+
+ // Picture-in-picture container with tabs (World / Graph)
+ slides.forEach((fig) => {
+ const srcWorld = fig.getAttribute('data-pip');
+ const srcGraph = fig.getAttribute('data-graph');
+ if (!srcWorld && !srcGraph) return;
+ const wrap = document.createElement('div');
+ wrap.className = 'pip';
+ const tabs = document.createElement('div');
+ tabs.className = 'pip-tabs';
+ const tabWorld = document.createElement('div'); tabWorld.className = 'tab active'; tabWorld.textContent = 'World';
+ const tabGraph = document.createElement('div'); tabGraph.className = 'tab'; tabGraph.textContent = 'Graph';
+ tabs.append(tabWorld, tabGraph);
+ const imgWorld = document.createElement('img'); imgWorld.alt = 'World view'; if (srcWorld) imgWorld.src = srcWorld; else imgWorld.style.display='none';
+ const imgGraph = document.createElement('img'); imgGraph.alt = 'Graph view'; if (srcGraph) imgGraph.src = srcGraph; else imgGraph.style.display='none'; imgGraph.classList.add('hidden');
+ wrap.append(tabs, imgWorld, imgGraph);
+ fig.appendChild(wrap);
+ function show(which){
+ if (which==='world') { tabWorld.classList.add('active'); tabGraph.classList.remove('active'); imgWorld.classList.remove('hidden'); imgGraph.classList.add('hidden'); }
+ else { tabGraph.classList.add('active'); tabWorld.classList.remove('active'); imgGraph.classList.remove('hidden'); imgWorld.classList.add('hidden'); }
+ }
+ tabWorld.addEventListener('click', ()=>show('world'));
+ tabGraph.addEventListener('click', ()=>show('graph'));
+ });
+
+ let worldOn = true;
+ world.addEventListener('click', () => {
+ worldOn = !worldOn;
+ world.textContent = worldOn ? 'World view: On' : 'World view: Off';
+ slides.forEach(fig => {
+ const pip = fig.querySelector('.pip');
+ if (pip) pip.classList.toggle('hidden', !worldOn);
+ });
+ });
+
+ // Expose simple API for cross-rule navigation
+ rule._pager = {
+ setIndex: (i) => { mode = 'one'; idx = Math.max(0, Math.min(slides.length - 1, i)); render(); },
+ setMode: (m) => { mode = m; render(); },
+ };
+
+ render();
+ }
+
+ document.querySelectorAll('.rule').forEach(buildPager);
+})();
diff --git a/docs/assets/collision/broad_phase_pairing.mmd b/docs/assets/collision/broad_phase_pairing.mmd
new file mode 100644
index 00000000..8a1b9cb3
--- /dev/null
+++ b/docs/assets/collision/broad_phase_pairing.mmd
@@ -0,0 +1,17 @@
+flowchart LR
+ subgraph LHS
+ PA((TemporalProxy(a,n))):::KClass
+ PB((TemporalProxy(b,n))):::KClass
+ PA -- overlaps --> PB
+ end
+
+ subgraph RHS
+ PA2((TemporalProxy(a,n))):::KClass
+ PB2((TemporalProxy(b,n))):::KClass
+ PP[[PotentialPair(a,b,n)]]:::Add
+ PA2 -- pair_of --> PP
+ PB2 -- pair_of --> PP
+ end
+
+classDef KClass stroke:#ffd166,stroke-width:2;
+classDef Add stroke:#00c853,stroke-width:2,fill:#0d2a1a,color:#dfe7ff;
diff --git a/docs/assets/collision/build_temporal_proxy.mmd b/docs/assets/collision/build_temporal_proxy.mmd
new file mode 100644
index 00000000..719a4f3f
--- /dev/null
+++ b/docs/assets/collision/build_temporal_proxy.mmd
@@ -0,0 +1,19 @@
+flowchart LR
+ subgraph LHS
+ E[Collider(e)] --- T[Transform(e,n)]
+ V[Velocity(e)]
+ K[Tick(n)]
+ T -- at --> K
+ E -- produced_in --> K
+ end
+
+ subgraph RHS
+ E2[Collider(e)]:::KClass --- T2[Transform(e,n)]:::KClass
+ K2[Tick(n)]:::KClass
+ P((TemporalProxy(e,n))):::Add
+ E2 -- has_proxy --> P
+ P -- produced_in --> K2
+ end
+
+classDef KClass stroke:#ffd166,stroke-width:2;
+classDef Add stroke:#00c853,stroke-width:2,fill:#0d2a1a,color:#dfe7ff;
diff --git a/docs/assets/collision/contact_events.mmd b/docs/assets/collision/contact_events.mmd
new file mode 100644
index 00000000..8221e17c
--- /dev/null
+++ b/docs/assets/collision/contact_events.mmd
@@ -0,0 +1,12 @@
+flowchart LR
+ subgraph LHS
+ C1((Contact(pair,n-1))):::KClass
+ C2((Contact(pair,n))):::KClass
+ end
+ subgraph RHS
+ E((ContactEvent(kind,pair,n))):::Add
+ E -- event_of --> C2
+ end
+
+classDef KClass stroke:#ffd166,stroke-width:2;
+classDef Add stroke:#00c853,stroke-width:2,fill:#0d2a1a,color:#dfe7ff;
diff --git a/docs/assets/collision/diagrams.css b/docs/assets/collision/diagrams.css
new file mode 100644
index 00000000..7a7d7014
--- /dev/null
+++ b/docs/assets/collision/diagrams.css
@@ -0,0 +1,63 @@
+/* Echo collision/CCD DPO diagrams — base styles and optional animations */
+:root {
+ --bg: #0b1020;
+ --panel: #121a33;
+ --text: #dfe7ff;
+ --muted: #9fb0d6;
+ --edge: #6aa0ff;
+ --k: #ffd166;
+ --added: #00c853;
+ --removed: #ff5252;
+ --scope: #ffa600;
+}
+
+svg { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'Liberation Sans', sans-serif; }
+.panel { fill: var(--panel); rx: 8px; }
+.title { fill: var(--text); font-weight: 600; font-size: 16px; }
+.caption { fill: var(--muted); font-size: 12px; }
+.node rect { fill: #182347; stroke: #3356a6; stroke-width: 1.2; rx: 6px; }
+.node.interfaceK rect { fill: #2b2746; stroke: var(--k); stroke-width: 2; }
+.node.added rect { fill: #0d2a1a; stroke: var(--added); stroke-width: 2; }
+.node.removed rect { fill: #2a0d0d; stroke: var(--removed); stroke-width: 2; }
+.node text { fill: var(--text); font-size: 12px; }
+.edge line, .edge path { stroke: var(--edge); stroke-width: 1.6; fill: none; }
+.edge.added line, .edge.added path { stroke: var(--added); stroke-width: 2; }
+.edge.removed line, .edge.removed path { stroke: var(--removed); stroke-width: 2; }
+.edge text { fill: var(--muted); font-size: 11px; }
+.scope rect { fill: none; stroke: var(--scope); stroke-width: 2; stroke-dasharray: 6 4; rx: 8px; }
+.label { fill: var(--text); font-size: 13px; }
+
+/* Optional animation hooks */
+.pulse-add { animation: pulseAdd 1.6s ease-in-out infinite; }
+.pulse-remove { animation: pulseRemove 1.6s ease-in-out infinite; }
+@keyframes pulseAdd { 0%{opacity:0.5} 50%{opacity:1} 100%{opacity:0.5} }
+@keyframes pulseRemove { 0%{opacity:1} 50%{opacity:0.6} 100%{opacity:1} }
+
+/* Fade-in on scroll (tour page) */
+.fade-seed { opacity: 0; transform: translateY(12px); transition: opacity 400ms ease, transform 400ms ease; }
+.fade-in { opacity: 1; transform: translateY(0); }
+
+/* Carousel pager */
+.pager { display: flex; gap: 8px; margin-top: 8px; }
+.btn { background: #1b2446; color: var(--text); border: 1px solid #3356a6; border-radius: 6px; padding: 6px 10px; cursor: pointer; font-size: 12px; }
+.btn:hover { background: #223060; }
+.btn:disabled { opacity: 0.5; cursor: default; }
+.hidden { display: none; }
+
+/* Overlay caption inside figures */
+.has-overlay figcaption { display: none; }
+.overlay { position: absolute; left: 12px; right: 12px; bottom: 12px; background: rgba(18,26,51,0.85); border: 1px solid #3356a6; border-radius: 6px; padding: 8px 10px; backdrop-filter: blur(2px); }
+.overlay strong { color: var(--text); font-size: 12px; letter-spacing: 0.2px; }
+.overlay .ov-t { color: var(--muted); font-size: 12px; margin-top: 2px; }
+.has-overlay { padding-bottom: 68px; }
+.slide-explain { margin-top: 10px; font-size: 13px; color: var(--text); }
+.slide-explain p { margin: 0 0 6px; }
+.slide-explain ul { margin: 6px 0 0 18px; }
+.slide-explain li { margin: 3px 0; color: var(--muted); }
+
+/* Picture-in-picture world view */
+.pip { position: absolute; right: 12px; top: 12px; width: 32%; max-width: 240px; border: 1px solid #3356a6; background: #0b1020; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.35); overflow: hidden; }
+.pip img { width: 100%; display: block; }
+.pip-tabs { display: flex; gap: 0; border-bottom: 1px solid #2a3c72; }
+.pip-tabs .tab { flex: 1; text-align: center; font-size: 11px; color: var(--muted); padding: 6px 4px; cursor: pointer; background: #121a33; }
+.pip-tabs .tab.active { color: var(--text); background: #1b2446; }
diff --git a/docs/assets/collision/dpo_broad_phase_pairing.svg b/docs/assets/collision/dpo_broad_phase_pairing.svg
new file mode 100644
index 00000000..e1525e19
--- /dev/null
+++ b/docs/assets/collision/dpo_broad_phase_pairing.svg
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+ DPO: BroadPhasePairing (update)
+ LHS: overlapping TemporalProxy(a,n), TemporalProxy(b,n); RHS: add PotentialPair(a,b,n); K = both proxies
+
+
+ LHS
+
+ RHS
+
+
+ TemporalProxy(a,n)
+ TemporalProxy(b,n)
+
+ overlap (fat AABBs)
+
+
+ TemporalProxy(a,n)
+ TemporalProxy(b,n)
+ PotentialPair(a,b,n)
+
+
+
+
+
+
diff --git a/docs/assets/collision/dpo_broad_phase_pairing_step1.svg b/docs/assets/collision/dpo_broad_phase_pairing_step1.svg
new file mode 100644
index 00000000..8a55a290
--- /dev/null
+++ b/docs/assets/collision/dpo_broad_phase_pairing_step1.svg
@@ -0,0 +1,26 @@
+
+
+
+
+
+ BroadPhasePairing — Step 1: LHS
+ TemporalProxy(a,n)
+ TemporalProxy(b,n)
+ fat AABBs overlap
+
diff --git a/docs/assets/collision/dpo_broad_phase_pairing_step2.svg b/docs/assets/collision/dpo_broad_phase_pairing_step2.svg
new file mode 100644
index 00000000..759cfa34
--- /dev/null
+++ b/docs/assets/collision/dpo_broad_phase_pairing_step2.svg
@@ -0,0 +1,26 @@
+
+
+
+
+
+ BroadPhasePairing — Step 2: Interface K
+ TemporalProxy(a,n)
+ TemporalProxy(b,n)
+
+
diff --git a/docs/assets/collision/dpo_broad_phase_pairing_step3.svg b/docs/assets/collision/dpo_broad_phase_pairing_step3.svg
new file mode 100644
index 00000000..14ed7556
--- /dev/null
+++ b/docs/assets/collision/dpo_broad_phase_pairing_step3.svg
@@ -0,0 +1,26 @@
+
+
+
+
+
+ BroadPhasePairing — Step 3: RHS
+ TemporalProxy(a,n)
+ TemporalProxy(b,n)
+ PotentialPair(a,b,n)
+
diff --git a/docs/assets/collision/dpo_build_temporal_proxy.svg b/docs/assets/collision/dpo_build_temporal_proxy.svg
new file mode 100644
index 00000000..4f35c6e3
--- /dev/null
+++ b/docs/assets/collision/dpo_build_temporal_proxy.svg
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+ DPO: BuildTemporalProxy (pre_update)
+ LHS: Collider+Transform(+Velocity) at Tick n; RHS: add TemporalProxy(e,n) with fat AABB; K: Collider, Transform, Tick
+
+
+
+ LHS
+
+ RHS
+
+
+ Collider(e)
+ Transform(e,n)
+ Velocity(e)
+ Tick(n)
+
+
+
+
+ produced_in
+
+
+
+ at
+
+
+
+ Collider(e)
+ Transform(e,n)
+ Tick(n)
+
+
+ TemporalProxy(e,n)
+
+
+ has_proxy
+
+
+
+ produced_in
+
+
+
+
+
+
diff --git a/docs/assets/collision/dpo_build_temporal_proxy_step1.svg b/docs/assets/collision/dpo_build_temporal_proxy_step1.svg
new file mode 100644
index 00000000..d0e9642c
--- /dev/null
+++ b/docs/assets/collision/dpo_build_temporal_proxy_step1.svg
@@ -0,0 +1,27 @@
+
+
+
+
+
+ BuildTemporalProxy — Step 1: LHS
+ Collider(e)
+ Transform(e,n)
+ Velocity(e)
+ Tick(n)
+
diff --git a/docs/assets/collision/dpo_build_temporal_proxy_step2.svg b/docs/assets/collision/dpo_build_temporal_proxy_step2.svg
new file mode 100644
index 00000000..0add5c31
--- /dev/null
+++ b/docs/assets/collision/dpo_build_temporal_proxy_step2.svg
@@ -0,0 +1,27 @@
+
+
+
+
+
+ BuildTemporalProxy — Step 2: Interface K
+ Collider(e)
+ Transform(e,n)
+ Tick(n)
+
+
diff --git a/docs/assets/collision/dpo_build_temporal_proxy_step3.svg b/docs/assets/collision/dpo_build_temporal_proxy_step3.svg
new file mode 100644
index 00000000..a667846a
--- /dev/null
+++ b/docs/assets/collision/dpo_build_temporal_proxy_step3.svg
@@ -0,0 +1,27 @@
+
+
+
+
+
+ BuildTemporalProxy — Step 3: RHS
+ Collider(e)
+ Transform(e,n)
+ Tick(n)
+ TemporalProxy(e,n)
+
diff --git a/docs/assets/collision/dpo_contact_events.svg b/docs/assets/collision/dpo_contact_events.svg
new file mode 100644
index 00000000..ed888ecb
--- /dev/null
+++ b/docs/assets/collision/dpo_contact_events.svg
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+ DPO: ContactEvents (post_update)
+ LHS: Contact(pair,n-1) and Contact(pair,n) (or absence); RHS: Begin/Persist/End event
+
+
+ LHS
+
+ RHS
+
+ Contact(pair,n-1)
+ Contact(pair,n)
+
+ ContactEvent(kind, pair, n)
+
+
+
+
+
+
diff --git a/docs/assets/collision/dpo_contact_events_step1.svg b/docs/assets/collision/dpo_contact_events_step1.svg
new file mode 100644
index 00000000..fb3e039f
--- /dev/null
+++ b/docs/assets/collision/dpo_contact_events_step1.svg
@@ -0,0 +1,25 @@
+
+
+
+
+
+ ContactEvents — Step 1: LHS
+ Contact(pair,n-1)
+ Contact(pair,n)
+
diff --git a/docs/assets/collision/dpo_contact_events_step2.svg b/docs/assets/collision/dpo_contact_events_step2.svg
new file mode 100644
index 00000000..05703400
--- /dev/null
+++ b/docs/assets/collision/dpo_contact_events_step2.svg
@@ -0,0 +1,26 @@
+
+
+
+
+
+ ContactEvents — Step 2: Interface K
+ Contact(pair,n-1)
+ Contact(pair,n)
+
+
diff --git a/docs/assets/collision/dpo_contact_events_step3.svg b/docs/assets/collision/dpo_contact_events_step3.svg
new file mode 100644
index 00000000..7e079498
--- /dev/null
+++ b/docs/assets/collision/dpo_contact_events_step3.svg
@@ -0,0 +1,26 @@
+
+
+
+
+
+ ContactEvents — Step 3: RHS
+ Contact(pair,n-1)
+ Contact(pair,n)
+ ContactEvent(kind,pair,n)
+
diff --git a/docs/assets/collision/dpo_gc_ephemeral.svg b/docs/assets/collision/dpo_gc_ephemeral.svg
new file mode 100644
index 00000000..a38a91bc
--- /dev/null
+++ b/docs/assets/collision/dpo_gc_ephemeral.svg
@@ -0,0 +1,38 @@
+
+
+
+
+
+ DPO: GC Ephemeral (timeline_flush)
+ LHS: Temporal artifacts older than retention and unreferenced; RHS: delete deterministically
+
+
+ LHS
+
+ RHS
+
+ TemporalProxy(e,n-2)
+ PotentialPair(a,b,n-2)
+ Toi(pair,n-2)
+
+ (deleted ephemeral nodes)
+
+
+
+
diff --git a/docs/assets/collision/dpo_gc_ephemeral_step1.svg b/docs/assets/collision/dpo_gc_ephemeral_step1.svg
new file mode 100644
index 00000000..d8af8e0c
--- /dev/null
+++ b/docs/assets/collision/dpo_gc_ephemeral_step1.svg
@@ -0,0 +1,26 @@
+
+
+
+
+
+ GC Ephemeral — Step 1: LHS
+ TemporalProxy(e,n-2)
+ PotentialPair(a,b,n-2)
+ Toi(pair,n-2)
+
diff --git a/docs/assets/collision/dpo_gc_ephemeral_step2.svg b/docs/assets/collision/dpo_gc_ephemeral_step2.svg
new file mode 100644
index 00000000..8844e59f
--- /dev/null
+++ b/docs/assets/collision/dpo_gc_ephemeral_step2.svg
@@ -0,0 +1,27 @@
+
+
+
+
+
+ GC Ephemeral — Step 2: Selection
+ TemporalProxy(e,n-2)
+ PotentialPair(a,b,n-2)
+ Toi(pair,n-2)
+
+
diff --git a/docs/assets/collision/dpo_gc_ephemeral_step3.svg b/docs/assets/collision/dpo_gc_ephemeral_step3.svg
new file mode 100644
index 00000000..f0651f70
--- /dev/null
+++ b/docs/assets/collision/dpo_gc_ephemeral_step3.svg
@@ -0,0 +1,24 @@
+
+
+
+
+
+ GC Ephemeral — Step 3: RHS
+ (deleted ephemeral nodes)
+
diff --git a/docs/assets/collision/dpo_narrow_phase_ccd.svg b/docs/assets/collision/dpo_narrow_phase_ccd.svg
new file mode 100644
index 00000000..c8c77b8a
--- /dev/null
+++ b/docs/assets/collision/dpo_narrow_phase_ccd.svg
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+ DPO: NarrowPhaseCCD (update)
+ LHS: PotentialPair(a,b,n), CCD policy triggers; RHS: Toi(pair,n) + Contact(pair,n) at s∈[0,1]
+
+
+ LHS
+
+ RHS
+
+ PotentialPair(a,b,n)
+ ccd: policy(a,b) == true
+
+ PotentialPair(a,b,n)
+ Toi(pair,n)
+ Contact(pair,n) { Manifold }
+ toi_s
+
+
+
+
+
diff --git a/docs/assets/collision/dpo_narrow_phase_ccd_step1.svg b/docs/assets/collision/dpo_narrow_phase_ccd_step1.svg
new file mode 100644
index 00000000..b9d20369
--- /dev/null
+++ b/docs/assets/collision/dpo_narrow_phase_ccd_step1.svg
@@ -0,0 +1,25 @@
+
+
+
+
+
+ NarrowPhaseCCD — Step 1: LHS
+ PotentialPair(a,b,n)
+ policy(a,b) == true (CCD)
+
diff --git a/docs/assets/collision/dpo_narrow_phase_ccd_step2.svg b/docs/assets/collision/dpo_narrow_phase_ccd_step2.svg
new file mode 100644
index 00000000..a7ab1df2
--- /dev/null
+++ b/docs/assets/collision/dpo_narrow_phase_ccd_step2.svg
@@ -0,0 +1,25 @@
+
+
+
+
+
+ NarrowPhaseCCD — Step 2: Interface K
+ PotentialPair(a,b,n)
+
+
diff --git a/docs/assets/collision/dpo_narrow_phase_ccd_step3.svg b/docs/assets/collision/dpo_narrow_phase_ccd_step3.svg
new file mode 100644
index 00000000..1613f487
--- /dev/null
+++ b/docs/assets/collision/dpo_narrow_phase_ccd_step3.svg
@@ -0,0 +1,26 @@
+
+
+
+
+
+ NarrowPhaseCCD — Step 3: RHS
+ PotentialPair(a,b,n)
+ Toi(pair,n)
+ Contact(pair,n) { Manifold }
+
diff --git a/docs/assets/collision/dpo_narrow_phase_discrete.svg b/docs/assets/collision/dpo_narrow_phase_discrete.svg
new file mode 100644
index 00000000..d3e70b0a
--- /dev/null
+++ b/docs/assets/collision/dpo_narrow_phase_discrete.svg
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+ DPO: NarrowPhaseDiscrete (update)
+ LHS: PotentialPair(a,b,n), discrete overlap at end poses; RHS: add Contact(pair,n) with Manifold
+
+
+ LHS
+
+ RHS
+
+ PotentialPair(a,b,n)
+ discrete: overlap == true @ end pose
+
+ PotentialPair(a,b,n)
+ Contact(pair,n) { Manifold }
+
+
+
+
diff --git a/docs/assets/collision/dpo_narrow_phase_discrete_step1.svg b/docs/assets/collision/dpo_narrow_phase_discrete_step1.svg
new file mode 100644
index 00000000..6d401272
--- /dev/null
+++ b/docs/assets/collision/dpo_narrow_phase_discrete_step1.svg
@@ -0,0 +1,25 @@
+
+
+
+
+
+ NarrowPhaseDiscrete — Step 1: LHS
+ PotentialPair(a,b,n)
+ overlap == true @ end pose
+
diff --git a/docs/assets/collision/dpo_narrow_phase_discrete_step2.svg b/docs/assets/collision/dpo_narrow_phase_discrete_step2.svg
new file mode 100644
index 00000000..39dc906c
--- /dev/null
+++ b/docs/assets/collision/dpo_narrow_phase_discrete_step2.svg
@@ -0,0 +1,25 @@
+
+
+
+
+
+ NarrowPhaseDiscrete — Step 2: Interface K
+ PotentialPair(a,b,n)
+
+
diff --git a/docs/assets/collision/dpo_narrow_phase_discrete_step3.svg b/docs/assets/collision/dpo_narrow_phase_discrete_step3.svg
new file mode 100644
index 00000000..cb694af4
--- /dev/null
+++ b/docs/assets/collision/dpo_narrow_phase_discrete_step3.svg
@@ -0,0 +1,25 @@
+
+
+
+
+
+ NarrowPhaseDiscrete — Step 3: RHS
+ PotentialPair(a,b,n)
+ Contact(pair,n) { Manifold }
+
diff --git a/docs/assets/collision/graph_collision_overview.mmd b/docs/assets/collision/graph_collision_overview.mmd
new file mode 100644
index 00000000..731969f8
--- /dev/null
+++ b/docs/assets/collision/graph_collision_overview.mmd
@@ -0,0 +1,48 @@
+flowchart TB
+ Tick[(Tick n)]
+ subgraph Entities
+ EA[Entity A]
+ EB[Entity B]
+ end
+ TA[Transform(A,n)]
+ CA[Collider(A)]
+ TB[Transform(B,n)]
+ CB[Collider(B)]
+ TPA[[TemporalProxy(A,n)]]
+ TPB[[TemporalProxy(B,n)]]
+ PP{{PotentialPair(A,B,n)}}
+ C((Contact(pair,n)))
+ TOI((Toi(pair,n)))
+ E((ContactEvent(kind,n)))
+
+ EA --> TA
+ EA --> CA
+ EB --> TB
+ EB --> CB
+
+ TA -- produced_in --> Tick
+ TB -- produced_in --> Tick
+
+ TA -. has_proxy .-> TPA
+ TB -. has_proxy .-> TPB
+ TPA -- pair_of --> PP
+ TPB -- pair_of --> PP
+ PP -- contact_of --> C
+ PP -. toi_of .-> TOI
+ C -- event_of --> E
+
+ TPA -- produced_in --> Tick
+ TPB -- produced_in --> Tick
+ PP -- produced_in --> Tick
+ C -- produced_in --> Tick
+ TOI -- produced_in --> Tick
+ E -- produced_in --> Tick
+
+classDef tick fill:#1a233f,stroke:#3e5fb5,color:#dfe7ff;
+classDef proxy stroke:#ffd166,color:#dfe7ff;
+classDef pair stroke:#00c853,color:#dfe7ff;
+classDef event fill:#241a4b,stroke:#a78bfa,color:#dfe7ff;
+class Tick tick;
+class TPA,TPB proxy;
+class PP pair;
+class E event;
diff --git a/docs/assets/collision/graph_collision_overview.svg b/docs/assets/collision/graph_collision_overview.svg
new file mode 100644
index 00000000..efc12d26
--- /dev/null
+++ b/docs/assets/collision/graph_collision_overview.svg
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+ Collision Subgraph — Typed Nodes and Edges (Tick n)
+ Everything is a graph: entities/components, time‑aware proxies, pairs, contacts, TOI, and events are nodes linked by typed edges.
+
+
+
+
+ Tick(n)
+
+
+
+
+
+ Entity A
+
+
+
+ Entity B
+
+
+
+
+
+ Transform(A,n)
+
+
+
+ Collider(A)
+
+
+
+
+
+ Transform(B,n)
+
+
+
+ Collider(B)
+
+
+
+
+
+ TemporalProxy(A,n)
+
+
+
+ TemporalProxy(B,n)
+
+
+
+
+
+ PotentialPair(A,B,n)
+
+
+
+
+
+ Contact(pair,n)
+
+
+
+ Toi(pair,n)
+
+
+
+
+
+ ContactEvent(kind,n)
+
+
+
+ has_component
+
+
+
+
+
+ produced_in
+
+
+
+ has_proxy
+
+
+
+ has_proxy
+
+
+
+ pair_of
+
+
+
+ contact_of
+ toi_of
+
+
+ event_of
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/assets/collision/legend.svg b/docs/assets/collision/legend.svg
new file mode 100644
index 00000000..4779ddb0
--- /dev/null
+++ b/docs/assets/collision/legend.svg
@@ -0,0 +1,48 @@
+
+
+
+
+
+ DPO Diagram Legend
+
+ Node
+ Regular node
+
+
+ Interface K
+ Preserved between LHS and RHS
+
+
+ Added
+ Created on RHS
+
+
+ Removed
+ Deleted on RHS
+
+
+
+ edge
+ added edge
+ removed edge
+
+
+
+ Scope boundary
+
diff --git a/docs/assets/collision/narrow_phase_ccd.mmd b/docs/assets/collision/narrow_phase_ccd.mmd
new file mode 100644
index 00000000..eaadda91
--- /dev/null
+++ b/docs/assets/collision/narrow_phase_ccd.mmd
@@ -0,0 +1,15 @@
+flowchart LR
+ subgraph LHS
+ PP[[PotentialPair(a,b,n)]]:::KClass
+ PP -- policy(ccd) --> CCD[/run CCD/]
+ end
+ subgraph RHS
+ PP2[[PotentialPair(a,b,n)]]:::KClass
+ TOI((Toi(pair,n))):::Add
+ C((Contact(pair,n))):::Add
+ TOI -- toi_s --> C
+ C -- contact_of --> PP2
+ end
+
+classDef KClass stroke:#ffd166,stroke-width:2;
+classDef Add stroke:#00c853,stroke-width:2,fill:#0d2a1a,color:#dfe7ff;
diff --git a/docs/assets/collision/narrow_phase_discrete.mmd b/docs/assets/collision/narrow_phase_discrete.mmd
new file mode 100644
index 00000000..7e441d50
--- /dev/null
+++ b/docs/assets/collision/narrow_phase_discrete.mmd
@@ -0,0 +1,13 @@
+flowchart LR
+ subgraph LHS
+ PP[[PotentialPair(a,b,n)]]:::KClass
+ PP -- overlap_at_end --> YES{overlap?}
+ end
+ subgraph RHS
+ PP2[[PotentialPair(a,b,n)]]:::KClass
+ C((Contact(pair,n))):::Add
+ C -- contact_of --> PP2
+ end
+
+classDef KClass stroke:#ffd166,stroke-width:2;
+classDef Add stroke:#00c853,stroke-width:2,fill:#0d2a1a,color:#dfe7ff;
diff --git a/docs/assets/collision/pip/build_proxy_step1.svg b/docs/assets/collision/pip/build_proxy_step1.svg
new file mode 100644
index 00000000..5bc3682a
--- /dev/null
+++ b/docs/assets/collision/pip/build_proxy_step1.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+ Entity e — start pose and velocity
+
+
diff --git a/docs/assets/collision/pip/build_proxy_step2.svg b/docs/assets/collision/pip/build_proxy_step2.svg
new file mode 100644
index 00000000..c1a855cc
--- /dev/null
+++ b/docs/assets/collision/pip/build_proxy_step2.svg
@@ -0,0 +1,6 @@
+
+
+
+
+ Interface K — e@Tick n preserved
+
diff --git a/docs/assets/collision/pip/build_proxy_step3.svg b/docs/assets/collision/pip/build_proxy_step3.svg
new file mode 100644
index 00000000..47853a2b
--- /dev/null
+++ b/docs/assets/collision/pip/build_proxy_step3.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+ Fat AABB over motion [start,end]
+
+
diff --git a/docs/assets/collision/pip/ccd_step1.svg b/docs/assets/collision/pip/ccd_step1.svg
new file mode 100644
index 00000000..7fbfb08e
--- /dev/null
+++ b/docs/assets/collision/pip/ccd_step1.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+ Fast mover towards obstacle (CCD)
+
+
diff --git a/docs/assets/collision/pip/ccd_step2.svg b/docs/assets/collision/pip/ccd_step2.svg
new file mode 100644
index 00000000..9c114a4c
--- /dev/null
+++ b/docs/assets/collision/pip/ccd_step2.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+ K: pair preserved for CCD
+
diff --git a/docs/assets/collision/pip/ccd_step3.svg b/docs/assets/collision/pip/ccd_step3.svg
new file mode 100644
index 00000000..572572cf
--- /dev/null
+++ b/docs/assets/collision/pip/ccd_step3.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+ Impact at s ≈ TOI; contact normal
+
diff --git a/docs/assets/collision/pip/discrete_step1.svg b/docs/assets/collision/pip/discrete_step1.svg
new file mode 100644
index 00000000..4f248427
--- /dev/null
+++ b/docs/assets/collision/pip/discrete_step1.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+ End pose overlap (discrete)
+
diff --git a/docs/assets/collision/pip/discrete_step2.svg b/docs/assets/collision/pip/discrete_step2.svg
new file mode 100644
index 00000000..f2846ec7
--- /dev/null
+++ b/docs/assets/collision/pip/discrete_step2.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+ K: pair preserved
+
diff --git a/docs/assets/collision/pip/discrete_step3.svg b/docs/assets/collision/pip/discrete_step3.svg
new file mode 100644
index 00000000..6bb7da0c
--- /dev/null
+++ b/docs/assets/collision/pip/discrete_step3.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+ Contact manifold (canonical order)
+
diff --git a/docs/assets/collision/pip/events_step1.svg b/docs/assets/collision/pip/events_step1.svg
new file mode 100644
index 00000000..e2e9ff1f
--- /dev/null
+++ b/docs/assets/collision/pip/events_step1.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+ Contacts at n and n−1 (compare)
+
diff --git a/docs/assets/collision/pip/events_step2.svg b/docs/assets/collision/pip/events_step2.svg
new file mode 100644
index 00000000..c3b14f78
--- /dev/null
+++ b/docs/assets/collision/pip/events_step2.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+ K: states preserved for diff
+
diff --git a/docs/assets/collision/pip/events_step3.svg b/docs/assets/collision/pip/events_step3.svg
new file mode 100644
index 00000000..ae0de6c5
--- /dev/null
+++ b/docs/assets/collision/pip/events_step3.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+ Begin
+ Event node emitted (Begin/Persist/End)
+
diff --git a/docs/assets/collision/pip/gc_step1.svg b/docs/assets/collision/pip/gc_step1.svg
new file mode 100644
index 00000000..0709dff2
--- /dev/null
+++ b/docs/assets/collision/pip/gc_step1.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+ Ephemeral artifacts present
+
diff --git a/docs/assets/collision/pip/gc_step2.svg b/docs/assets/collision/pip/gc_step2.svg
new file mode 100644
index 00000000..f4864573
--- /dev/null
+++ b/docs/assets/collision/pip/gc_step2.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+ GC selection window
+
diff --git a/docs/assets/collision/pip/gc_step3.svg b/docs/assets/collision/pip/gc_step3.svg
new file mode 100644
index 00000000..641fdf26
--- /dev/null
+++ b/docs/assets/collision/pip/gc_step3.svg
@@ -0,0 +1,5 @@
+
+
+
+ Ephemeral cleared (deterministic)
+
diff --git a/docs/assets/collision/pip/graph_build_proxy_step1.svg b/docs/assets/collision/pip/graph_build_proxy_step1.svg
new file mode 100644
index 00000000..cada2fb1
--- /dev/null
+++ b/docs/assets/collision/pip/graph_build_proxy_step1.svg
@@ -0,0 +1,13 @@
+
+
+
+
+ Transform(A,n)
+
+ Collider(A)
+
+ Tick(n)
+
+
+ produced_in
+
diff --git a/docs/assets/collision/pip/graph_build_proxy_step2.svg b/docs/assets/collision/pip/graph_build_proxy_step2.svg
new file mode 100644
index 00000000..c82b011f
--- /dev/null
+++ b/docs/assets/collision/pip/graph_build_proxy_step2.svg
@@ -0,0 +1,11 @@
+
+
+
+
+ Transform(A,n)
+
+ Collider(A)
+
+ Tick(n)
+
+
diff --git a/docs/assets/collision/pip/graph_build_proxy_step3.svg b/docs/assets/collision/pip/graph_build_proxy_step3.svg
new file mode 100644
index 00000000..4f55ee58
--- /dev/null
+++ b/docs/assets/collision/pip/graph_build_proxy_step3.svg
@@ -0,0 +1,14 @@
+
+
+
+
+ Transform(A,n)
+
+ Collider(A)
+
+ Tick(n)
+
+ TemporalProxy(A,n)
+
+ has_proxy
+
diff --git a/docs/assets/collision/pip/graph_ccd_step1.svg b/docs/assets/collision/pip/graph_ccd_step1.svg
new file mode 100644
index 00000000..44622f7e
--- /dev/null
+++ b/docs/assets/collision/pip/graph_ccd_step1.svg
@@ -0,0 +1,7 @@
+
+
+
+
+ PotentialPair(A,B,n)
+ Policy: CCD
+
diff --git a/docs/assets/collision/pip/graph_ccd_step2.svg b/docs/assets/collision/pip/graph_ccd_step2.svg
new file mode 100644
index 00000000..8f9237b0
--- /dev/null
+++ b/docs/assets/collision/pip/graph_ccd_step2.svg
@@ -0,0 +1,7 @@
+
+
+
+
+ PotentialPair(A,B,n)
+ Run CA / sweep
+
diff --git a/docs/assets/collision/pip/graph_ccd_step3.svg b/docs/assets/collision/pip/graph_ccd_step3.svg
new file mode 100644
index 00000000..2d729cdd
--- /dev/null
+++ b/docs/assets/collision/pip/graph_ccd_step3.svg
@@ -0,0 +1,12 @@
+
+
+
+
+ PotentialPair(A,B,n)
+
+ Contact(pair,n)
+
+ Toi(pair,n)
+
+
+
diff --git a/docs/assets/collision/pip/graph_discrete_step1.svg b/docs/assets/collision/pip/graph_discrete_step1.svg
new file mode 100644
index 00000000..ef8dec34
--- /dev/null
+++ b/docs/assets/collision/pip/graph_discrete_step1.svg
@@ -0,0 +1,7 @@
+
+
+
+
+ PotentialPair(A,B,n)
+ LHS: overlap at end pose
+
diff --git a/docs/assets/collision/pip/graph_discrete_step2.svg b/docs/assets/collision/pip/graph_discrete_step2.svg
new file mode 100644
index 00000000..07802466
--- /dev/null
+++ b/docs/assets/collision/pip/graph_discrete_step2.svg
@@ -0,0 +1,7 @@
+
+
+
+
+ PotentialPair(A,B,n)
+ K: pair preserved
+
diff --git a/docs/assets/collision/pip/graph_discrete_step3.svg b/docs/assets/collision/pip/graph_discrete_step3.svg
new file mode 100644
index 00000000..e6ff976d
--- /dev/null
+++ b/docs/assets/collision/pip/graph_discrete_step3.svg
@@ -0,0 +1,10 @@
+
+
+
+
+ PotentialPair(A,B,n)
+
+ Contact(pair,n)
+
+ contact_of
+
diff --git a/docs/assets/collision/pip/graph_events_step1.svg b/docs/assets/collision/pip/graph_events_step1.svg
new file mode 100644
index 00000000..2c43658f
--- /dev/null
+++ b/docs/assets/collision/pip/graph_events_step1.svg
@@ -0,0 +1,8 @@
+
+
+
+
+ Contact(pair,n-1)
+
+ Contact(pair,n)
+
diff --git a/docs/assets/collision/pip/graph_events_step2.svg b/docs/assets/collision/pip/graph_events_step2.svg
new file mode 100644
index 00000000..e2b13f47
--- /dev/null
+++ b/docs/assets/collision/pip/graph_events_step2.svg
@@ -0,0 +1,9 @@
+
+
+
+
+ Contact(pair,n-1)
+
+ Contact(pair,n)
+ K: compare states
+
diff --git a/docs/assets/collision/pip/graph_events_step3.svg b/docs/assets/collision/pip/graph_events_step3.svg
new file mode 100644
index 00000000..e7348665
--- /dev/null
+++ b/docs/assets/collision/pip/graph_events_step3.svg
@@ -0,0 +1,10 @@
+
+
+
+
+ Contact(pair,n)
+
+ ContactEvent(kind,n)
+
+ event_of
+
diff --git a/docs/assets/collision/pip/graph_gc_step1.svg b/docs/assets/collision/pip/graph_gc_step1.svg
new file mode 100644
index 00000000..032f3fde
--- /dev/null
+++ b/docs/assets/collision/pip/graph_gc_step1.svg
@@ -0,0 +1,11 @@
+
+
+
+
+ TemporalProxy(*,n)
+
+ PotentialPair(*,n)
+
+ Toi(*,n)
+ Ephemeral artifacts
+
diff --git a/docs/assets/collision/pip/graph_gc_step2.svg b/docs/assets/collision/pip/graph_gc_step2.svg
new file mode 100644
index 00000000..43500529
--- /dev/null
+++ b/docs/assets/collision/pip/graph_gc_step2.svg
@@ -0,0 +1,11 @@
+
+
+
+
+ TemporalProxy(*,n)
+
+ PotentialPair(*,n)
+
+ Toi(*,n)
+ Selecting for deletion
+
diff --git a/docs/assets/collision/pip/graph_gc_step3.svg b/docs/assets/collision/pip/graph_gc_step3.svg
new file mode 100644
index 00000000..aaeab7e5
--- /dev/null
+++ b/docs/assets/collision/pip/graph_gc_step3.svg
@@ -0,0 +1,5 @@
+
+
+
+ Ephemeral cleared
+
diff --git a/docs/assets/collision/pip/graph_pairing_step1.svg b/docs/assets/collision/pip/graph_pairing_step1.svg
new file mode 100644
index 00000000..ddce482a
--- /dev/null
+++ b/docs/assets/collision/pip/graph_pairing_step1.svg
@@ -0,0 +1,9 @@
+
+
+
+
+ TemporalProxy(A,n)
+
+ TemporalProxy(B,n)
+ LHS: overlapping proxies
+
diff --git a/docs/assets/collision/pip/graph_pairing_step2.svg b/docs/assets/collision/pip/graph_pairing_step2.svg
new file mode 100644
index 00000000..a0a33552
--- /dev/null
+++ b/docs/assets/collision/pip/graph_pairing_step2.svg
@@ -0,0 +1,9 @@
+
+
+
+
+ TemporalProxy(A,n)
+
+ TemporalProxy(B,n)
+ K: proxies preserved
+
diff --git a/docs/assets/collision/pip/graph_pairing_step3.svg b/docs/assets/collision/pip/graph_pairing_step3.svg
new file mode 100644
index 00000000..c8567ae7
--- /dev/null
+++ b/docs/assets/collision/pip/graph_pairing_step3.svg
@@ -0,0 +1,13 @@
+
+
+
+
+ TemporalProxy(A,n)
+
+ TemporalProxy(B,n)
+
+ PotentialPair(A,B,n)
+
+
+ pair_of
+
diff --git a/docs/assets/collision/pip/pairing_step1.svg b/docs/assets/collision/pip/pairing_step1.svg
new file mode 100644
index 00000000..7df40c48
--- /dev/null
+++ b/docs/assets/collision/pip/pairing_step1.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+ Overlapping fat AABBs → candidate
+
diff --git a/docs/assets/collision/pip/pairing_step2.svg b/docs/assets/collision/pip/pairing_step2.svg
new file mode 100644
index 00000000..f8f19568
--- /dev/null
+++ b/docs/assets/collision/pip/pairing_step2.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+ K: proxies preserved (ordering stable)
+
diff --git a/docs/assets/collision/pip/pairing_step3.svg b/docs/assets/collision/pip/pairing_step3.svg
new file mode 100644
index 00000000..9ed71d3a
--- /dev/null
+++ b/docs/assets/collision/pip/pairing_step3.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+ PotentialPair added (canonical id)
+
diff --git a/docs/assets/collision/scheduler_phase_mapping.svg b/docs/assets/collision/scheduler_phase_mapping.svg
new file mode 100644
index 00000000..3e7b4204
--- /dev/null
+++ b/docs/assets/collision/scheduler_phase_mapping.svg
@@ -0,0 +1,50 @@
+
+
+
+
+
+ Scheduler Phase Mapping (Collision/CCD)
+ pre_update → update → post_update → timeline_flush
+
+
+
+ pre_update
+ BuildTemporalProxy
+
+
+
+
+ update
+ BroadPhasePairing
+ NarrowPhaseDiscrete
+ NarrowPhaseCCD
+
+
+
+
+ post_update
+ ContactEvents
+
+
+
+
+ timeline_flush
+ GC Ephemeral
+
+
diff --git a/docs/code-map.md b/docs/code-map.md
new file mode 100644
index 00000000..96081044
--- /dev/null
+++ b/docs/code-map.md
@@ -0,0 +1,57 @@
+# Echo Code Map
+
+> Quick index from concepts → code, with the most relevant specs.
+
+## Crates
+
+- rmg-core — deterministic graph rewriting engine (Rust)
+ - Public API aggregator: `crates/rmg-core/src/lib.rs`
+ - Identifiers & hashing: `crates/rmg-core/src/ident.rs`
+ - Node/edge records: `crates/rmg-core/src/record.rs`
+ - In-memory graph store: `crates/rmg-core/src/graph.rs`
+ - Rules and patterns: `crates/rmg-core/src/rule.rs`
+ - Transactions: `crates/rmg-core/src/tx.rs`
+ - Deterministic scheduler: `crates/rmg-core/src/scheduler.rs`
+ - Snapshots + hashing: `crates/rmg-core/src/snapshot.rs`
+ - Payload codecs (demo): `crates/rmg-core/src/payload.rs`
+ - Engine implementation: `crates/rmg-core/src/engine_impl.rs`
+ - Demo rule: `crates/rmg-core/src/demo/motion.rs`
+ - Deterministic math: `crates/rmg-core/src/math/*`
+ - Tests (integration): `crates/rmg-core/tests/*`
+
+- rmg-ffi — C ABI for host integrations
+ - `crates/rmg-ffi/src/lib.rs`
+
+- rmg-wasm — wasm-bindgen bindings
+ - `crates/rmg-wasm/src/lib.rs`
+
+- rmg-cli — CLI scaffolding
+ - `crates/rmg-cli/src/main.rs`
+
+## Specs → Code
+
+- RMG core model — docs/spec-rmg-core.md → `ident.rs`, `record.rs`, `graph.rs`, `rule.rs`, `engine_impl.rs`, `snapshot.rs`, `scheduler.rs`
+- Scheduler — docs/spec-scheduler.md → `scheduler.rs`, `engine_impl.rs`
+- ECS storage (future) — docs/spec-ecs-storage.md → new `ecs/*` modules (TBD)
+- Serialization — docs/spec-serialization-protocol.md → `snapshot.rs` (hashing), future codecs
+- Deterministic math — docs/spec-deterministic-math.md → `math/*`
+- Temporal bridge/Codex’s Baby — docs/spec-temporal-bridge.md, docs/spec-codex-baby.md → future modules (TBD)
+
+## Conventions
+
+- Column-major matrices, right-handed coordinates, f32 math.
+- One concrete concept per file; keep modules < 300 LoC where feasible.
+- Tests live in `crates//tests` and favor small, focused cases.
+
+## Refactor Policy
+
+- 1 file = 1 concrete concept (engine, graph store, identifiers, etc.).
+- No 500+ LoC “god files”; split before modules exceed ~300 LoC.
+- Keep inline tests in separate files under `crates//tests`.
+- Maintain stable re-exports in `lib.rs` so public API stays coherent.
+
+## Onboarding
+
+- Start with `README.md` and `docs/docs-index.md`.
+- For engine flow, read `engine_impl.rs` (apply → schedule → commit → snapshot).
+- For demo behavior, see `demo/motion.rs` and tests under `crates/rmg-core/tests/*`.
diff --git a/docs/collision-dpo-tour.html b/docs/collision-dpo-tour.html
new file mode 100644
index 00000000..6577e606
--- /dev/null
+++ b/docs/collision-dpo-tour.html
@@ -0,0 +1,210 @@
+
+
+
+
+
+ Echo Collision DPO Tour
+
+
+
+ Collision / CCD — DPO Rule Tour
+ Each rule shown as LHS → Interface K → RHS. See the legend for visual semantics.
+ Legend • Spec
+
+
+
Graph Anatomy (Everything Is a Graph)
+
+
+
+ Overview — typed nodes and edges for one colliding pair at tick n.
+ Node/Edge Graph Entities, components, temporal proxies, potential pair, contact, TOI, event — all first‑class nodes linked by typed edges (has_component, has_proxy, pair_of, contact_of, event_of, produced_in).
+
+
+ This is the literal graph Echo maintains. Derived artifacts (proxies, pairs, contacts, events) are not hidden engine buffers —
+ they are nodes that tools can query, branch, replay, and merge deterministically. The same initial facts and policies yield the
+ same subgraph and the same snapshot hash on every peer.
+
+
+
+
+
+
+ How Things Move (time-aware proxies)
+
+
BuildTemporalProxy (pre_update)
+
+
+
+ Step 1 — LHS: Collider + Transform (+ Velocity) at Tick n
+
+
+ We gather the collider’s Transform (and optional Velocity ) at tick n . In Echo this input is
+ explicit graph state, not a transient engine struct. Chronos gives us a fixed dt, so the motion window
+ for the upcoming tick is well-defined and reproducible.
+
+
+ Different: many engines pull from mutable component state ad‑hoc; here we read typed nodes bound to a specific tick.
+ Determinism: same dt and same components ⇒ same inputs on every peer/branch.
+
+
+
+
+
+ Step 2 — Interface K
+
+
+ The DPO Interface K shows what is preserved between LHS and RHS: collider + transform + tick.
+ This is how we say “the world keeps these facts while we add the proxy.”
+
+
+ Different: Echo makes the preserved context explicit; typical engines merge implicit state in-place.
+
+
+
+
+
+ Step 3 — RHS: TemporalProxy(e,n) added
+
+
+ We add a TemporalProxy with a fat AABB that encloses motion over [start,end]. Padding is derived from
+ velocity and quantized policy, so two peers derive the same box. The proxy links back to the entity and Tick n .
+
+
+ Different: broad‑phase caches become first‑class graph nodes with stable IDs.
+ Determinism: quantized padding + stable insertion order ⇒ identical proxy sets.
+
+
+
+
+
+
+ This rule deterministically derives a TemporalProxy for each collider at tick n .
+ The proxy’s “fat AABB” encloses the body over the whole tick window [start,end], so fast movers can’t tunnel between broad‑phase sweeps.
+ The proxy is a typed node in the graph (not an opaque engine cache) and is linked back to the entity and the producing tick.
+
+
+ Different from typical engines: broad‑phase buffers are usually internal and mutation‑ordered; in Echo they are explicit graph nodes, created by a rewrite with a stable scope and ID.
+ Determinism: proxy size and padding are computed from quantized policy values; insert order is sorted by ID, so peers/branches build identical proxy sets.
+
+
+
+
+ How Collision Works (broad → narrow → events)
+
+
BroadPhasePairing (update)
+
+
Step 1 — LHS: overlapping proxies We test fat AABB overlap on proxies built for the full tick window. Overlap means the pair is a candidate for narrow phase.
Different: the candidate condition is a graph fact, not an opaque boolean.Determinism: identical proxies ⇒ identical overlap set.
+
Step 2 — K: proxies preserved The proxies themselves are preserved (K). This makes the rule commute with other rules that may also read them this tick.
+
Step 3 — RHS: PotentialPair added We mint a PotentialPair with canonical PairId = H(min(A,B)||max(A,B)||branch) and back‑refs to proxies.
Different: pair lists are reproducible data, not engine iteration order.Determinism: output list is sorted strictly; peers/branches match.
+
+
+
+ The broad phase converts overlapping proxies into PotentialPair nodes. Each pair gets a canonical
+ PairId = H(min(A,B) || max(A,B) || branch) and edges back to the proxies. The emitted list is
+ sorted deterministically, which makes network replication and timeline diffs trivial.
+
+
+ Different: most engines keep an internal unsorted array of candidate pairs; Echo materializes pairs as graph facts, with stable IDs and ordering.
+ Determinism: ties in AABB endpoints break on IDs; output is strictly sorted, so two peers converge on identical pair order.
+
+
+
+
+
+
NarrowPhaseDiscrete (update)
+
+
Step 1 — LHS: discrete overlap @ end pose For low‑speed pairs, we evaluate shapes at the end pose of tick n . If they overlap, we proceed to build a manifold.
Different: thresholding policy is data; no hidden time‑step heuristics.
+
Step 2 — K: pair preserved We keep the PotentialPair (K). The narrow phase acts as a pure derivation from pair+poses.
+
Step 3 — RHS: Contact with Manifold added We create a Contact with a reduced Manifold (2–4 points). Points are canonicalized by feature IDs to ensure reproducible ordering.
Different: engine doesn’t call your code mid‑narrow; it records facts you can read consistently.Determinism: centralized tolerances + ordering.
+
+
+
+ For low‑speed pairs, we evaluate shapes at end‑of‑tick poses and, if intersecting, create a Contact with a
+ deterministically ordered Manifold (2–4 clipped points). The contact attaches to the pair and to the producing tick.
+ Manifold point ordering and feature IDs are canonicalized to remove platform drift.
+
+
+ Different: instead of imperative callbacks that mutate scripts, Echo records contacts as first‑class nodes; scripts read them after rules run.
+ Determinism: manifold reduction, feature selection, and floating‑point tolerances are centralized and quantized.
+
+
+
+
+
+
NarrowPhaseCCD (update)
+
+
Step 1 — LHS: CCD policy triggers Policy flags fast motion/small features (or material‑required CCD). We will compute a Toi in [0,1] before creating a contact.
+
Step 2 — K: pair preserved We keep the pair (K) and run conservative advancement or a swept primitive test to find the impact time.
+
Step 3 — RHS: Toi + Contact added We emit a Toi node with quantized s and a Contact at the impact pose. Quantization and iteration caps are recorded to make this stable.
+
+
+
+ When a policy indicates high motion or small features, we run CCD : conservative advancement for general convex
+ shapes or closed‑form sweeps for spheres/capsules. We emit a Toi node with quantized s ∈ [0,1] and a Contact
+ at the impact pose. Because s is quantized and the rule scopes are stable, peers compute the same TOI and contact set.
+
+
+ Different: CCD outputs are persisted as graph data (Toi + Contact), not transient solver state; branches and replays see identical values.
+ Determinism: iteration caps, policy thresholds, and s quantization are recorded; identical inputs yield identical s .
+
+
+
+
+
+
+ How We Keep It Clean (deterministic GC)
+
+
GC Ephemeral (timeline_flush)
+
+
Step 1 — LHS: ephemeral nodes Before flush, the frame has proxies, pairs, TOIs and optional per‑tick contacts. They’re marked ephemeral.
+
Step 2 — Selection We deterministically select unreferenced, older artifacts for deletion. The retention policy is configured and recorded.
+
Step 3 — RHS: nodes deleted We remove the selected nodes in a stable ID order. Snapshots after flush are identical across peers/branches.
+
+
+
+ Broad‑phase proxies, potential pairs, transient TOIs and, optionally, per‑tick contacts are ephemeral . At
+ timeline_flush we delete them in a deterministic order. We keep only the high‑value artifacts (Aion‑tagged events, metrics) for tools and audits.
+
+
+ Different: many engines leak implicit caches across frames; Echo models and cleans them explicitly as graph data.
+ Determinism: GC order is sorted by ID; post‑flush snapshots are identical across branches and peers.
+
+
+
+
+
+
+
diff --git a/docs/decision-log.md b/docs/decision-log.md
index e0763c33..8ec05e45 100644
--- a/docs/decision-log.md
+++ b/docs/decision-log.md
@@ -54,3 +54,9 @@
- Decision: Raise the workspace floor (MSRV) to Rust 1.71.1. All crates and CI jobs target 1.71.1.
- Implementation: Updated `rust-toolchain.toml` to 1.71.1; bumped `rust-version` in crate manifests; CI jobs pin 1.71.1; devcontainer installs only 1.71.1.
+## 2025-10-29 — Docs E2E carousel init (PR #10)
+
+- Context: Playwright tour test clicks Next to enter carousel from "all" mode.
+- Decision: Do not disable Prev/Next in "all" mode; allow navigation buttons to toggle into carousel.
+- Change: docs/assets/collision/animate.js leaves Prev/Next enabled in 'all'; boundary disabling still applies in single-slide mode.
+- Consequence: Users can initiate the carousel via navigation controls; E2E tour test passes deterministically.
diff --git a/docs/execution-plan.md b/docs/execution-plan.md
index 76bec188..13e0a0f3 100644
--- a/docs/execution-plan.md
+++ b/docs/execution-plan.md
@@ -33,6 +33,12 @@ This is Codex’s working map for building Echo. Update it relentlessly—each s
## Today’s Intent
+> 2025-10-29 — Docs E2E (PR #10)
+
+- Collision DPO tour carousel: keep Prev/Next enabled in "all" mode so users and tests can enter carousel via navigation. Fixes Playwright tour test.
+- Updated Makefile by merging hooks target with docs targets.
+- CI Docs Guard satisfied with this entry; Decision Log updated.
+
> 2025-10-29 — rmg-core snapshot header + tx/rules hardening (PR #9 base)
- Adopt Snapshot v1 header shape in `rmg-core` with `parents: Vec`, and canonical digests:
diff --git a/docs/guide/collision-tour.md b/docs/guide/collision-tour.md
new file mode 100644
index 00000000..5bb3c340
--- /dev/null
+++ b/docs/guide/collision-tour.md
@@ -0,0 +1,13 @@
+---
+title: Collision DPO Tour
+---
+
+# Collision DPO Tour
+
+The interactive tour illustrates how Echo models collision and CCD as DPO graph rewrites.
+
+- Launch the tour: [docs/collision-dpo-tour.html](../collision-dpo-tour.html)
+- Assets live under `docs/assets/collision/`.
+
+Tip: Toggle the World/Graph tabs in the picture-in-picture panel and use the Prev/Next buttons to step through each rule.
+
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 00000000..9a3709db
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,11 @@
+---
+title: Echo Docs
+---
+
+# Echo
+
+Deterministic, multiverse-aware ECS.
+
+- See the Collision DPO Tour: [Open the interactive HTML](./collision-dpo-tour.html).
+- Read the spec: [Geometry & Collision](./spec-geom-collision.md).
+
diff --git a/e2e/collision-dpo-tour.spec.ts b/e2e/collision-dpo-tour.spec.ts
new file mode 100644
index 00000000..14aee96f
--- /dev/null
+++ b/e2e/collision-dpo-tour.spec.ts
@@ -0,0 +1,51 @@
+import { test, expect } from '@playwright/test'
+import { resolve } from 'node:path'
+import { pathToFileURL } from 'node:url'
+
+function fileUrl(rel: string) {
+ return pathToFileURL(resolve(rel)).href
+}
+
+test.describe('Collision DPO Tour (static HTML)', () => {
+ test('loads and renders', async ({ page }) => {
+ await page.goto(fileUrl('docs/collision-dpo-tour.html'))
+ await expect(page.locator('h1')).toHaveText(/Collision/i)
+ // Animate script attaches pagers; ensure at least one exists
+ await expect(page.locator('.pager').first()).toBeVisible()
+ })
+
+ test('tabs toggle World/Graph views', async ({ page }) => {
+ await page.goto(fileUrl('docs/collision-dpo-tour.html'))
+ // Find a figure with pip tabs
+ const tabs = page.locator('.pip-tabs').first()
+ await expect(tabs).toBeVisible()
+ const graphTab = tabs.locator('.tab', { hasText: 'Graph' })
+ const worldTab = tabs.locator('.tab', { hasText: 'World' })
+ await graphTab.click()
+ // Graph image should be visible, world hidden within the same figure
+ const fig = tabs.locator('..') // pip
+ const pip = fig
+ await expect(pip.locator('img[alt="Graph view"]')).toBeVisible()
+ await expect(pip.locator('img[alt="World view"]')).toBeHidden()
+ await worldTab.click()
+ await expect(pip.locator('img[alt="World view"]')).toBeVisible()
+ })
+
+ test('prev/next navigation toggles carousel mode', async ({ page }) => {
+ await page.goto(fileUrl('docs/collision-dpo-tour.html'))
+ const firstRule = page.locator('.rule').first()
+ const nextBtn = firstRule.locator('.pager .btn', { hasText: 'Next' })
+ await expect(nextBtn).toBeVisible()
+ // Initially all slides are visible
+ const figs = firstRule.locator('.step-grid figure')
+ const total = await figs.count()
+ expect(total).toBeGreaterThan(1)
+ // Click next -> enter carousel mode (only one visible)
+ await nextBtn.click()
+ // Wait a tick for layout updates
+ await page.waitForTimeout(50)
+ const hiddenCount = await firstRule.locator('.step-grid figure.hidden').count()
+ expect(hiddenCount).toBeGreaterThan(0)
+ })
+})
+
diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts
new file mode 100644
index 00000000..11509fc4
--- /dev/null
+++ b/e2e/playwright.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from '@playwright/test'
+
+export default defineConfig({
+ testDir: './e2e',
+ retries: 0,
+ use: {
+ headless: true,
+ viewport: { width: 1280, height: 800 },
+ ignoreHTTPSErrors: true,
+ },
+})
+
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..e040490d
--- /dev/null
+++ b/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "echo-docs-and-tests",
+ "private": true,
+ "scripts": {
+ "docs:dev": "vitepress dev docs",
+ "docs:build": "vitepress build docs",
+ "test:e2e": "playwright test"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.48.0",
+ "vitepress": "^1.3.3"
+ }
+}
+
diff --git a/scripts/docs-open.sh b/scripts/docs-open.sh
new file mode 100755
index 00000000..3365a020
--- /dev/null
+++ b/scripts/docs-open.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+set -euo pipefail
+URL="${1:-http://localhost:5173/}"
+case "${OSTYPE:-}" in
+ darwin*) open "$URL" ;;
+ linux*) xdg-open "$URL" >/dev/null 2>&1 || echo "Open: $URL" ;;
+ msys*|cygwin*|win32*) cmd.exe /c start "" "$URL" ;;
+ *) echo "Open: $URL" ;;
+esac
+