diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 8e089b7..74543c7 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -4,6 +4,7 @@ on: push: branches: - main + pull_request: workflow_dispatch: concurrency: @@ -35,8 +36,10 @@ jobs: - name: Build documentation run: | python3 -m sphinx -b html doc doc/build --keep-going - cd doc/build - touch .nojekyll + touch doc/build/.nojekyll + + - name: Build proof visualization + run: tools/proof-viz/make_viz.sh doc/build/proof-viz - name: Upload pages artifact uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 diff --git a/doc/index.rst b/doc/index.rst index 1aaf272..598273d 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -19,6 +19,12 @@ example: :end-before: SNIPPET_END: OpenSSL-SHA256 :dedent: 2 +Proof visualization +------------------- + +Explore the `interactive tile and frontier proof atlas `_ to see +which durable tiles and resident Merkle nodes supply each proof hash. + Trees ----- diff --git a/tools/proof-viz/.gitignore b/tools/proof-viz/.gitignore new file mode 100644 index 0000000..fe107c2 --- /dev/null +++ b/tools/proof-viz/.gitignore @@ -0,0 +1 @@ +/data.json \ No newline at end of file diff --git a/tools/proof-viz/app.js b/tools/proof-viz/app.js new file mode 100644 index 0000000..2ebbfa8 --- /dev/null +++ b/tools/proof-viz/app.js @@ -0,0 +1,543 @@ +"use strict"; + +const scenarioRoot = document.querySelector("#scenarios"); + +(async () => { + const response = await fetch("data.json"); + if (!response.ok) { + throw new Error(`Could not load proof data (${response.status})`); + } + + const data = await response.json(); + if (data.schemaVersion !== 1 || !Array.isArray(data.scenarios)) { + throw new Error("Proof visualization data is missing"); + } + + const canvasColorNames = [ + "tile", "frontier", "computed", "route", "query", "endpoint", + "edge", "boundary", "muted", "paper" + ]; + const readCanvasColors = () => { + const styles = getComputedStyle(document.documentElement); + return Object.fromEntries(canvasColorNames.map( + (name) => [name, styles.getPropertyValue(`--${name}`).trim()] + )); + }; + let colors = readCanvasColors(); + + const state = { + edges: true, + calls: true + }; + + const number = new Intl.NumberFormat("en-US"); + const tooltip = document.querySelector("#node-tooltip"); + const renderers = []; + + window.matchMedia("(prefers-color-scheme: dark)").addEventListener( + "change", + (event) => { + if (!localStorage.getItem("theme")) { + document.documentElement.dataset.theme = event.matches ? "dark" : "light"; + } + colors = readCanvasColors(); + renderers.forEach((renderer) => renderer.draw()); + } + ); + + scenarioRoot.replaceChildren(); + scenarioRoot.removeAttribute("aria-busy"); + document.querySelector("#scene-count").textContent = data.scenarios.length; + document.querySelector("#tile-width").textContent = number.format(data.tileWidth); + + function fact(term, value) { + const wrapper = document.createElement("div"); + const name = document.createElement("dt"); + const content = document.createElement("dd"); + name.textContent = term; + content.textContent = value; + wrapper.append(name, content); + return wrapper; + } + + function sourceAnswer(attempt) { + return `${attempt.source} ${attempt.success ? "answered" : "missed"} [${attempt.lo}, ${attempt.hi}) at level ${attempt.level}`; + } + + function makeAttempts(scenario) { + const section = document.createElement("div"); + section.className = "attempts"; + + const head = document.createElement("div"); + head.className = "attempts__head"; + const title = document.createElement("strong"); + title.textContent = "Resolver attempt order"; + const note = document.createElement("span"); + note.textContent = "hollow = miss; solid = answer"; + head.append(title, note); + + const track = document.createElement("div"); + track.className = "attempts__track"; + scenario.attempts.forEach((attempt, index) => { + const marker = document.createElement("span"); + marker.className = `attempt attempt--${attempt.source}${attempt.success ? " is-success" : ""}`; + marker.title = `${index + 1}. ${sourceAnswer(attempt)}`; + marker.setAttribute("aria-label", marker.title); + track.append(marker); + }); + + section.append(head, track); + return section; + } + + function tooltipText(node) { + const span = node.hi - node.lo; + const roles = []; + if (node.path) roles.push("proof route"); + if (node.proof) roles.push("proof component"); + if (node.endpoint) roles.push(`${node.endpoint === "A" ? "earlier" : "later"} selected leaf (${node.endpoint})`); + if (node.queried) roles.push(`resolved #${node.queryOrder} by ${node.querySource}`); + if (node.overlap) roles.push("available from both; memory selected first"); + if (roles.length === 0) roles.push("not used by this proof"); + + const source = node.source === "tile" + ? "tile-backed" + : node.source === "frontier" + ? "resident frontier" + : "computed from child ranges"; + return { + title: `[${number.format(node.lo)}, ${number.format(node.hi)})`, + detail: `${number.format(span)} ${span === 1 ? "leaf" : "leaves"} · ${source} · ${roles.join(" · ")}` + }; + } + + function showTooltip(event, node) { + const text = tooltipText(node); + tooltip.replaceChildren(); + const title = document.createElement("b"); + title.textContent = text.title; + const detail = document.createElement("span"); + detail.textContent = text.detail; + tooltip.append(title, detail); + tooltip.hidden = false; + + const margin = 14; + const width = tooltip.offsetWidth; + const height = tooltip.offsetHeight; + tooltip.style.left = `${Math.min(event.clientX + 14, window.innerWidth - width - margin)}px`; + tooltip.style.top = `${Math.min(event.clientY + 14, window.innerHeight - height - margin)}px`; + } + + function hideTooltip() { + tooltip.hidden = true; + } + + function splitAt(lo, hi) { + return lo + 2 ** Math.floor(Math.log2(hi - lo - 1)); + } + + function buildNodes(scenario) { + scenario.covered = Math.floor(scenario.leaves / data.tileWidth) * data.tileWidth; + scenario.frontierStart = Math.min(scenario.covered, scenario.leaves - 1); + scenario.type = scenario.secondIndex === undefined ? "inclusion" : "consistency"; + scenario.mapLeaves = (scenario.secondIndex ?? scenario.leaves - 1) + 1; + const nodes = []; + const byRange = new Map(); + const key = (lo, hi) => `${lo}:${hi}`; + + scenario.attempts.forEach((attempt) => { + const width = 2 ** attempt.level; + attempt.lo = attempt.index * width; + attempt.hi = attempt.lo + width; + }); + + function add(lo, hi, depth, parent = -1) { + const width = hi - lo; + const complete = 2 ** Math.floor(Math.log2(width)) === width && lo % width === 0; + const inFrontier = complete && lo >= scenario.frontierStart; + const inTiles = complete && hi <= scenario.covered; + const node = { + lo, + hi, + depth, + parent, + source: inFrontier ? "frontier" : inTiles ? "tile" : "computed", + overlap: inFrontier && inTiles, + path: false, + proof: false, + endpoint: "", + queried: false, + queryOrder: 0, + querySource: "" + }; + const index = nodes.push(node) - 1; + byRange.set(key(lo, hi), node); + if (width > 1) { + const split = splitAt(lo, hi); + add(lo, split, depth + 1, index); + add(split, hi, depth + 1, index); + } + } + + const mark = (lo, hi, role) => { + byRange.get(key(lo, hi))[role] = true; + }; + + function markInclusion(lo, hi) { + mark(lo, hi, "path"); + if (hi - lo === 1) return; + const split = splitAt(lo, hi); + if (scenario.focus < split) { + mark(split, hi, "proof"); + markInclusion(lo, split); + } else { + mark(lo, split, "proof"); + markInclusion(split, hi); + } + } + + function markConsistency(firstSize, lo, hi, complete) { + mark(lo, hi, "path"); + if (firstSize === hi - lo) { + if (!complete) mark(lo, hi, "proof"); + return; + } + const split = splitAt(lo, hi); + const leftSize = split - lo; + if (firstSize <= leftSize) { + markConsistency(firstSize, lo, split, complete); + mark(split, hi, "proof"); + } else { + markConsistency(firstSize - leftSize, split, hi, false); + mark(lo, split, "proof"); + } + } + + add(0, scenario.mapLeaves, 0); + if (scenario.type === "inclusion") { + markInclusion(0, scenario.mapLeaves); + } else { + markConsistency(scenario.focus + 1, 0, scenario.mapLeaves, true); + byRange.get(key(scenario.focus, scenario.focus + 1)).endpoint = "A"; + byRange.get(key(scenario.secondIndex, scenario.secondIndex + 1)).endpoint = "B"; + } + + scenario.attempts.forEach((attempt, order) => { + const node = attempt.success && byRange.get(key(attempt.lo, attempt.hi)); + if (!node) return; + if (!node.queried) { + node.queryOrder = order + 1; + node.querySource = attempt.source; + } + node.queried = true; + }); + return nodes; + } + + function createRenderer(canvas, scroll, scenario) { + let hitTargets = []; + + function draw() { + const maxDepth = Math.max(...scenario.nodes.map((node) => node.depth)); + const minimumWidth = Math.max(660, Math.ceil(scenario.mapLeaves * 2.35 + 88)); + const availableWidth = scroll.clientWidth - 2; + const cssWidth = window.innerWidth > 980 + ? availableWidth + : Math.max(availableWidth, minimumWidth); + const cssHeight = Math.max(270, (maxDepth + 1) * 22 + 64); + const pixelRatio = Math.min(window.devicePixelRatio || 1, 2); + + canvas.width = Math.round(cssWidth * pixelRatio); + canvas.height = Math.round(cssHeight * pixelRatio); + canvas.style.width = `${cssWidth}px`; + canvas.style.height = `${cssHeight}px`; + const context = canvas.getContext("2d"); + context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); + context.clearRect(0, 0, cssWidth, cssHeight); + + const plot = { left: 42, right: 22, top: 25, bottom: 38 }; + const plotWidth = cssWidth - plot.left - plot.right; + const plotHeight = cssHeight - plot.top - plot.bottom; + const positions = scenario.nodes.map((node) => ({ + x: plot.left + (((node.lo + node.hi) / 2) / scenario.mapLeaves) * plotWidth, + y: plot.top + (node.depth / Math.max(maxDepth, 1)) * plotHeight + })); + + if (scenario.covered > 0 && scenario.covered < scenario.mapLeaves) { + const boundaryX = plot.left + (scenario.covered / scenario.mapLeaves) * plotWidth; + context.save(); + context.setLineDash([4, 4]); + context.strokeStyle = colors.boundary; + context.beginPath(); + context.moveTo(boundaryX, 8); + context.lineTo(boundaryX, cssHeight - 20); + context.stroke(); + context.restore(); + context.fillStyle = colors.muted; + context.font = "10px 'Cascadia Code', monospace"; + context.textAlign = boundaryX > cssWidth - 140 ? "right" : "left"; + context.fillText( + `flush line ${number.format(scenario.covered)}`, + boundaryX + (boundaryX > cssWidth - 140 ? -6 : 6), + 14 + ); + } + + if (state.edges) { + context.lineWidth = 0.7; + context.strokeStyle = colors.edge; + context.beginPath(); + scenario.nodes.forEach((node, index) => { + if (node.parent < 0) return; + const parent = positions[node.parent]; + const current = positions[index]; + context.moveTo(parent.x, parent.y); + context.lineTo(current.x, current.y); + }); + context.stroke(); + } + + context.lineWidth = 1.7; + context.strokeStyle = colors.route; + context.beginPath(); + scenario.nodes.forEach((node, index) => { + if (!node.path || node.parent < 0 || !scenario.nodes[node.parent].path) return; + const parent = positions[node.parent]; + const current = positions[index]; + context.moveTo(parent.x, parent.y); + context.lineTo(current.x, current.y); + }); + context.stroke(); + + const baseSize = scenario.mapLeaves > 400 ? 3 : 3.6; + hitTargets = []; + scenario.nodes.forEach((node, index) => { + const position = positions[index]; + const roleSize = baseSize + 4; + + if (node.proof || node.path) { + context.fillStyle = colors.route; + context.fillRect( + position.x - roleSize / 2, + position.y - roleSize / 2, + roleSize, + roleSize + ); + } + + if (state.calls && node.queried) { + const querySize = roleSize + 4; + context.strokeStyle = colors.query; + context.lineWidth = 2; + context.strokeRect( + position.x - querySize / 2, + position.y - querySize / 2, + querySize, + querySize + ); + } + + if (node.overlap) { + const overlapSize = baseSize + 2.5; + context.fillStyle = colors.tile; + context.fillRect( + position.x - overlapSize / 2, + position.y - overlapSize / 2, + overlapSize, + overlapSize + ); + } + + context.fillStyle = colors[node.source]; + context.fillRect( + position.x - baseSize / 2, + position.y - baseSize / 2, + baseSize, + baseSize + ); + + if (node.endpoint) { + const markerSize = baseSize + 8; + context.save(); + context.strokeStyle = colors.endpoint; + context.fillStyle = colors.endpoint; + context.lineWidth = 2; + context.beginPath(); + if (node.endpoint === "A") { + context.arc(position.x, position.y, markerSize / 2, 0, Math.PI * 2); + } else { + context.moveTo(position.x, position.y - markerSize / 2); + context.lineTo(position.x + markerSize / 2, position.y); + context.lineTo(position.x, position.y + markerSize / 2); + context.lineTo(position.x - markerSize / 2, position.y); + context.closePath(); + } + context.stroke(); + context.font = "700 10px 'Cascadia Code', monospace"; + context.textAlign = "center"; + context.fillText( + `${node.endpoint} ${number.format(node.lo)}`, + position.x, + position.y - markerSize / 2 - 5 + ); + context.restore(); + } + hitTargets.push({ ...position, node }); + }); + + context.fillStyle = colors.muted; + context.font = "10px 'Cascadia Code', monospace"; + context.textAlign = "left"; + context.fillText("leaf 0", plot.left, cssHeight - 12); + context.textAlign = "right"; + context.fillText(`leaf ${number.format(scenario.mapLeaves - 1)}`, cssWidth - plot.right, cssHeight - 12); + } + + canvas.addEventListener("pointermove", (event) => { + const bounds = canvas.getBoundingClientRect(); + const x = event.clientX - bounds.left; + const y = event.clientY - bounds.top; + let nearest = null; + let nearestDistance = 9 * 9; + for (let index = hitTargets.length - 1; index >= 0; index--) { + const target = hitTargets[index]; + const distance = (target.x - x) ** 2 + (target.y - y) ** 2; + if (distance < nearestDistance) { + nearest = target; + nearestDistance = distance; + } + } + if (nearest) { + showTooltip(event, nearest.node); + } else { + hideTooltip(); + } + }); + canvas.addEventListener("pointerleave", hideTooltip); + + const observer = new ResizeObserver(draw); + observer.observe(scroll); + draw(); + return { draw, observer }; + } + + function makeScene(scenario, index) { + const article = document.createElement("article"); + article.className = "scene"; + article.dataset.type = scenario.type; + article.id = scenario.id; + + const inner = document.createElement("div"); + inner.className = "scene__inner"; + + const copy = document.createElement("div"); + copy.className = "scene__copy"; + const intro = document.createElement("div"); + const type = document.createElement("p"); + type.className = "scene__type"; + type.innerHTML = `${String(index + 1).padStart(2, "0")} / ${scenario.type} proof`; + const title = document.createElement("h2"); + title.textContent = scenario.title; + const description = document.createElement("p"); + description.className = "scene__description"; + description.textContent = scenario.description; + intro.append(type, title, description); + + const details = document.createElement("div"); + const facts = document.createElement("dl"); + facts.className = "scene__facts"; + if (scenario.type === "inclusion") { + facts.append( + fact("Leaves", number.format(scenario.leaves)), + fact("Tiled prefix", number.format(scenario.covered)), + fact("Frontier begins", number.format(scenario.frontierStart)), + fact("Target leaf", number.format(scenario.focus)) + ); + } else { + facts.append( + fact("Backing leaves", number.format(scenario.leaves)), + fact("Tiled prefix", number.format(scenario.covered)), + fact("Earlier leaf A", `index ${number.format(scenario.focus)} · tree ends at A`), + fact("Later leaf B", `index ${number.format(scenario.secondIndex)} · tree ends at B`) + ); + } + const takeaway = document.createElement("p"); + takeaway.className = "scene__takeaway"; + takeaway.textContent = scenario.takeaway; + details.append(facts, takeaway); + copy.append(intro, details); + + const visual = document.createElement("div"); + visual.className = "scene__visual"; + const visualHead = document.createElement("div"); + visualHead.className = "visual-head"; + const mapTitle = document.createElement("span"); + mapTitle.innerHTML = scenario.type === "consistency" + ? "Leaf-to-leaf consistency / A and B anchor the two tree states" + : "Node map / root to leaves"; + const mapMeta = document.createElement("span"); + mapMeta.textContent = `${number.format(scenario.nodes.length)} nodes · ${number.format(scenario.attempts.length)} resolver attempts`; + visualHead.append(mapTitle, mapMeta); + + const scroll = document.createElement("div"); + scroll.className = "canvas-scroll"; + const canvas = document.createElement("canvas"); + canvas.className = "tree-canvas"; + canvas.setAttribute("role", "img"); + canvas.setAttribute( + "aria-label", + `${scenario.title}: ${number.format(scenario.mapLeaves)} proof leaves with tile, frontier, and proof-route nodes` + ); + scroll.append(canvas); + const attempts = makeAttempts(scenario); + visual.append(visualHead, scroll, attempts); + + inner.append(copy, visual); + article.append(inner); + scenarioRoot.append(article); + + const renderer = createRenderer(canvas, scroll, scenario); + renderers.push({ ...renderer, article, attempts }); + } + + data.scenarios.forEach((scenario, index) => { + scenario.nodes = buildNodes(scenario); + makeScene(scenario, index); + }); + + const filterButtons = document.querySelectorAll("[data-filter]"); + filterButtons.forEach((button) => { + button.addEventListener("click", () => { + const filter = button.dataset.filter; + filterButtons.forEach((candidate) => { + const active = candidate === button; + candidate.classList.toggle("is-active", active); + candidate.setAttribute("aria-selected", String(active)); + }); + renderers.forEach((renderer) => { + renderer.article.hidden = filter !== "all" && renderer.article.dataset.type !== filter; + }); + }); + }); + + document.querySelector("#toggle-edges").addEventListener("change", (event) => { + state.edges = event.target.checked; + renderers.forEach((renderer) => renderer.draw()); + }); + + document.querySelector("#toggle-calls").addEventListener("change", (event) => { + state.calls = event.target.checked; + renderers.forEach((renderer) => { + renderer.attempts.hidden = !state.calls; + renderer.draw(); + }); + }); +})().catch((error) => { + console.error(error); + const message = document.createElement("p"); + message.className = "load-status load-status--error"; + message.setAttribute("role", "alert"); + message.textContent = `Could not load proof visualization: ${error.message}`; + scenarioRoot.removeAttribute("aria-busy"); + scenarioRoot.replaceChildren(message); +}); \ No newline at end of file diff --git a/tools/proof-viz/index.html b/tools/proof-viz/index.html new file mode 100644 index 0000000..79cb06d --- /dev/null +++ b/tools/proof-viz/index.html @@ -0,0 +1,101 @@ + + + + + + + + Merkle proof atlas + + + + + + +
+
+

