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).
335 lines
14 KiB
JavaScript
335 lines
14 KiB
JavaScript
// Proof-page composer. Loads:
|
|
// - /api/root/:hash → header info
|
|
// - /api/root/:hash/leaves → leaves strip (privacy-aware)
|
|
// - /api/lattice/:hash → all tree layers for the 3D widget
|
|
// - /api/proof/:hash/:leaf → proof card (on leaf selection)
|
|
// - /api/audit/recent?subject_root_filter=... → linked audit events
|
|
// Then mounts the six.js Merkle lattice scene.
|
|
|
|
import { mountLattice } from "./merkle-lattice.js";
|
|
import { chunkHex } from "./hash.js";
|
|
|
|
export async function mountProofPage(rootHash, initialLeafIndex, cacheKey) {
|
|
const rootCard = document.getElementById("root-hash");
|
|
const proofCard = document.getElementById("proof-card");
|
|
const leavesStrip = document.getElementById("leaves-strip");
|
|
const latticeStatus = document.getElementById("lattice-status");
|
|
const auditLink = document.getElementById("audit-link");
|
|
const leafContent = document.getElementById("leaf-content");
|
|
const contentModeLabel = document.getElementById("content-mode-label");
|
|
const contentToggle = document.getElementById("content-toggle");
|
|
const contentBaseVersion = document.getElementById("content-base-version");
|
|
const btnPulse = document.getElementById("btn-pulse");
|
|
const btnReset = document.getElementById("btn-reset");
|
|
|
|
// "prose" = arborist.wikitext.to_base() projection (versioned, derived
|
|
// from raw content); "raw" = the bytes the leaf hash actually commits to.
|
|
let contentMode = "prose";
|
|
let currentLeafIdx = null;
|
|
|
|
// 1) Root metadata.
|
|
const root = await getJSON(`/api/root/${rootHash}`);
|
|
if (root.error) {
|
|
latticeStatus.textContent = `root not found in shard (${root.error})`;
|
|
return;
|
|
}
|
|
rootCard.textContent = `${chunkHex(rootHash)}\n${root.title || ''}\n${root.document_uri || ''}\n${root.leaf_count} leaves · ${root.chunking_version}`;
|
|
|
|
// Context-root variant: the hash is a Merkle root over sorted source
|
|
// document_roots — same canonicalization as a document tree, so the
|
|
// standard widgets (lattice, leaves, inclusion proof) still apply.
|
|
// Just additionally surface the QA card + sources manifest.
|
|
if (root.kind === "context_root") {
|
|
renderContextExtras(root, cacheKey).catch((err) => {
|
|
console.warn("context extras render failed:", err);
|
|
});
|
|
}
|
|
|
|
// 2) Leaves strip + content cache.
|
|
const leavesRes = await getJSON(`/api/root/${rootHash}/leaves`);
|
|
const leaves = leavesRes.leaves || [];
|
|
if (contentModeLabel) {
|
|
contentModeLabel.textContent = leavesRes.private_revealed
|
|
? "(private: reveal)"
|
|
: "(private: deny — content hidden)";
|
|
}
|
|
renderLeavesStrip(leavesStrip, leaves, (idx) => loadProof(idx));
|
|
|
|
// 3) Lattice — fetch all layers, build scene.
|
|
const lattice = await getJSON(`/api/lattice/${rootHash}`);
|
|
latticeStatus.textContent = `${lattice.layer_count} layers · ${lattice.leaf_count} leaves`;
|
|
|
|
const sceneApi = await mountLattice(document.getElementById("lattice-canvas"), lattice.layers);
|
|
btnPulse.disabled = false;
|
|
|
|
// Provenance glow: cited chunks (used) bright gold, primary candidates
|
|
// soft mint. Leaves carry `used` / `source_role` for context roots;
|
|
// regular document roots have neither, so markUsage is a no-op there.
|
|
sceneApi.markUsage(
|
|
leaves.map((L) => ({ used: !!L.used, role: L.source_role }))
|
|
);
|
|
|
|
// Canvas leaf-click → compute that leaf's inclusion proof (matches
|
|
// the leaves-strip chip behavior). Any-node click → surface the
|
|
// hash + layer + idx in the inspector so interior + root spheres
|
|
// aren't visually inert.
|
|
sceneApi.onLeafSelect((idx) => loadProof(idx));
|
|
sceneApi.onNodeSelect((node) => {
|
|
const inspect = document.getElementById("node-inspect");
|
|
if (!inspect) return;
|
|
const label = node.isLeaf
|
|
? `leaf #${node.idx}`
|
|
: (node.layer === lattice.layers.length - 1 ? "root" : `interior · layer ${node.layer} · #${node.idx}`);
|
|
inspect.classList.remove("muted");
|
|
inspect.innerHTML = `<span class="k">${label}</span> <span class="v mono">${chunkHex(node.hash)}</span>`;
|
|
});
|
|
|
|
// 4) Audit linkage — indexed lookup by subject_root (cheap even on a
|
|
// 3.5M-event chain).
|
|
try {
|
|
const j = await getJSON(`/api/audit/by-root/${rootHash}?limit=10`);
|
|
const linked = j.events || [];
|
|
if (linked.length === 0) {
|
|
auditLink.textContent = "no audit events for this root";
|
|
} else {
|
|
auditLink.innerHTML = "";
|
|
for (const ev of linked.slice(0, 8)) {
|
|
const a = document.createElement("a");
|
|
a.href = `/audit/${ev.event_hash}`;
|
|
a.className = "audit-link-row hash-id";
|
|
a.title = ev.event_hash;
|
|
a.textContent = `${ev.event_type} ${chunkHex(ev.event_hash)}`;
|
|
auditLink.appendChild(a);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
auditLink.textContent = `audit lookup failed: ${err.message}`;
|
|
}
|
|
|
|
let lastProof = null;
|
|
|
|
async function loadProof(idx) {
|
|
proofCard.innerHTML = `<p class="muted">computing proof for leaf #${idx}…</p>`;
|
|
sceneApi.highlightLeaf(idx);
|
|
const proof = await getJSON(`/api/proof/${rootHash}/${idx}`);
|
|
lastProof = proof;
|
|
renderProofCard(proofCard, proof);
|
|
sceneApi.setProofPath(proof);
|
|
btnPulse.disabled = false;
|
|
|
|
// Mirror the canvas-click inspector update so chip clicks also
|
|
// surface the full leaf hash (no truncation).
|
|
const inspect = document.getElementById("node-inspect");
|
|
if (inspect) {
|
|
const fullHash = proof.leaf_hash || leaves[idx]?.leaf_hash || "";
|
|
inspect.classList.remove("muted");
|
|
inspect.innerHTML = `<span class="k">leaf #${idx}</span> <span class="v mono">${chunkHex(fullHash)}</span>`;
|
|
}
|
|
|
|
currentLeafIdx = idx;
|
|
renderLeafContent();
|
|
}
|
|
|
|
function renderLeafContent() {
|
|
const leaf = currentLeafIdx == null ? null : leaves[currentLeafIdx];
|
|
if (!leaf) return;
|
|
if (leaf.content == null) {
|
|
leafContent.classList.add("muted");
|
|
leafContent.textContent = leavesRes.private_revealed
|
|
? "(no stored content for this leaf — cold tier?)"
|
|
: "private-leaf default-deny — set privacy.reveal_private_leaves=true in the .ini to view content.";
|
|
if (contentToggle) contentToggle.hidden = true;
|
|
return;
|
|
}
|
|
|
|
// Show toggle only when a prose projection exists AND differs from raw.
|
|
const hasProse = leaf.prose != null && leaf.prose !== leaf.content;
|
|
if (contentToggle) contentToggle.hidden = !hasProse;
|
|
if (contentBaseVersion) {
|
|
contentBaseVersion.textContent = hasProse && leaf.base_version
|
|
? `· ${leaf.base_version} projection`
|
|
: "";
|
|
}
|
|
|
|
leafContent.classList.remove("muted");
|
|
if (contentMode === "prose" && hasProse) {
|
|
leafContent.textContent = leaf.prose;
|
|
} else {
|
|
leafContent.textContent = leaf.content;
|
|
}
|
|
|
|
if (contentToggle) {
|
|
contentToggle.querySelectorAll("button").forEach((b) => {
|
|
b.classList.toggle("active", b.dataset.mode === contentMode);
|
|
});
|
|
}
|
|
}
|
|
|
|
if (contentToggle) {
|
|
contentToggle.addEventListener("click", (ev) => {
|
|
const btn = ev.target.closest("button[data-mode]");
|
|
if (!btn) return;
|
|
contentMode = btn.dataset.mode;
|
|
renderLeafContent();
|
|
});
|
|
}
|
|
|
|
// Context manifest "leaf #N" badges → highlight + prove that leaf in
|
|
// the current tree. Delegated so it works regardless of when the async
|
|
// renderContextExtras populated the list.
|
|
const sourcesListEl = document.getElementById("sources-list");
|
|
if (sourcesListEl) {
|
|
sourcesListEl.addEventListener("click", (ev) => {
|
|
const badge = ev.target.closest(".leaf-jump");
|
|
if (!badge) return;
|
|
ev.preventDefault();
|
|
const idx = parseInt(badge.dataset.leaf, 10);
|
|
if (!Number.isNaN(idx)) loadProof(idx);
|
|
});
|
|
}
|
|
|
|
btnPulse.addEventListener("click", () => {
|
|
if (lastProof) sceneApi.pulseProofPath();
|
|
else if (leaves.length) loadProof(0);
|
|
});
|
|
btnReset.addEventListener("click", () => {
|
|
sceneApi.reset();
|
|
proofCard.innerHTML = `<p class="muted">select a leaf to compute its inclusion proof…</p>`;
|
|
lastProof = null;
|
|
});
|
|
|
|
if (initialLeafIndex != null && leaves.length > initialLeafIndex) {
|
|
await loadProof(initialLeafIndex);
|
|
sceneApi.pulseProofPath();
|
|
} else if (leaves.length > 0) {
|
|
// Auto-select the first leaf so the dashboard is visually populated
|
|
// the moment the page lands.
|
|
await loadProof(0);
|
|
}
|
|
}
|
|
|
|
function renderLeavesStrip(el, leaves, onSelect) {
|
|
el.innerHTML = "";
|
|
if (!leaves.length) {
|
|
el.innerHTML = `<span class="muted">no leaves in shard</span>`;
|
|
return;
|
|
}
|
|
for (const leaf of leaves) {
|
|
const btn = document.createElement("button");
|
|
const usedTag = leaf.used && leaf.used_pointer_ids && leaf.used_pointer_ids.length
|
|
? ` (${leaf.used_pointer_ids.join(",")})` : "";
|
|
btn.className = "leaf-chip"
|
|
+ (leaf.used ? " used" : (leaf.source_role === "primary_answer_source" ? " primary" : ""));
|
|
btn.title = leaf.used ? `${leaf.leaf_hash}\nused${usedTag}` : leaf.leaf_hash;
|
|
btn.innerHTML = `<span class="leaf-idx">#${leaf.idx}</span><span class="leaf-hash">${leaf.leaf_hash}</span>`
|
|
+ (leaf.used ? `<span class="leaf-used">used${usedTag}</span>` : "");
|
|
btn.addEventListener("click", () => {
|
|
el.querySelectorAll(".leaf-chip.active").forEach((n) => n.classList.remove("active"));
|
|
btn.classList.add("active");
|
|
onSelect(leaf.idx);
|
|
});
|
|
el.appendChild(btn);
|
|
}
|
|
}
|
|
|
|
function renderProofCard(el, proof) {
|
|
if (proof.error) {
|
|
el.innerHTML = `<p class="muted">error: ${proof.error}</p>`;
|
|
return;
|
|
}
|
|
const passClass = proof.passed ? "passed" : "failed";
|
|
el.innerHTML = `
|
|
<div class="proof-pass ${passClass}">${proof.passed ? "✓ PASSED" : "✗ FAILED"}</div>
|
|
<div class="proof-row"><span class="k">leaf #</span><span class="v">${proof.leaf_index}</span></div>
|
|
<div class="proof-row"><span class="k">leaf hash</span><span class="v mono">${chunkHex(proof.leaf_hash)}</span></div>
|
|
<div class="proof-row"><span class="k">siblings</span><span class="v">${proof.sibling_hashes.length}</span></div>
|
|
<div class="proof-row"><span class="k">computed root</span><span class="v mono">${chunkHex(proof.computed_root)}</span></div>
|
|
<div class="proof-row"><span class="k">expected root</span><span class="v mono">${chunkHex(proof.expected_root)}</span></div>
|
|
<details class="proof-details">
|
|
<summary>sibling path</summary>
|
|
<ol class="sibling-list">
|
|
${proof.sibling_hashes.map((h, i) => `<li title="${h}">${proof.left_right_flags[i] ? "←" : "→"} ${chunkHex(h)}</li>`).join("")}
|
|
</ol>
|
|
</details>
|
|
<div class="muted">Inclusion is integrity, not semantic truth.</div>
|
|
`;
|
|
}
|
|
|
|
async function getJSON(url) {
|
|
const res = await fetch(url);
|
|
const j = await res.json().catch(() => ({}));
|
|
if (!res.ok && !j.error) j.error = `HTTP ${res.status}`;
|
|
return j;
|
|
}
|
|
|
|
async function renderContextExtras(root, cacheKey) {
|
|
// Additive overlay for context-root pages — the lattice + leaves +
|
|
// inclusion-proof widgets already render the merkle tree built from
|
|
// sorted source document_roots. This adds the QA card (question +
|
|
// answer) and a richer sources manifest with per-source links.
|
|
const ctxPanel = document.getElementById("context-panel");
|
|
const qaCard = document.getElementById("qa-card");
|
|
const sourcesList = document.getElementById("sources-list");
|
|
|
|
const effectiveCacheKey = cacheKey || root.cache_key;
|
|
if (effectiveCacheKey && qaCard) {
|
|
const qa = await getJSON(`/api/cache/${effectiveCacheKey}`);
|
|
if (!qa.error) {
|
|
qaCard.hidden = false;
|
|
qaCard.innerHTML = `
|
|
<div class="qa-row"><span class="k">question</span><span class="v">${escapeHTML(qa.question_text || "")}</span></div>
|
|
<div class="qa-row"><span class="k">answer</span><pre class="v qa-answer">${escapeHTML(qa.answer_text || "")}</pre></div>
|
|
<div class="qa-row"><span class="k">cache_key</span><span class="v mono">${chunkHex(qa.cache_key)}</span></div>
|
|
<div class="qa-row"><span class="k">state</span><span class="v">${escapeHTML(qa.falsification_state || "")} · hits ${qa.hit_count ?? 0}</span></div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
if (sourcesList) {
|
|
sourcesList.innerHTML = "";
|
|
const sources = root.sources || [];
|
|
if (!sources.length) {
|
|
sourcesList.innerHTML = `<li class="muted">no sources recorded in this QA's merkle_proof.</li>`;
|
|
} else {
|
|
// The context tree's leaves are the source document_roots sorted
|
|
// lexicographically (matches arborist.qa.query._context_root and
|
|
// the read seam's _context_leaf_hashes). Number every source by
|
|
// its position in THAT order so the manifest agrees with the leaf
|
|
// chips, the lattice, and the inclusion proof's leaf_index — all
|
|
// 0-based. (Was an <ol>, which auto-numbered 1-based, over the
|
|
// unsorted score-ranked list — two different orderings + two
|
|
// different bases.)
|
|
const ordered = [...sources].sort((a, b) =>
|
|
(a.document_root || "").toLowerCase().localeCompare((b.document_root || "").toLowerCase())
|
|
);
|
|
ordered.forEach((s, leafIdx) => {
|
|
const li = document.createElement("li");
|
|
li.className = "source-row" + (s.used ? " used" : "");
|
|
const score = typeof s.score === "number" ? s.score.toFixed(1) : "";
|
|
const usedPtrs = s.used_pointer_ids && s.used_pointer_ids.length
|
|
? ` (${escapeHTML(s.used_pointer_ids.join(","))})` : "";
|
|
const usedTag = s.used
|
|
? ` · <span class="used-tag">used${usedPtrs}</span>` : "";
|
|
li.innerHTML = `
|
|
<button type="button" class="leaf-jump" data-leaf="${leafIdx}" title="highlight leaf #${leafIdx} in the tree">leaf #${leafIdx}</button>
|
|
<a class="source-link" href="/proof/${encodeURIComponent(s.document_root)}">
|
|
<span class="source-title">${escapeHTML(s.title || s.document_uri || s.document_root.slice(0, 16))}</span>
|
|
<span class="source-uri muted">${escapeHTML(s.document_uri || "")}</span>
|
|
<span class="source-meta muted">
|
|
role: ${escapeHTML(s.source_role || "?")} · chunk #${s.chunk_idx ?? "?"} · score ${score} · shard ${escapeHTML(s.shard || "?")}${usedTag}
|
|
</span>
|
|
<span class="source-hash mono">${chunkHex(s.document_root)}</span>
|
|
</a>
|
|
`;
|
|
sourcesList.appendChild(li);
|
|
});
|
|
}
|
|
}
|
|
if (ctxPanel) ctxPanel.hidden = false;
|
|
}
|
|
|
|
function escapeHTML(s) {
|
|
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
})[c]);
|
|
}
|