arborist-viz/arborist_viz/static/viz/merkle-lattice.js
russell@unturf.com 0cd96ab1d7
Initial commit: arborist VIZ — Merkle command center
Pyramid web app for inspecting arborist Merkle trees over sharded SQLite:
document roots, inclusion proofs, an interactive 3D Merkle lattice
(six.js / WebGL with graceful fallback), audit-chain linkage, and a live
SSE event stream.

Generated artifacts (env/, vendored static/six/ bundles, demo databases)
are gitignored and rebuilt via the Makefile (venv, six, init-db, serve).
2026-05-28 09:22:59 -04:00

377 lines
13 KiB
JavaScript

// 3D Merkle lattice via six.js (fox's three.js fork).
//
// Layers stack on the Y axis: leaves at y=0, root at top. Each node is a
// small sphere; child→parent edges are line segments. A proof "pulse"
// recolors leaf → siblings → running-hash path → root in waves.
//
// Render loop pauses when no animation is in flight (ticket §10
// "Pause render loop when static if possible"). Reset wipes selection.
import * as THREE from "/static/six/six.module.js";
const COLORS = {
leaf: 0x4dd1c1, // mint
interior: 0x7aa2ff, // soft blue
root: 0xf5b455, // amber
edge: 0x1f2934, // dark gray
selected: 0xffffff, // bright white
usedLeaf: 0xffd166, // gold — chunk the answer actually cited (glow)
primaryLeaf: 0x4dd1c1, // mint — primary candidate, retrieved but not cited
pulseLeaf: 0x4dd1c1,
pulseSib: 0xf5b455, // siblings = amber
pulsePath: 0x7aa2ff, // running-hash path = blue
pulseRoot: 0xe96b6b, // root flash = red on arrival
};
export async function mountLattice(container, layers) {
// Container sizing.
const width = () => container.clientWidth || 600;
const height = () => container.clientHeight || 400;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b0e12);
// Camera — adapts to layer count + max layer width.
const maxWidth = Math.max(1, ...layers.map((l) => l.length));
const layerCount = layers.length;
const camera = new THREE.PerspectiveCamera(45, width() / height(), 0.1, 1000);
camera.position.set(0, layerCount * 1.2, Math.max(maxWidth * 1.4, layerCount * 2.2, 8));
camera.lookAt(0, layerCount * 0.5, 0);
// WebGL context creation can fail (no GPU, blocklisted driver, a
// browser without a software fallback). Only this panel needs 3D — the
// inclusion proof, leaves strip, and audit chain do not. Degrade to a
// message and hand back a no-op API so the caller's sceneApi.* calls
// stay safe instead of throwing and blanking the whole proof page.
let renderer;
try {
renderer = new THREE.WebGLRenderer({ antialias: true });
} catch (err) {
console.warn("merkle-lattice: WebGL unavailable — 3D view disabled.", err);
container.innerHTML =
`<div class="lattice-fallback muted">3D lattice unavailable — this browser could not create a WebGL context. The inclusion proof, leaves, and audit chain still work.</div>`;
const noop = () => {};
return {
highlightLeaf: noop,
markUsage: noop,
setProofPath: noop,
pulseProofPath: noop,
reset: noop,
onLeafSelect: noop,
onNodeSelect: noop,
};
}
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(width(), height());
container.innerHTML = "";
container.appendChild(renderer.domElement);
// Lights — soft ambient + a key from above.
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
const key = new THREE.DirectionalLight(0xffffff, 0.8);
key.position.set(2, layerCount + 2, 4);
scene.add(key);
// Build node meshes per layer. Spread leaves widest, root smallest.
const nodesByLayer = []; // nodesByLayer[layerIdx][nodeIdx] = mesh
const allNodeMeshes = [];
for (let li = 0; li < layers.length; li++) {
const layer = layers[li];
const isRoot = li === layers.length - 1;
const isLeaf = li === 0;
const layerNodes = [];
const spacing = isRoot ? 0 : 1.4;
const total = (layer.length - 1) * spacing;
for (let ni = 0; ni < layer.length; ni++) {
const radius = isRoot ? 0.5 : (isLeaf ? 0.3 : 0.36);
const geom = new THREE.SphereGeometry(radius, 24, 16);
const baseColor = isRoot ? COLORS.root : (isLeaf ? COLORS.leaf : COLORS.interior);
const mat = new THREE.MeshStandardMaterial({
color: baseColor,
emissive: baseColor,
emissiveIntensity: 0.25,
roughness: 0.4,
metalness: 0.1,
});
const mesh = new THREE.Mesh(geom, mat);
const x = (ni * spacing) - total / 2;
const y = li * 1.4;
mesh.position.set(x, y, 0);
mesh.userData = {
layer: li, idx: ni, baseColor, hash: layer[ni], isLeaf, isRoot,
baseIntensity: 0.25,
};
scene.add(mesh);
layerNodes.push(mesh);
allNodeMeshes.push(mesh);
}
nodesByLayer.push(layerNodes);
}
// Child→parent edges. Each parent at layer l has children at layer l-1
// (indices 2*idx and 2*idx+1; odd self-dup means right==left when
// 2*idx+1 >= len(child layer)).
const edgeMaterial = new THREE.LineBasicMaterial({ color: COLORS.edge });
for (let li = 1; li < nodesByLayer.length; li++) {
const child = nodesByLayer[li - 1];
const parent = nodesByLayer[li];
for (let pi = 0; pi < parent.length; pi++) {
const left = child[2 * pi];
const right = child[(2 * pi + 1) < child.length ? (2 * pi + 1) : (2 * pi)];
if (!left) continue;
const p = parent[pi].position;
const segL = lineSegment(p, left.position, edgeMaterial);
scene.add(segL);
parent[pi].userData.edgeLeft = segL;
if (right && right !== left) {
const segR = lineSegment(p, right.position, edgeMaterial);
scene.add(segR);
parent[pi].userData.edgeRight = segR;
}
}
}
// Render-loop control. We only animate when a pulse is running.
let pulseQueue = [];
let pulseAt = 0;
let highlightedLeaf = null;
let proofPath = null; // {leaf_index, sibling_hashes, left_right_flags}
function render() {
renderer.render(scene, camera);
}
function frame() {
if (pulseQueue.length === 0) {
render();
return;
}
const now = performance.now();
while (pulseQueue.length && now >= pulseQueue[0].at) {
const step = pulseQueue.shift();
if (step.kind === "color") {
recolor(step.mesh, step.color, step.intensity);
} else if (step.kind === "restore") {
restore(step.mesh);
}
}
render();
if (pulseQueue.length) requestAnimationFrame(frame);
else render();
}
render();
// Drag-to-rotate (no OrbitControls dependency).
let isDragging = false;
let lastX = 0;
let yaw = 0;
let pitch = 0;
function applyCamera() {
const r = Math.max(maxWidth * 1.4, layerCount * 2.2, 8);
camera.position.set(
Math.sin(yaw) * Math.cos(pitch) * r,
layerCount * 0.6 + Math.sin(pitch) * r,
Math.cos(yaw) * Math.cos(pitch) * r,
);
camera.lookAt(0, layerCount * 0.5, 0);
render();
}
renderer.domElement.addEventListener("pointerdown", (ev) => {
isDragging = true; lastX = ev.clientX; renderer.domElement.setPointerCapture(ev.pointerId);
});
renderer.domElement.addEventListener("pointermove", (ev) => {
if (!isDragging) return;
const dx = ev.clientX - lastX;
lastX = ev.clientX;
yaw += dx * 0.01;
applyCamera();
});
renderer.domElement.addEventListener("pointerup", () => { isDragging = false; });
// Click-to-select.
const raycaster = new THREE.Raycaster();
const ndc = new THREE.Vector2();
let onLeafSelect = null;
let onNodeSelect = null;
renderer.domElement.addEventListener("click", (ev) => {
const rect = renderer.domElement.getBoundingClientRect();
ndc.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
ndc.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(ndc, camera);
const hits = raycaster.intersectObjects(allNodeMeshes, false);
if (!hits.length) return;
const hit = hits[0].object;
const ud = hit.userData;
if (ud.isLeaf && onLeafSelect) {
onLeafSelect(ud.idx);
}
// Surface every node (leaves included) for inspectors that want the
// hash + layer + idx — proof-page renders it in a side panel so
// interior + root spheres no longer feel inert.
if (onNodeSelect) onNodeSelect({ hash: ud.hash, layer: ud.layer, idx: ud.idx, isLeaf: !!ud.isLeaf });
});
// Window resize.
window.addEventListener("resize", () => {
renderer.setSize(width(), height());
camera.aspect = width() / height();
camera.updateProjectionMatrix();
render();
});
// ----- public API ------
return {
highlightLeaf(idx) {
restoreAll();
highlightedLeaf = idx;
const leaf = nodesByLayer[0][idx];
if (leaf) recolor(leaf, COLORS.selected, 1.2);
render();
},
markUsage(usage) {
// Persistent provenance glow on layer-0 leaves:
// used (cited in the answer) → bright gold;
// primary candidate (retrieved, not cited) → soft mint glow;
// background → default. Written into each node's BASE appearance
// so proof-pulse restores keep the glow. `usage` is indexed by
// leaf position (sorted by document_root — matches the lattice,
// the leaves strip, and the inclusion-proof leaf_index).
if (!Array.isArray(usage)) return;
const leafLayer = nodesByLayer[0] || [];
usage.forEach((u, idx) => {
const mesh = leafLayer[idx];
if (!mesh || !u) return;
if (u.used) {
mesh.userData.baseColor = COLORS.usedLeaf;
mesh.userData.baseIntensity = 1.3;
} else if (u.role === "primary_answer_source") {
mesh.userData.baseColor = COLORS.primaryLeaf;
mesh.userData.baseIntensity = 0.7;
} else {
return;
}
restore(mesh);
});
render();
},
setProofPath(proof) {
proofPath = proof;
},
pulseProofPath() {
restoreAll();
if (!proofPath || proofPath.error) return;
const leafIdx = proofPath.leaf_index;
const flags = proofPath.left_right_flags || [];
// Build the running-hash path: at each layer, the node we're
// climbing through (leaf → parent → … → root).
const runningPath = [];
let idx = leafIdx;
for (let li = 0; li < nodesByLayer.length; li++) {
runningPath.push({ layer: li, idx });
idx = Math.floor(idx / 2);
}
// Sibling path: at each layer below the root, the sibling we hash with.
const siblingPath = [];
idx = leafIdx;
for (let li = 0; li < flags.length; li++) {
const is_left = flags[li]; // sibling is left of running
const sib = is_left ? idx - 1 : idx + 1;
// odd-element self-duplication: if sib out of range, the sibling
// IS the running node — we render no extra highlight (the sphere
// is already highlighted by running path).
const layer = nodesByLayer[li];
if (layer && sib >= 0 && sib < layer.length) {
siblingPath.push({ layer: li, idx: sib });
}
idx = Math.floor(idx / 2);
}
pulseQueue = [];
pulseAt = performance.now();
const STEP = 260; // ms between pulses
// Wave 1 — light the leaf.
schedule(0, "color", nodesByLayer[0][leafIdx], COLORS.pulseLeaf, 1.5);
// Wave 2 — light each sibling in order.
siblingPath.forEach((p, i) => {
const mesh = nodesByLayer[p.layer][p.idx];
schedule(STEP * (i + 1), "color", mesh, COLORS.pulseSib, 1.4);
});
// Wave 3 — climb running-hash path.
runningPath.forEach((p, i) => {
if (p.layer === 0) return; // leaf already lit
const mesh = nodesByLayer[p.layer][p.idx];
const isRoot = p.layer === nodesByLayer.length - 1;
const t = STEP * (siblingPath.length + 1 + i);
schedule(
t,
"color",
mesh,
isRoot ? COLORS.pulseRoot : COLORS.pulsePath,
isRoot ? 2.0 : 1.4,
);
});
requestAnimationFrame(frame);
},
reset() {
restoreAll();
pulseQueue = [];
proofPath = null;
highlightedLeaf = null;
render();
},
onLeafSelect(callback) {
onLeafSelect = callback;
},
onNodeSelect(callback) {
onNodeSelect = callback;
},
};
// ----- helpers -----
function schedule(deltaMs, kind, mesh, color, intensity) {
pulseQueue.push({ at: pulseAt + deltaMs, kind, mesh, color, intensity });
}
function recolor(mesh, color, intensity) {
if (!mesh) return;
mesh.material.color.setHex(color);
mesh.material.emissive.setHex(color);
mesh.material.emissiveIntensity = intensity != null ? intensity : 0.5;
}
function restore(mesh) {
if (!mesh) return;
const c = mesh.userData.baseColor;
mesh.material.color.setHex(c);
mesh.material.emissive.setHex(c);
// baseIntensity carries the persistent provenance glow (markUsage) so
// a proof-pulse restore returns to the glow, not the flat default.
mesh.material.emissiveIntensity =
mesh.userData.baseIntensity != null ? mesh.userData.baseIntensity : 0.25;
}
function restoreAll() {
for (const m of allNodeMeshes) restore(m);
}
}
function lineSegment(a, b, material) {
const geom = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(a.x, a.y, a.z),
new THREE.Vector3(b.x, b.y, b.z),
]);
return new THREE.Line(geom, material);
}