merklecpp / instrumented proofs

+
+

Tile & frontier proof atlas

+

+ A node-level view of where proof hashes come from when durable tiles + meet the resident Merkle frontier. +

+
+

+ 11 scenes + 256 tile width + Live calls evidence +

+
+
+ +
+
+
+
+ + + +
+
+ + +
+
+
+ +
+
    +
  • Tile durable full-tile data
  • +
  • Frontier resident in-memory data
  • +
  • Computed assembled across ranges
  • +
  • Proof route ancestry and proof components
  • +
  • Resolved an actual source answer
  • +
  • A / B leaves selected consistency endpoints
  • +
+
+ +
+

Loading proof data...

+
+ +
+
+ +

Observed, not simulated

+
+
+

+ Each scene constructs the same production-shaped fixture used by + tiles_proofs: full tiles cover the compacted prefix and + an in-memory tree owns the frontier. Thin wrappers record every + subtree_root attempt made by the real + ProofEngine. +

+

+ Inclusion scenes are checked byte-for-byte against + Tree::path(). The consistency scene is verified with + ProofEngine::verify_consistency() before it reaches this + page. +

+
+
+
+ + + + + + \ No newline at end of file diff --git a/tools/proof-viz/make_viz.sh b/tools/proof-viz/make_viz.sh new file mode 100755 index 0000000..a7bba5a --- /dev/null +++ b/tools/proof-viz/make_viz.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +repo_root=$(cd "$script_dir/../.." && pwd) +output_dir=${1:-$script_dir} +build_dir=${BUILD_DIR:-$repo_root/build/proof-viz} +cxx=${CXX:-c++} + +mkdir -p "$build_dir" "$output_dir" +output_dir=$(cd "$output_dir" && pwd) +rm -f "$output_dir/data.js" +"$cxx" -std=c++20 -O2 \ + -I"$repo_root" \ + -I"$repo_root/test" \ + "$script_dir/proof_viz.cpp" \ + -o "$build_dir/proof_viz" +"$build_dir/proof_viz" "$script_dir/scenarios" "$output_dir/data.json" + +node --check "$script_dir/app.js" +node - "$output_dir/data.json" <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const data = JSON.parse(fs.readFileSync(path.resolve(process.argv[2]), "utf8")); +const validAttempt = (attempt) => { + if (!["frontier", "tile"].includes(attempt.source)) return false; + if (!Number.isSafeInteger(attempt.level) || attempt.level < 0 || attempt.level > 52) return false; + if (!Number.isSafeInteger(attempt.index) || attempt.index < 0) return false; + const width = 2 ** attempt.level; + const lo = attempt.index * width; + if (!Number.isSafeInteger(width) || width < 1) return false; + if (!Number.isSafeInteger(lo) || lo < 0 || lo > Number.MAX_SAFE_INTEGER - width) return false; + return typeof attempt.success === "boolean"; +}; +const validScenario = (scenario) => + ["id", "title", "description", "takeaway"].every( + (field) => typeof scenario[field] === "string" + ) && + Number.isSafeInteger(scenario.leaves) && scenario.leaves > 0 && + Number.isSafeInteger(scenario.focus) && + scenario.focus >= 0 && scenario.focus < scenario.leaves && + (scenario.secondIndex === undefined || + (Number.isSafeInteger(scenario.secondIndex) && + scenario.secondIndex > scenario.focus && + scenario.secondIndex < scenario.leaves)) && + Array.isArray(scenario.attempts) && + scenario.attempts.length > 0 && + scenario.attempts.every(validAttempt); + +if ( + !data || + data.schemaVersion !== 1 || + !Number.isSafeInteger(data.tileWidth) || data.tileWidth < 1 || + !Array.isArray(data.scenarios) || data.scenarios.length === 0 || + !data.scenarios.every(validScenario) +) { + throw new Error("invalid proof visualization data"); +} + +console.log(`validated ${data.scenarios.length} proof visualization scenarios`); +NODE + +if [[ "$output_dir" != "$script_dir" ]]; then + cp "$script_dir/index.html" "$script_dir/styles.css" "$script_dir/app.js" \ + "$output_dir/" +fi \ No newline at end of file diff --git a/tools/proof-viz/proof_viz.cpp b/tools/proof-viz/proof_viz.cpp new file mode 100644 index 0000000..dca94c3 --- /dev/null +++ b/tools/proof-viz/proof_viz.cpp @@ -0,0 +1,393 @@ +#include "tiles_test_util.h" +#include "util.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +using merkle::Hash; +using merkle::tiles::CombinedHashSource; +using merkle::tiles::HashSource; +using merkle::tiles::MemoryHashSource; +using merkle::tiles::ProofEngine; +using merkle::tiles::TILE_WIDTH; +using merkle::tiles::TileHashSource; +using merkle::tiles::TileStore; +using merkle::tiles::TileWriter; + +struct Attempt +{ + std::string source; + uint8_t level; + uint64_t index; + bool success; +}; + +using Attempts = std::vector; + +class TracingSource : public HashSource +{ +public: + TracingSource( + const HashSource& source, std::string name, Attempts& attempts) : + source(source), name(std::move(name)), attempts(attempts) + {} + + bool subtree_root(uint8_t level, uint64_t index, Hash& out) const override + { + const bool success = source.subtree_root(level, index, out); + attempts.push_back(Attempt{name, level, index, success}); + return success; + } + +private: + const HashSource& source; + std::string name; + Attempts& attempts; +}; + +struct Scenario +{ + std::string id; + std::string title; + std::string description; + std::string takeaway; + uint64_t order; + uint64_t leaves; + uint64_t focus; + std::optional second_index; + Attempts attempts; +}; + +static Scenario read_scenario(const fs::path& path) +{ + static constexpr std::array keys = { + "name", + "proof", + "title", + "description", + "takeaway", + "order", + "leaves", + "focus", + "second"}; + std::ifstream stream(path); + if (!stream) + { + throw std::runtime_error("could not open " + path.string()); + } + const auto error = [&](size_t line, const std::string& message) { + return std::runtime_error( + path.string() + ":" + std::to_string(line) + ": " + message); + }; + + std::array values; + for (size_t index = 0; index < keys.size(); index++) + { + std::string line; + if (!std::getline(stream, line)) + { + throw error(index + 1, "expected key " + std::string(keys[index])); + } + if (!line.empty() && line.back() == '\r') + { + line.pop_back(); + } + const std::string prefix = std::string(keys[index]) + ": "; + if (line.compare(0, prefix.size(), prefix) != 0) + { + throw error(index + 1, "expected key " + std::string(keys[index])); + } + values[index] = line.substr(prefix.size()); + if ( + values[index].empty() || values[index].front() == ' ' || + values[index].back() == ' ' || + std::any_of( + values[index].begin(), + values[index].end(), + [](unsigned char character) { return character < 0x20; })) + { + throw error(index + 1, "invalid value for " + std::string(keys[index])); + } + } + std::string extra; + if (std::getline(stream, extra)) + { + throw error(keys.size() + 1, "unexpected extra line"); + } + + const auto number = [&](size_t index) { + uint64_t value = 0; + const auto [end, parse_error] = std::from_chars( + values[index].data(), values[index].data() + values[index].size(), value); + if ( + parse_error != std::errc{} || + end != values[index].data() + values[index].size()) + { + throw error( + index + 1, "invalid unsigned integer for " + std::string(keys[index])); + } + return value; + }; + const std::string& proof = values[1]; + if (proof != "inclusion" && proof != "consistency") + { + throw error(2, "proof must be inclusion or consistency"); + } + Scenario scenario{ + std::move(values[0]), + std::move(values[2]), + std::move(values[3]), + std::move(values[4]), + number(5), + number(6), + number(7), + std::nullopt, + {}}; + const std::string& second = values[8]; + + if (proof == "inclusion") + { + if (second != "none") + { + throw error(9, "inclusion proof requires second: none"); + } + } + else + { + if (second == "none") + { + throw error(9, "consistency proof requires a second index"); + } + scenario.second_index = number(8); + } + + if (path.stem() != scenario.id) + { + throw error(1, "name must match the file name"); + } + if (scenario.order == 0) + { + throw error(6, "order must be greater than zero"); + } + if (scenario.leaves == 0) + { + throw error(7, "leaves must be greater than zero"); + } + if (scenario.focus >= scenario.leaves) + { + throw error(8, "focus must be less than leaves"); + } + if ( + scenario.second_index && + (scenario.focus >= *scenario.second_index || + *scenario.second_index >= scenario.leaves)) + { + throw error(9, "invalid consistency indices"); + } + return scenario; +} + +static std::vector read_scenarios(const fs::path& directory) +{ + if (!fs::is_directory(directory)) + { + throw std::runtime_error("not a scenario directory: " + directory.string()); + } + + std::vector scenarios; + for (const fs::directory_entry& entry : fs::directory_iterator(directory)) + { + if (!entry.is_regular_file() || entry.path().extension() != ".scenario") + { + continue; + } + scenarios.push_back(read_scenario(entry.path())); + } + + if (scenarios.empty()) + { + throw std::runtime_error( + "scenario directory is empty: " + directory.string()); + } + std::sort( + scenarios.begin(), + scenarios.end(), + [](const Scenario& left, const Scenario& right) { + return left.order < right.order; + }); + for (size_t index = 0; index < scenarios.size(); index++) + { + if (scenarios[index].order != index + 1) + { + throw std::runtime_error( + "scenario order values must be contiguous from 1"); + } + } + return scenarios; +} + +static void run_scenario( + const fs::path& directory, + Scenario& scenario, + const std::vector& hashes) +{ + TileStore store(directory); + TileWriter writer(store); + const auto leaf_at = [&](uint64_t index) -> const Hash& { + return hashes.at(static_cast(index)); + }; + writer.write_up_to(scenario.leaves, leaf_at); + + merkle::Tree oracle; + merkle::Tree frontier; + for (uint64_t index = 0; index < scenario.leaves; index++) + { + oracle.insert(hashes.at(static_cast(index))); + frontier.insert(hashes.at(static_cast(index))); + } + const uint64_t covered = (scenario.leaves / TILE_WIDTH) * TILE_WIDTH; + uint64_t frontier_start = covered; + if (frontier_start >= scenario.leaves) + { + frontier_start = scenario.leaves - 1; + } + if (frontier_start > 0) + { + frontier.flush_to(static_cast(frontier_start)); + } + + const MemoryHashSource memory(frontier); + const TileHashSource tiles(store, covered); + const TracingSource traced_memory(memory, "frontier", scenario.attempts); + const TracingSource traced_tiles(tiles, "tile", scenario.attempts); + const CombinedHashSource combined(traced_memory, traced_tiles); + const ProofEngine engine(combined); + const CombinedHashSource control_combined(memory, tiles); + const ProofEngine control_engine(control_combined); + + if (!scenario.second_index) + { + const Hash root = oracle.root(); + const auto proof = engine.inclusion_proof(scenario.focus, scenario.leaves); + const auto control_proof = + control_engine.inclusion_proof(scenario.focus, scenario.leaves); + if ( + !proof->verify(root) || *proof != *oracle.path(scenario.focus) || + *proof != *control_proof) + { + throw std::runtime_error("inclusion proof mismatch for " + scenario.id); + } + } + else + { + const uint64_t second_index = *scenario.second_index; + const Hash first_root = *oracle.past_root(scenario.focus); + const Hash second_root = *oracle.past_root(second_index); + const auto proof = + engine.consistency_proof_from_indices(scenario.focus, second_index); + const auto control_proof = control_engine.consistency_proof_from_indices( + scenario.focus, second_index); + if ( + proof != control_proof || + !ProofEngine::verify_consistency( + scenario.focus + 1, second_index + 1, first_root, second_root, proof)) + { + throw std::runtime_error("consistency proof mismatch for " + scenario.id); + } + } +} + +static void write_data( + const fs::path& output, const std::vector& scenarios) +{ + std::ofstream stream(output); + if (!stream) + { + throw std::runtime_error("could not open " + output.string()); + } + + stream << "{\"schemaVersion\":1,\"tileWidth\":" << TILE_WIDTH + << ",\"scenarios\":["; + for (size_t scenario_index = 0; scenario_index < scenarios.size(); + scenario_index++) + { + const Scenario& scenario = scenarios[scenario_index]; + stream << (scenario_index == 0 ? "{" : ",{"); + stream << "\"id\":" << std::quoted(scenario.id); + stream << ",\"title\":" << std::quoted(scenario.title); + stream << ",\"description\":" << std::quoted(scenario.description); + stream << ",\"takeaway\":" << std::quoted(scenario.takeaway); + stream << ",\"leaves\":" << scenario.leaves; + stream << ",\"focus\":" << scenario.focus; + if (scenario.second_index) + { + stream << ",\"secondIndex\":" << *scenario.second_index; + } + stream << ",\"attempts\":["; + for (size_t attempt_index = 0; attempt_index < scenario.attempts.size(); + attempt_index++) + { + const Attempt& attempt = scenario.attempts[attempt_index]; + stream << (attempt_index == 0 ? "{" : ",{"); + stream << "\"source\":" << std::quoted(attempt.source) + << ",\"level\":" << static_cast(attempt.level) + << ",\"index\":" << attempt.index + << ",\"success\":" << (attempt.success ? "true" : "false") << "}"; + } + stream << "]}"; + } + stream << "]}\n"; +} + +int main(int argc, char** argv) +{ + try + { + if (argc != 3) + { + throw std::runtime_error( + "usage: proof_viz SCENARIO_DIRECTORY OUTPUT_JSON"); + } + const fs::path scenario_directory = argv[1]; + const fs::path output = argv[2]; + std::vector scenarios = read_scenarios(scenario_directory); + size_t max_leaves = 0; + for (const Scenario& scenario : scenarios) + { + max_leaves = std::max(max_leaves, static_cast(scenario.leaves)); + } + const TemporaryDirectory temporary_directory("merklecpp_proof_viz"); + const auto hashes = make_hashes(max_leaves); + + for (Scenario& scenario : scenarios) + { + run_scenario(temporary_directory.path() / scenario.id, scenario, hashes); + std::cout << scenario.id << ": " << scenario.attempts.size() + << " source attempts\n"; + } + write_data(output, scenarios); + std::cout << "wrote " << output << '\n'; + } + catch (const std::exception& error) + { + std::cerr << "proof visualization failed: " << error.what() << '\n'; + return 1; + } + return 0; +} \ No newline at end of file diff --git a/tools/proof-viz/scenarios/boundary-overlap.scenario b/tools/proof-viz/scenarios/boundary-overlap.scenario new file mode 100644 index 0000000..7325eb9 --- /dev/null +++ b/tools/proof-viz/scenarios/boundary-overlap.scenario @@ -0,0 +1,9 @@ +name: boundary-overlap +proof: inclusion +title: The exact two-tile boundary +description: At 512 leaves, both tiles are complete. Compaction deliberately retains the final leaf in memory because merklecpp never flushes the entire tree. +takeaway: The blue target pixel has a green overlap mark: both sources can answer it, but CombinedHashSource chooses memory first; its siblings come from tiles. +order: 5 +leaves: 512 +focus: 511 +second: none diff --git a/tools/proof-viz/scenarios/consistency-arbitrary-crossing.scenario b/tools/proof-viz/scenarios/consistency-arbitrary-crossing.scenario new file mode 100644 index 0000000..ebee77b --- /dev/null +++ b/tools/proof-viz/scenarios/consistency-arbitrary-crossing.scenario @@ -0,0 +1,9 @@ +name: consistency-arbitrary-crossing +proof: consistency +title: An arbitrary leaf pair crosses the boundary +description: Leaves A=91 and B=287 end the 92- and 288-leaf tree states in a 300-leaf backing tree. +takeaway: The tree ending at A sits wholly in the tiled past, while the tree ending at B requires both the durable tile and resident frontier. +order: 10 +leaves: 300 +focus: 91 +second: 287 diff --git a/tools/proof-viz/scenarios/consistency-boundary.scenario b/tools/proof-viz/scenarios/consistency-boundary.scenario new file mode 100644 index 0000000..59a40aa --- /dev/null +++ b/tools/proof-viz/scenarios/consistency-boundary.scenario @@ -0,0 +1,9 @@ +name: consistency-boundary +proof: consistency +title: Consistency across the flush line +description: Leaf indices 255 and 299 identify the 256- and 300-leaf checkpoints on opposite sides of the flush line. +takeaway: There is no target leaf. Red marks the SUBPROOF recursion and its proof components, combining the old green root with blue hashes from the new frontier. +order: 7 +leaves: 300 +focus: 255 +second: 299 diff --git a/tools/proof-viz/scenarios/consistency-frontier-only.scenario b/tools/proof-viz/scenarios/consistency-frontier-only.scenario new file mode 100644 index 0000000..76d5e7e --- /dev/null +++ b/tools/proof-viz/scenarios/consistency-frontier-only.scenario @@ -0,0 +1,9 @@ +name: consistency-frontier-only +proof: consistency +title: Two leaves before tiling +description: Leaves A=47 and B=149 end the 48- and 150-leaf tree states inside a 192-leaf backing tree that has not completed its first tile. +takeaway: Every consistency component is answered by the resident frontier, even though neither selected leaf is the backing tree's current end. +order: 8 +leaves: 192 +focus: 47 +second: 149 diff --git a/tools/proof-viz/scenarios/consistency-frontier-pair.scenario b/tools/proof-viz/scenarios/consistency-frontier-pair.scenario new file mode 100644 index 0000000..b8777bc --- /dev/null +++ b/tools/proof-viz/scenarios/consistency-frontier-pair.scenario @@ -0,0 +1,9 @@ +name: consistency-frontier-pair +proof: consistency +title: Two leaves inside one frontier +description: Leaves A=269 and B=493 end two tree states beyond the first tile in a 511-leaf backing tree. +takeaway: Both roots include tiled history, but their changing suffixes are assembled from different portions of the same blue frontier. +order: 11 +leaves: 511 +focus: 269 +second: 493 diff --git a/tools/proof-viz/scenarios/consistency-tiled-history.scenario b/tools/proof-viz/scenarios/consistency-tiled-history.scenario new file mode 100644 index 0000000..043dc91 --- /dev/null +++ b/tools/proof-viz/scenarios/consistency-tiled-history.scenario @@ -0,0 +1,9 @@ +name: consistency-tiled-history +proof: consistency +title: Two leaves in tiled history +description: Leaves A=127 and B=399 end historical tree states inside a 513-leaf backing tree with two durable tiles. +takeaway: The current frontier begins after both checkpoints, so the complete consistency proof is recovered from green tile storage. +order: 9 +leaves: 513 +focus: 127 +second: 399 diff --git a/tools/proof-viz/scenarios/frontier-only.scenario b/tools/proof-viz/scenarios/frontier-only.scenario new file mode 100644 index 0000000..b974190 --- /dev/null +++ b/tools/proof-viz/scenarios/frontier-only.scenario @@ -0,0 +1,9 @@ +name: frontier-only +proof: inclusion +title: Before the first tile +description: With 192 leaves, no full 256-entry tile exists. Every subtree request is answered by the resident tree. +takeaway: The red route never changes source: the target, proof siblings, and root reduction all stay in the blue frontier. +order: 1 +leaves: 192 +focus: 37 +second: none diff --git a/tools/proof-viz/scenarios/frontier-to-tile.scenario b/tools/proof-viz/scenarios/frontier-to-tile.scenario new file mode 100644 index 0000000..545fe2c --- /dev/null +++ b/tools/proof-viz/scenarios/frontier-to-tile.scenario @@ -0,0 +1,9 @@ +name: frontier-to-tile +proof: inclusion +title: A frontier proof reaches backward +description: Leaf 271 sits in the 44-leaf resident frontier beyond one completed tile. +takeaway: Most local siblings are blue, but the final left sibling is the entire 256-leaf tiled prefix, resolved in one green lookup. +order: 3 +leaves: 300 +focus: 271 +second: none diff --git a/tools/proof-viz/scenarios/near-boundary.scenario b/tools/proof-viz/scenarios/near-boundary.scenario new file mode 100644 index 0000000..5e6c226 --- /dev/null +++ b/tools/proof-viz/scenarios/near-boundary.scenario @@ -0,0 +1,9 @@ +name: near-boundary +proof: inclusion +title: One leaf short of the next tile +description: At 511 leaves, the first tile is durable and the next 255 leaves still form the frontier. +takeaway: The long blue frontier is not itself a perfect subtree. The engine assembles it from smaller resident ranges before joining the green past. +order: 4 +leaves: 511 +focus: 510 +second: none diff --git a/tools/proof-viz/scenarios/next-frontier.scenario b/tools/proof-viz/scenarios/next-frontier.scenario new file mode 100644 index 0000000..79495c5 --- /dev/null +++ b/tools/proof-viz/scenarios/next-frontier.scenario @@ -0,0 +1,9 @@ +name: next-frontier +proof: inclusion +title: A new frontier after two tiles +description: Leaf 512 is the first resident leaf after a 512-leaf tiled prefix. +takeaway: The proof begins with one blue leaf and crosses immediately to a single green 512-leaf sibling that represents both completed tiles. +order: 6 +leaves: 513 +focus: 512 +second: none diff --git a/tools/proof-viz/scenarios/tile-to-frontier.scenario b/tools/proof-viz/scenarios/tile-to-frontier.scenario new file mode 100644 index 0000000..5d9592f --- /dev/null +++ b/tools/proof-viz/scenarios/tile-to-frontier.scenario @@ -0,0 +1,9 @@ +name: tile-to-frontier +proof: inclusion +title: A proof leaves the tiled past +description: Leaf 42 is already represented by the first full tile, while leaves 256-299 remain resident in memory. +takeaway: The proof starts in green tile storage, then needs blue frontier subtrees to complete the current 300-leaf root. +order: 2 +leaves: 300 +focus: 42 +second: none diff --git a/tools/proof-viz/styles.css b/tools/proof-viz/styles.css new file mode 100644 index 0000000..bb0ab68 --- /dev/null +++ b/tools/proof-viz/styles.css @@ -0,0 +1,524 @@ +:root { + color-scheme: light; + --page-gutter: clamp(12px, 1.5vw, 40px); + --paper: #fff; + --paper-strong: #fff; + --surface: #f3f4f5; + --ink: #222832; + --muted: #48566b; + --line: #d1d5da; + --primary: #0a7d91; + --inline-code: #085d6c; + --tile: #00843f; + --frontier: #276be9; + --computed: #9ca4af; + --route: #d72d47; + --query: #f66a0a; + --endpoint: var(--ink); + --edge: rgba(72, 86, 107, 0.2); + --boundary: rgba(34, 40, 50, 0.48); + --tooltip: rgba(34, 40, 50, 0.96); + --shadow: rgba(0, 0, 0, 0.1); +} + +:root[data-theme="dark"] { + color-scheme: dark; + --paper: #14181e; + --paper-strong: #222832; + --surface: #29313d; + --ink: #ced6dd; + --muted: #9ca4af; + --line: #48566b; + --primary: #3fb1c5; + --inline-code: #f3c7ee; + --tile: #5fb488; + --frontier: #79a3f2; + --computed: #677384; + --route: #e78894; + --query: #ff9245; + --edge: rgba(156, 164, 175, 0.22); + --boundary: rgba(206, 214, 221, 0.52); + --tooltip: rgba(20, 24, 30, 0.97); + --shadow: rgba(0, 0, 0, 0.2); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + color: var(--ink); + background-color: var(--paper); + font-family: system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", + "Segoe UI Symbol", "Noto Color Emoji"; + font-size: 16px; + line-height: 1.55; +} + +button, +input { + font: inherit; +} + +button:focus-visible, +input:focus-visible { + outline: 3px solid color-mix(in srgb, var(--primary) 55%, transparent); + outline-offset: 2px; +} + +code { + padding: 0.1em 0.25em; + border: 1px solid var(--line); + border-radius: 2px; + color: var(--inline-code); + background: var(--surface); + font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, + Consolas, "DejaVu Sans Mono", monospace; + font-size: 0.875em; +} + +.masthead { + border-bottom: 1px solid var(--line); +} + +.masthead__inner, +.control-band__inner, +.scene__inner, +.method { + padding-inline: var(--page-gutter); +} + +.masthead__inner { + padding-block: 42px; +} + +.identity { + margin: 0 0 24px; +} + +.identity, +.section-label, +.scene__type { + color: var(--muted); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +.masthead__copy { + max-width: 1000px; +} + +h1, +h2 { + font-weight: 600; +} + +h1 { + max-width: 940px; + margin: 0; + font-size: clamp(40px, 5vw, 72px); + line-height: 1; +} + +.masthead__copy>p { + max-width: 530px; + margin: 20px 0 0; + color: var(--muted); + font-size: 17px; +} + +.masthead__stats { + display: flex; + flex-wrap: wrap; + gap: 32px; + margin: 32px 0 0; +} + +.scene__facts dt { + color: var(--muted); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} + +.masthead__stats span { + color: var(--muted); + font-size: 13px; +} + +.masthead__stats b { + color: var(--ink); +} + +.control-band { + position: sticky; + top: 0; + z-index: 20; + border-bottom: 1px solid var(--line); + background: var(--paper); +} + +.control-band__inner { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 24px; + padding-block: 14px; +} + +.segmented { + display: inline-grid; + grid-template-columns: repeat(3, auto); + border: 1px solid var(--line); + border-radius: 4px; + overflow: hidden; +} + +.segmented button { + min-height: 38px; + padding: 7px 16px; + border: 0; + border-right: 1px solid var(--line); + color: var(--ink); + background: transparent; + cursor: pointer; +} + +.segmented button:last-child { + border-right: 0; +} + +.segmented button.is-active { + color: #fff; + background: var(--primary); +} + +.toggles { + display: flex; + gap: 22px; +} + +.toggle { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--muted); + font-size: 13px; + cursor: pointer; +} + +.toggle input { + width: 15px; + height: 15px; + margin: 0; + accent-color: var(--primary); +} + +.legend-band { + padding: 28px var(--page-gutter); + border-bottom: 1px solid var(--line); + background: var(--surface); +} + +.method h2 { + margin: 5px 0 0; + font-size: 31px; + line-height: 1.1; +} + +.legend { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 14px; + margin: 0; + padding: 0; + list-style: none; +} + +.legend li { + display: grid; + grid-template-columns: 14px 1fr; + gap: 1px 9px; + color: var(--muted); + font-size: 11px; + line-height: 1.35; +} + +.legend b { + color: var(--ink); + font-size: 12px; +} + +.swatch { + grid-row: span 2; + width: 12px; + height: 12px; + margin-top: 2px; + border: 1px solid transparent; +} + +.swatch--tile { + background: var(--tile); +} + +.swatch--frontier { + background: var(--frontier); +} + +.swatch--computed { + background: var(--computed); +} + +.swatch--route { + background: var(--route); +} + +.swatch--query { + border: 3px solid var(--query); + background: var(--paper-strong); +} + +.swatch--endpoint { + border: 2px solid var(--ink); + border-radius: 50%; + background: var(--paper-strong); +} + +.scene { + border-bottom: 1px solid var(--line); + background: var(--paper); +} + +.load-status { + margin: 0; + padding: 64px var(--page-gutter); + color: var(--muted); +} + +.load-status--error { + color: var(--route); +} + +.scene__inner { + display: grid; + grid-template-columns: minmax(250px, 320px) minmax(0, 1fr); + gap: 32px; + padding-block: 48px; +} + +.scene__index { + color: var(--route); +} + +.scene h2 { + margin: 11px 0 18px; + font-size: 38px; + line-height: 1.05; +} + +.scene__description { + margin: 0; + color: var(--muted); + font-size: 14px; +} + +.scene__facts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + gap: 14px 18px; + margin: 24px 0; + padding-block: 14px; + border-block: 1px solid var(--line); +} + +.scene__facts dd { + margin: 3px 0 0; + font-size: 14px; + font-weight: 700; +} + +.scene__takeaway { + margin: 0; + padding-left: 15px; + border-left: 3px solid var(--query); + color: var(--ink); + font-size: 17px; + line-height: 1.4; +} + +.scene__visual { + min-width: 0; +} + +.visual-head { + display: flex; + justify-content: space-between; + gap: 18px; + margin-bottom: 10px; + color: var(--muted); + font-size: 11px; + text-transform: uppercase; +} + +.visual-head strong { + color: var(--ink); +} + +.canvas-scroll { + overflow-x: auto; + border: 1px solid var(--line); + background-color: var(--paper-strong); +} + +.tree-canvas { + display: block; + cursor: crosshair; +} + +.attempts { + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid var(--line); +} + +.attempts__head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + margin-bottom: 9px; + color: var(--muted); + font-size: 11px; +} + +.attempts__head strong { + color: var(--ink); + text-transform: uppercase; +} + +.attempts__track { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.attempt { + width: 13px; + height: 13px; + border: 2px solid currentColor; +} + +.attempt--frontier { + color: var(--frontier); +} + +.attempt--tile { + color: var(--tile); +} + +.attempt.is-success { + background: currentColor; +} + +.node-tooltip { + position: fixed; + z-index: 50; + max-width: 280px; + padding: 10px 12px; + border: 1px solid var(--ink); + border-radius: 4px; + color: var(--paper-strong); + background: var(--tooltip); + box-shadow: 0 8px 24px var(--shadow); + pointer-events: none; + font-size: 11px; + line-height: 1.45; +} + +.node-tooltip b { + display: block; + margin-bottom: 3px; + color: #fff; + font-size: 12px; +} + +.method { + display: grid; + grid-template-columns: minmax(200px, 280px) minmax(0, 760px); + gap: 32px; + padding-block: 48px; +} + +.method__copy { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 24px; + color: var(--muted); +} + +.method__copy p { + margin: 0; +} + +@media (max-width: 760px) { + body { + font-size: 14px; + } + + .scene__inner, + .method { + grid-template-columns: 1fr; + } + + .masthead__inner, + .scene__inner, + .method { + padding-block: 32px; + } + + h1 { + font-size: 40px; + } + + .control-band { + position: static; + } + + .control-band__inner { + align-items: stretch; + flex-direction: column; + } + + .segmented { + width: 100%; + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .toggles { + justify-content: space-between; + } + + .visual-head, + .attempts__head { + align-items: flex-start; + flex-direction: column; + gap: 3px; + } +} + +@media print { + .control-band { + display: none; + } + + .scene { + break-inside: avoid; + } + + .canvas-scroll { + overflow: hidden; + } +} \ No newline at end of file