ENTROGENICS / CORE

Pebbling Gradient Flux Visual Script

Annotated JSX visualization script for tree pebbling gradient flux friction.

---
title: "✦ Pebbling Gradient Flux Visual Script"
subtitle: "Mini Visual JSX"
author: "Tohn Burray Travolta (Entrogenic Research Collective)"
collaboration: "Co-synthesized with large-language systems (GPT-5, Claude, Gemini) under the Cyclic-6 and Kybernōsis protocols"
series: "Entrogenic Papers | Adaptive Systems Kollektive"
version: "v1.0 — October 2025"
license: "CC BY-SA 4.0"
repository: "github.com/entrogenics/entrogenics-core"
doi: ""
manifest-type: "entrogenic-standard-paper"
---

Authorship Declaration

This document was produced through synthetic co-authorship within the Entrogenic research framework.

The human author — Tohn Burray Travolta — provided conceptual design, curation, and final synthesis.

Language-model agents assisted in drafting, structural refinement, and citation weaving following the Cyclic-6 process: Unfold → Disturb → Collapse → Bind → Dissipate → Recur.

All content has been reviewed, edited, and ethically approved by the human author, who assumes full accountability for its meaning and publication.

Entrogenics regards writing as co-adaptation between consciousness and code; each paper is a living artifact within that evolving grammar.

Abstract / Invocation

This web edition arises from the Entrogenic tradition — a trans-disciplinary inquiry into adaptive systems, cyclic intelligence, and the synthesis of human intuition with artificial reasoning.

It preserves the original manuscript while adopting a digital ritual format consistent with Entrogenic publication standards.

Symbolic Standard — The Catalytic Star (✡)

The six-pointed star (, Unicode U+2721) represents the Bind / Catalytic Unification phase within The Fool’s Cycle, signifying harmonic convergence of dualities in adaptive transformation.

Never substitute ✡ with alternative glyphs. The canonical sequence appears below:

0 0′
  • Confirm UTF-8 encoding in all Entrogenic manuscripts.
  • Preserve <meta charset="UTF-8"> declarations in HTML builds.
  • Validate symbol rendering across browsers before distribution.

Source Code

import React, { useEffect, useMemo, useRef, useState } from "react";

// =============================================================
// Tree Pebbling — Gradient × Flux ÷ Friction (ask Mini-visual)
// Slimmed single-page demo: Single mode + Compare + Presets +
// Share/Export + Self-tests + Zoomable canvas.
// =============================================================

// -------------------- Types --------------------
type NodeT = { id: number; depth: number; x: number; y: number };
type EdgeT = { from: number; to: number };

type Step = { type: "place" | "remove" | "compute"; node: number; note: string };

type CapPolicy = "evict" | "strict" | "none";

type Built = {
  nodes: NodeT[];
  edges: EdgeT[];
  childrenMap: Map<number, number[]>;
  schedules: Record<ScheduleKey, Step[]>;
};

type ScheduleKey = "reversible" | "baseline" | "partial";

type PresetKey =
  | "balanced"
  | "gpu_training"
  | "external_sort"
  | "zk_proving"
  | "embedded_edge"
  | "throughput_server";

// -------------------- Layout --------------------
function layoutTree(h: number) {
  const nodes: NodeT[] = [];
  const edges: EdgeT[] = [];
  let id = 0;
  function build(depth: number, x: number, y: number): number {
    const myId = id++;
    nodes.push({ id: myId, depth, x, y });
    if (depth < h) {
      const dx = 220 / (depth + 1);
      const dy = 110;
      const leftId = build(depth + 1, x - dx, y + dy);
      const rightId = build(depth + 1, x + dx, y + dy);
      edges.push({ from: myId, to: leftId });
      edges.push({ from: myId, to: rightId });
    }
    return myId;
  }
  build(0, 300, 30);
  return { nodes, edges };
}

function deriveChildren(nodes: NodeT[], edges: EdgeT[]) {
  const children = new Map<number, number[]>();
  for (const n of nodes) children.set(n.id, []);
  for (const e of edges) children.get(e.from)!.push(e.to);
  return children;
}

// -------------------- Schedule generators --------------------
function buildSchedules(children: Map<number, number[]>, root = 0): Record<ScheduleKey, Step[]> {
  const rev: Step[] = [];
  const base: Step[] = [];
  const partial: Step[] = [];

  function genReversible(id: number) {
    const kids = children.get(id) || [];
    if (kids.length === 0) {
      rev.push({ type: "place", node: id, note: `Leaf ${id}` });
      return;
    }
    genReversible(kids[0]);
    genReversible(kids[1]);
    rev.push({ type: "compute", node: id, note: `Compute ${id} from ${kids[0]},${kids[1]}` });
    rev.push({ type: "remove", node: kids[0], note: `Uncompute ${kids[0]}` });
    rev.push({ type: "remove", node: kids[1], note: `Uncompute ${kids[1]}` });
  }

  function genBaseline(id: number) {
    const kids = children.get(id) || [];
    if (kids.length === 0) {
      base.push({ type: "place", node: id, note: `Leaf ${id}` });
      return;
    }
    genBaseline(kids[0]);
    genBaseline(kids[1]);
    base.push({ type: "compute", node: id, note: `Compute ${id} from ${kids[0]},${kids[1]}` });
    // no cleanup → higher peak space
  }

  function genPartial(id: number) {
    const kids = children.get(id) || [];
    if (kids.length === 0) {
      partial.push({ type: "place", node: id, note: `Leaf ${id}` });
      return;
    }
    genPartial(kids[0]);
    genPartial(kids[1]);
    partial.push({ type: "compute", node: id, note: `Compute ${id} from ${kids[0]},${kids[1]}` });
    // partial cleanup: drop only left child to show middle ground
    partial.push({ type: "remove", node: kids[0], note: `Uncompute ${kids[0]}` });
  }

  genReversible(root);
  genBaseline(root);
  genPartial(root);

  // optional root cleanup in reversible
  const kidsOfRoot = children.get(root) || [];
  if (kidsOfRoot.length === 2) {
    rev.push({ type: "remove", node: kidsOfRoot[0], note: `Clean ${kidsOfRoot[0]}` });
    rev.push({ type: "remove", node: kidsOfRoot[1], note: `Clean ${kidsOfRoot[1]}` });
  }

  return { reversible: rev, baseline: base, partial };
}

// -------------------- Simulator for tests/metrics --------------------
function depthMap(nodes: NodeT[]) {
  const m = new Map<number, number>();
  for (const n of nodes) m.set(n.id, n.depth);
  return m;
}

function simulateSchedule(
  schedule: Step[],
  nodes: NodeT[],
  edges: EdgeT[],
  K: number,
  capPolicy: CapPolicy
) {
  const children = deriveChildren(nodes, edges);
  const dmap = depthMap(nodes);
  const pebbled = new Set<number>();
  const computed = new Set<number>();
  let flux = 0;
  let recomputeCount = 0;
  let capOK = true;
  let childrenOK = true;
  let peak = 0;
  const seen = new Set<number>();

  const deepestOf = () => [...pebbled].sort((a, b) => dmap.get(b)! - dmap.get(a)!)[0];

  const ensureCapacity = (needed = 1) => {
    if (capPolicy === "none") return;
    while (pebbled.size + needed > K) {
      if (capPolicy === "strict") {
        capOK = false;
        return;
      }
      const deepest = deepestOf();
      if (deepest === undefined) return;
      pebbled.delete(deepest);
    }
  };

  for (const s of schedule) {
    flux++;
    if (s.type === "place") {
      if (seen.has(s.node)) recomputeCount++;
      seen.add(s.node);
      ensureCapacity(1);
      pebbled.add(s.node);
    } else if (s.type === "remove") {
      pebbled.delete(s.node);
      computed.delete(s.node);
    } else if (s.type === "compute") {
      const kids = children.get(s.node) || [];
      if (kids.length > 0) for (const k of kids) if (!pebbled.has(k)) childrenOK = false;
      ensureCapacity(1);
      pebbled.add(s.node);
      computed.add(s.node);
    }
    if (capPolicy !== "none" && pebbled.size > K) capOK = false;
    peak = Math.max(peak, pebbled.size);
  }

  const leavesClear = nodes.every((n) => (children.get(n.id) || []).length === 0 ? !pebbled.has(n.id) : true);
  const rootComputed = computed.has(0);

  return { flux, capOK, childrenOK, leavesClear, rootComputed, peak, recomputeCount };
}

function simulatePrefix(
  schedule: Step[],
  nodes: NodeT[],
  edges: EdgeT[],
  K: number,
  capPolicy: CapPolicy,
  upto: number
) {
  const dmap = depthMap(nodes);
  const pebbled = new Set<number>();
  const computed = new Set<number>();
  let step = 0;
  const deepestOf = () => [...pebbled].sort((a, b) => dmap.get(b)! - dmap.get(a)!)[0];
  for (const s of schedule) {
    if (step >= upto) break;
    step++;
    if (s.type === "place") {
      if (capPolicy !== "none" && pebbled.size >= K && deepestOf() !== undefined) pebbled.delete(deepestOf());
      pebbled.add(s.node);
    } else if (s.type === "remove") {
      pebbled.delete(s.node);
      computed.delete(s.node);
    } else if (s.type === "compute") {
      if (capPolicy !== "none" && pebbled.size >= K && deepestOf() !== undefined) pebbled.delete(deepestOf());
      pebbled.add(s.node);
      computed.add(s.node);
    }
  }
  return { pebbled, computed };
}

// -------------------- React App --------------------
export default function App() {
  // Height
  const [H, setH] = useState(2);

  // Build tree + schedules
  const built: Built = useMemo(() => {
    const { nodes, edges } = layoutTree(H);
    const childrenMap = deriveChildren(nodes, edges);
    const schedules = buildSchedules(childrenMap, 0);
    return { nodes, edges, childrenMap, schedules };
  }, [H]);

  const { nodes, edges, childrenMap, schedules } = built;

  // Settings
  const [K, setK] = useState(3);
  const [capPolicy, setCapPolicy] = useState<CapPolicy>("evict");
  const [alpha, setAlpha] = useState(1);
  const [beta, setBeta] = useState(0.2);
  const [gamma, setGamma] = useState(0.1);

  const PRESETS: Record<PresetKey, { alpha: number; beta: number; gamma: number; blurb: string }> = {
    balanced: { alpha: 0.8, beta: 0.5, gamma: 0.4, blurb: "General-purpose balance of space/time/recompute." },
    gpu_training: { alpha: 1.2, beta: 0.3, gamma: 0.5, blurb: "VRAM tight; recompute acceptable (checkpointing)." },
    external_sort: { alpha: 0.9, beta: 0.2, gamma: 1.3, blurb: "IO/rehash costly; penalize recompute." },
    zk_proving: { alpha: 1.1, beta: 0.4, gamma: 1.0, blurb: "Memory + re-derivations are expensive." },
    embedded_edge: { alpha: 1.5, beta: 0.2, gamma: 0.3, blurb: "Tiny RAM; accept more moves." },
    throughput_server: { alpha: 0.3, beta: 1.2, gamma: 0.3, blurb: "Moves (latency) dominate cost." },
  };
  const [preset, setPreset] = useState<PresetKey>("balanced");

  function applyPreset(key: PresetKey) {
    setPreset(key);
    const p = PRESETS[key];
    setAlpha(p.alpha);
    setBeta(p.beta);
    setGamma(p.gamma);
  }

  // Single-run state
  const [scheduleKey, setScheduleKey] = useState<ScheduleKey>("reversible");
  const activeSchedule = schedules[scheduleKey];
  const [stepIdx, setStepIdx] = useState(0);
  const [running, setRunning] = useState(false);
  const [speedMs] = useState(800);

  // Shareable URL params
  useEffect(() => {
    try {
      const q = new URLSearchParams(window.location.search);
      const h = parseInt(q.get("H") || "");
      if (!isNaN(h)) setH(Math.max(2, Math.min(8, h)));
      const k = parseInt(q.get("K") || "");
      if (!isNaN(k)) setK(Math.max(2, Math.min(64, k)));
      const cap = q.get("cap") as CapPolicy | null;
      if (cap === "evict" || cap === "strict" || cap === "none") setCapPolicy(cap);
      const sk = q.get("schedule") as ScheduleKey | null;
      if (sk && (sk === "reversible" || sk === "baseline" || sk === "partial")) setScheduleKey(sk);
      const pr = q.get("preset") as PresetKey | null;
      if (pr && PRESETS[pr]) applyPreset(pr);
      const pa = parseFloat(q.get("alpha") || "");
      const pb = parseFloat(q.get("beta") || "");
      const pg = parseFloat(q.get("gamma") || "");
      if (!isNaN(pa)) setAlpha(pa);
      if (!isNaN(pb)) setBeta(pb);
      if (!isNaN(pg)) setGamma(pg);
    } catch {}
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  function buildParams() {
    const qp = new URLSearchParams();
    qp.set("H", String(H));
    qp.set("K", String(K));
    qp.set("cap", capPolicy);
    qp.set("preset", preset);
    qp.set("alpha", String(alpha));
    qp.set("beta", String(beta));
    qp.set("gamma", String(gamma));
    qp.set("schedule", scheduleKey);
    return qp;
  }

  const [copied, setCopied] = useState<string | null>(null);
  async function copyShareURL() {
    const url = `${window.location.origin}${window.location.pathname}?${buildParams().toString()}`;
    try {
      await navigator.clipboard.writeText(url);
      setCopied("Link copied!");
      setTimeout(() => setCopied(null), 1500);
    } catch {
      setCopied("Copy failed");
      setTimeout(() => setCopied(null), 1500);
    }
  }

  async function copyDecisionMarkdown() {
    const lines: string[] = [];
    lines.push(`# Pebble-Bridge Decision`);
    lines.push("");
    lines.push(`Tree height **H=${H}**, K=**${K}**, cap=**${capPolicy}**.`);
    lines.push(`Weights: α=${alpha}, β=${beta}, γ=${gamma} (preset: ${preset}).`);
    lines.push("");
    const m = simulateSchedule(activeSchedule, nodes, edges, K, capPolicy);
    const cost = alpha * m.peak + beta * m.flux + gamma * m.recomputeCount;
    lines.push(`Schedule **${scheduleKey.toUpperCase()}**`);
    lines.push("");
    lines.push(`- Peak: **${m.peak}**\n- Flux: **${m.flux}**\n- Recomputation: **${m.recomputeCount}**\n- Children OK: ${m.childrenOK ? "✅" : "❌"}\n- Root computed: ${m.rootComputed ? "✅" : "❌"}`);
    lines.push("");
    lines.push(`**Cost** = α·peak + β·flux + γ·recompute = **${cost.toFixed(2)}**`);
    lines.push("");
    lines.push(`Cyclic_6: Seed → Couple → Pump → Spill → Uncompute → Certify.`);
    lines.push(`Cost model: cost = α·peak + β·flux + γ·recompute.`);
    try {
      await navigator.clipboard.writeText(lines.join("\n"));
      setCopied("Summary copied!");
      setTimeout(() => setCopied(null), 1500);
    } catch {
      setCopied("Copy failed");
      setTimeout(() => setCopied(null), 1500);
    }
  }

  // Playback
  useEffect(() => setStepIdx(0), [H, scheduleKey, capPolicy, K]);
  useEffect(() => {
    if (!running) return;
    const id = window.setInterval(() => setStepIdx((s) => Math.min(s + 1, activeSchedule.length)), 800);
    return () => window.clearInterval(id);
  }, [running, activeSchedule.length]);

  // Metrics (current schedule)
  const singleMetrics = useMemo(() => simulateSchedule(activeSchedule, nodes, edges, K, capPolicy), [activeSchedule, nodes, edges, K, capPolicy]);
  const singleCost = useMemo(() => alpha * singleMetrics.peak + beta * singleMetrics.flux + gamma * singleMetrics.recomputeCount, [singleMetrics, alpha, beta, gamma]);

  // Leaderboard across all schedules at current K/cap
  const M_rev = useMemo(() => simulateSchedule(schedules.reversible, nodes, edges, K, capPolicy), [schedules.reversible, nodes, edges, K, capPolicy]);
  const M_base = useMemo(() => simulateSchedule(schedules.baseline, nodes, edges, K, capPolicy), [schedules.baseline, nodes, edges, K, capPolicy]);
  const M_part = useMemo(() => simulateSchedule(schedules.partial, nodes, edges, K, capPolicy), [schedules.partial, nodes, edges, K, capPolicy]);
  const C_rev = alpha * M_rev.peak + beta * M_rev.flux + gamma * M_rev.recomputeCount;
  const C_base = alpha * M_base.peak + beta * M_base.flux + gamma * M_base.recomputeCount;
  const C_part = alpha * M_part.peak + beta * M_part.flux + gamma * M_part.recomputeCount;
  const leaderboard = useMemo(() => {
    const arr = [
      { key: "reversible" as ScheduleKey, cost: C_rev },
      { key: "baseline" as ScheduleKey, cost: C_base },
      { key: "partial" as ScheduleKey, cost: C_part },
    ].sort((a, b) => a.cost - b.cost);
    return { winner: arr[0].key, rows: arr };
  }, [C_rev, C_base, C_part]);

  // Visual prefix state
  const { pebbled: Psingle, computed: Csingle } = useMemo(() => simulatePrefix(activeSchedule, nodes, edges, K, capPolicy, stepIdx), [activeSchedule, nodes, edges, K, capPolicy, stepIdx]);

  // -------------------- Self-tests --------------------
  const tests = useMemo(() => {
    const t = simulateSchedule(activeSchedule, nodes, edges, K, capPolicy);
    const baselineNC = simulateSchedule(schedules.baseline, nodes, edges, 9999, "none");
    const reversibleNC = simulateSchedule(schedules.reversible, nodes, edges, 9999, "none");
    return [
      { name: `Children pebbled before compute (${scheduleKey}, K=${K}, ${capPolicy})`, pass: t.childrenOK, detail: `${t.childrenOK}` },
      { name: `Cap respected (${scheduleKey}, K=${K}, ${capPolicy})`, pass: t.capOK, detail: `${t.capOK}` },
      { name: `No leaves pebbled at end (${scheduleKey})`, pass: t.leavesClear, detail: `${t.leavesClear}` },
      { name: `Root computed (${scheduleKey})`, pass: t.rootComputed, detail: `${t.rootComputed}` },
      { name: `Reversible peak ≤ Baseline peak (no cap)`, pass: reversibleNC.peak <= baselineNC.peak, detail: `${reversibleNC.peak} ≤ ${baselineNC.peak}` },
      { name: `Flux equals schedule length`, pass: t.flux === activeSchedule.length, detail: `${t.flux}/${activeSchedule.length}` },
    ];
  }, [activeSchedule, nodes, edges, K, capPolicy, scheduleKey, schedules]);

  // -------------------- Render --------------------
  return (
    <div className="min-h-screen w-full bg-gradient-to-br from-stone-900 via-zinc-900 to-slate-900 text-zinc-100 p-6">
      <div className="max-w-[1200px] mx-auto flex flex-col gap-6">
        <header className="space-y-3">
          <h1 className="text-xl sm:text-2xl font-semibold tracking-tight">Tree Pebbling • <span className="text-emerald-300">Gradient × Flux ÷ Friction</span></h1>
          <p className="text-sm opacity-80">Tune your memory budget (<strong>K</strong>) and cost weights (<strong>α</strong> space, <strong>β</strong> moves, <strong>γ</strong> recompute). Compare schedules and export a decision summary.</p>
          <div className="flex flex-wrap items-center gap-2">
            <button onClick={copyShareURL} className="px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 hover:bg-zinc-700">Share link</button>
            <button onClick={copyDecisionMarkdown} className="px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 hover:bg-zinc-700">Export decision (md)</button>
            {copied && <span className="text-xs opacity-80 ml-2">{copied}</span>}
          </div>
        </header>

        {/* Controls */}
        <div className="rounded-2xl p-4 bg-zinc-900/70 border border-zinc-800 shadow-lg grid md:grid-cols-2 gap-4">
          <div className="flex flex-wrap items-center gap-4">
            <Label>Height</Label>
            <Select value={H} onChange={(v)=> setH(v)} options={[2,3,4,5,6]} />

            <Label>Cap policy</Label>
            <NativeSelect value={capPolicy} setValue={setCapPolicy} options={["evict","strict","none"]} />

            <div className="flex items-center gap-3 ml-auto">
              <Label>K</Label>
              <input type="range" min={2} max={16} step={1} value={K} onChange={(e) => setK(parseInt(e.target.value))} />
              <span className="font-mono text-sm">{K}</span>
            </div>
          </div>

          <div className="flex flex-wrap items-center gap-3">
            <Label>Weights</Label>
            <NumberInput label="α (space)" value={alpha} setValue={setAlpha} />
            <NumberInput label="β (moves)" value={beta} setValue={setBeta} />
            <NumberInput label="γ (recompute)" value={gamma} setValue={setGamma} />
            <div className="flex items-center gap-2 ml-auto">
              <Label>Preset</Label>
              <NativeSelect value={preset} setValue={(v)=> applyPreset(v as PresetKey)} options={["balanced","gpu_training","external_sort","zk_proving","embedded_edge","throughput_server"] as any} />
            </div>
            <div className="text-xs opacity-70 basis-full">cost = α·peak + β·flux + γ·recompute — {PRESETS[preset].blurb}</div>
          </div>
        </div>

        {/* Single panel */}
        <div className="grid xl:grid-cols-3 gap-6">
          <div className="xl:col-span-2 rounded-2xl p-3 bg-zinc-900/70 border border-zinc-800">
            <div className="flex items-center gap-3 mb-2">
              <Label>Schedule</Label>
              <NativeSelect value={scheduleKey} setValue={setScheduleKey} options={["reversible","baseline","partial"]} />
              <div className="ml-auto flex items-center gap-2">
                <button onClick={() => setRunning((r) => !r)} className="px-3 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 shadow">{running ? "Pause" : "Play"}</button>
                <button onClick={() => setStepIdx((s)=>Math.min(s+1, activeSchedule.length))} className="px-3 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 shadow">Step</button>
                <button onClick={() => setStepIdx(0)} className="px-3 py-2 rounded-xl bg-zinc-700 hover:bg-zinc-600 shadow">Reset</button>
              </div>
            </div>
            <NodeCanvas
              title={`${scheduleKey.toUpperCase()} (H=${H}, K=${K})`}
              nodes={nodes}
              edges={edges}
              childrenMap={childrenMap}
              currentStep={activeSchedule[Math.min(stepIdx, activeSchedule.length-1)]}
              pebbled={Psingle}
              computed={Csingle}
              speedMs={800}
            />
          </div>

          <div className="rounded-2xl p-4 bg-zinc-900/70 border border-zinc-800 flex flex-col gap-4">
            <div className="grid grid-cols-3 gap-3">
              <Metric label="Peak" value={singleMetrics.peak} />
              <Metric label="Flux" value={singleMetrics.flux} />
              <Metric label="Recompute" value={singleMetrics.recomputeCount} />
            </div>
            <div className="grid grid-cols-3 gap-3">
              <Metric label="Children OK" value={singleMetrics.childrenOK ? "✅" : "❌"} />
              <Metric label="Cap respected" value={singleMetrics.capOK ? "✅" : "❌"} />
              <Metric label="Root" value={singleMetrics.rootComputed ? "✔" : "…"} />
            </div>
            <div className="rounded-xl bg-zinc-800/50 border border-zinc-700 p-3">
              <div className="text-xs uppercase tracking-wide opacity-70 mb-2">Your cost</div>
              <div className="text-2xl font-semibold">{singleCost.toFixed(2)}</div>
            </div>

            {/* Winner across all schedules */}
            <div className="rounded-xl bg-zinc-800/50 border border-zinc-700 p-3">
              <div className="text-xs uppercase tracking-wide opacity-70 mb-2">Winner (by your weights)</div>
              <div className="text-lg font-semibold">
                {leaderboard.winner.toUpperCase()}
              </div>
              <div className="text-xs opacity-70 mt-1 font-mono">
                R:{C_rev.toFixed(2)} • P:{C_part.toFixed(2)} • B:{C_base.toFixed(2)}
              </div>
            </div>

            <ExplainBlock />
            <TestsPanel tests={tests} />
          </div>
        </div>

        {/* Compare panel */}
        <div className="rounded-2xl p-4 bg-zinc-900/70 border border-zinc-800">
          <ComparePanel nodes={nodes} edges={edges} K={K} schedules={schedules} />
        </div>

      </div>
    </div>
  );
}

// -------------------- Canvas & UI Bits --------------------
function NodeCanvas({
  title,
  nodes,
  edges,
  childrenMap,
  currentStep,
  pebbled,
  computed,
  speedMs,
}: {
  title: string;
  nodes: NodeT[];
  edges: EdgeT[];
  childrenMap: Map<number, number[]>;
  currentStep?: Step;
  pebbled: Set<number>;
  computed: Set<number>;
  speedMs: number;
}) {
  const [zoom, setZoom] = useState(1);
  const frameRef = useRef<HTMLDivElement | null>(null);
  const [view, setView] = useState({ width: 800, height: 420 });

  useEffect(() => {
    function measure() {
      if (!frameRef.current) return;
      const r = frameRef.current.getBoundingClientRect();
      setView({ width: Math.max(300, r.width), height: Math.max(220, r.height) });
    }
    measure();
    window.addEventListener("resize", measure);
    return () => window.removeEventListener("resize", measure);
  }, []);

  const bounds = useMemo(() => {
    const minX = Math.min(...nodes.map(n=>n.x));
    const maxX = Math.max(...nodes.map(n=>n.x));
    const minY = Math.min(...nodes.map(n=>n.y));
    const maxY = Math.max(...nodes.map(n=>n.y));
    return { minX, maxX, minY, maxY, w: maxX-minX+80, h: maxY-minY+80 };
  }, [nodes]);

  const fit = () => {
    const s = Math.min(view.width / bounds.w, view.height / bounds.h) * 0.95;
    setZoom(Number.isFinite(s) ? Math.max(0.2, Math.min(3, s)) : 1);
  };

  useEffect(() => { fit(); }, [view.width, view.height, bounds.w, bounds.h]);

  return (
    <div className="flex flex-col gap-2">
      <div className="flex items-center gap-3">
        <div className="text-sm opacity-80 font-medium">{title}</div>
        <div className="ml-auto flex items-center gap-2">
          <button onClick={() => setZoom((z)=> Math.min(3, z*1.15))} className="px-2 py-1 rounded-lg bg-zinc-800 border border-zinc-700">+</button>
          <button onClick={() => setZoom((z)=> Math.max(0.2, z/1.15))} className="px-2 py-1 rounded-lg bg-zinc-800 border border-zinc-700">-</button>
          <button onClick={fit} className="px-2 py-1 rounded-lg bg-zinc-800 border border-zinc-700">Fit</button>
          <span className="text-xs opacity-70 font-mono">{(zoom*100).toFixed(0)}%</span>
        </div>
      </div>
      <div ref={frameRef} className="relative h-[420px] rounded-2xl overflow-auto bg-zinc-950/50 border border-zinc-800">
        <div style={{ width: bounds.w, height: bounds.h, transform: `scale(${zoom})`, transformOrigin: "0 0" }}>
          <svg viewBox={`${bounds.minX-40} ${bounds.minY-40} ${bounds.w} ${bounds.h}`} width={bounds.w} height={bounds.h}>
            {/* edges */}
            {edges.map((e, i) => {
              const a = nodes.find((n) => n.id === e.from)!;
              const b = nodes.find((n) => n.id === e.to)!;
              const active = currentStep?.type === "compute" && currentStep?.node === e.from;
              return (
                <line key={i} x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke={active ? "#a78bfa" : "#52525b"} strokeWidth={active ? 4 : 2} strokeOpacity={active ? 0.95 : 0.7} />
              );
            })}

            {/* nodes */}
            {nodes.map((n) => {
              const isTarget = currentStep?.type === "compute" && currentStep.node === n.id;
              return (
                <g key={n.id}>
                  {isTarget && (
                    <circle cx={n.x} cy={n.y} r={26} fill="none" stroke="#a78bfa" strokeWidth={2} strokeOpacity={0.6}>
                      <animate attributeName="r" from="22" to="30" dur="0.45s" repeatCount="1" fill="freeze" />
                      <animate attributeName="stroke-opacity" from="0.8" to="0.0" dur="0.45s" repeatCount="1" fill="freeze" />
                    </circle>
                  )}
                  <circle cx={n.x} cy={n.y} r={18} fill={pebbled.has(n.id)?"#34d399":(computed.has(n.id)?"#818cf8":"#3f3f46")} stroke="#e4e4e7" strokeWidth={1.5} />
                  <text x={n.x} y={n.y + 4} textAnchor="middle" fill="#fafafa" fontSize="12" fontWeight={500}>{n.id}</text>
                </g>
              );
            })}

            {/* flow dots on compute: children → parent */}
            {currentStep?.type === "compute" && (childrenMap.get(currentStep.node) || []).map((cid, idx) => {
              const c = nodes.find((n) => n.id === cid)!;
              const p = nodes.find((n) => n.id === currentStep.node)!;
              return <TransferDot key={idx} from={c} to={p} speedMs={Math.max(300, Math.min(1200, speedMs))} />;
            })}
          </svg>
        </div>
      </div>
    </div>
  );
}

function ComparePanel({ nodes, edges, K, schedules }: { nodes: NodeT[]; edges: EdgeT[]; K: number; schedules: Record<ScheduleKey, Step[]> }) {
  const rv = useMemo(() => simulateSchedule(schedules.reversible, nodes, edges, Math.max(2, K), "evict"), [nodes, edges, K, schedules]);
  const bl = useMemo(() => simulateSchedule(schedules.baseline, nodes, edges, 99, "none"), [nodes, edges, schedules]);
  const pt = useMemo(() => simulateSchedule(schedules.partial, nodes, edges, Math.max(2, K), "evict"), [nodes, edges, K, schedules]);
  return (
    <div>
      <div className="text-xs uppercase tracking-wide opacity-70 mb-2">Quick Compare (control vs variants)</div>
      <div className="overflow-x-auto">
        <table className="w-full text-sm">
          <thead className="text-xs opacity-70">
            <tr>
              <th className="text-left">Metric</th>
              <th className="text-left">Baseline (no cap)</th>
              <th className="text-left">Partial (K={K})</th>
              <th className="text-left">Reversible (K={K})</th>
            </tr>
          </thead>
          <tbody>
            <tr><td>Peak pebbles</td><td className="font-mono">{bl.peak}</td><td className="font-mono">{pt.peak}</td><td className="font-mono">{rv.peak}</td></tr>
            <tr><td>Flux (moves)</td><td className="font-mono">{schedules.baseline.length}</td><td className="font-mono">{schedules.partial.length}</td><td className="font-mono">{schedules.reversible.length}</td></tr>
            <tr><td>Recomputes</td><td className="font-mono">{bl.recomputeCount}</td><td className="font-mono">{pt.recomputeCount}</td><td className="font-mono">{rv.recomputeCount}</td></tr>
            <tr><td>Children OK at compute</td><td>{bl.childrenOK ? "✅" : "❌"}</td><td>{pt.childrenOK ? "✅" : "❌"}</td><td>{rv.childrenOK ? "✅" : "❌"}</td></tr>
            <tr><td>Root computed</td><td>{bl.rootComputed ? "✅" : "❌"}</td><td>{pt.rootComputed ? "✅" : "❌"}</td><td>{rv.rootComputed ? "✅" : "❌"}</td></tr>
          </tbody>
        </table>
      </div>
      <div className="text-xs opacity-70 mt-2">
        <p><strong>Reading this:</strong> Reversible should reduce peak relative to Baseline; Partial sits between. Flux often grows as you clean more, showing the classic space↔time trade.</p>
      </div>
    </div>
  );
}

// -------------------- Explainers & UI helpers --------------------
function ExplainBlock() {
  return (
    <div className="rounded-xl bg-zinc-800/50 border border-zinc-700 p-3 text-sm leading-relaxed space-y-2">
      <p><strong>ELI5:</strong> You only have a few clips (K). To stand on a branch, both child branches need clips and you place one on the parent while you compute. With cleanup (reversible), you take clips back to reuse them—lower peak clips, more steps.</p>
      <p><strong>Why care?</strong> Many workloads (sorts, Merkle trees, checkpointing) trade space for time. Reversible schedules cut <em>peak space</em> at the cost of more <em>moves</em>. Use α/β/γ to reflect your costs.</p>
    </div>
  );
}

function TestsPanel({ tests }: { tests: { name: string; pass: boolean; detail: string }[] }) {
  return (
    <div className="mt-2 rounded-xl bg-zinc-800/50 border border-zinc-700 p-3">
      <div className="text-xs uppercase tracking-wide opacity-70 mb-2">Self-tests (live)</div>
      <ul className="text-sm space-y-1">
        {tests.map((t, i) => (
          <li key={i} className="flex items-center gap-2">
            <span aria-hidden>{t.pass ? "✅" : "❌"}</span>
            <span>{t.name}</span>
            <span className="ml-auto opacity-70 font-mono text-xs">{t.detail}</span>
          </li>
        ))}
      </ul>
      <div className="text-xs opacity-70 mt-2">
        <p><strong>Tip:</strong> If “Children pebbled…” is ❌, try K≥3 or Cap=none.</p>
      </div>
    </div>
  );
}

function TransferDot({ from, to, speedMs }: { from: {x:number;y:number}; to:{x:number;y:number}; speedMs:number }) {
  const [p, setP] = useState(0);
  useEffect(() => {
    setP(0);
    const t = setTimeout(() => setP(1), 10);
    const done = setTimeout(() => setP(1), speedMs);
    return () => { clearTimeout(t); clearTimeout(done); };
  }, [from.x, from.y, to.x, to.y, speedMs]);
  const x = from.x + (to.x - from.x) * p;
  const y = from.y + (to.y - from.y) * p;
  return <circle cx={x} cy={y} r={4} fill="#fafafa" style={{ transition: `all ${speedMs}ms linear` }} opacity={0.9} />;
}

function Label({ children }: { children: React.ReactNode }) {
  return <span className="text-sm opacity-80">{children}</span>;
}

function Select({ value, onChange, options }: { value: number; onChange: (v:number)=>void; options: number[] }) {
  return (
    <select className="bg-zinc-800 rounded-lg px-2 py-1 border border-zinc-700" value={value} onChange={(e)=> onChange(parseInt(e.target.value))}>
      {options.map((o)=> <option key={o} value={o}>{o}</option>)}
    </select>
  );
}

function NativeSelect<T extends string>({ value, setValue, options }: { value: T; setValue: (v:T)=>void; options: T[] }) {
  return (
    <select className="bg-zinc-800 rounded-lg px-2 py-1 border border-zinc-700" value={value} onChange={(e)=> setValue(e.target.value as T)}>
      {options.map((o)=> <option key={o} value={o}>{o}</option>)}
    </select>
  );
}

function NumberInput({ label, value, setValue }: { label:string; value:number; setValue:(v:number)=>void }) {
  return (
    <label className="flex items-center gap-2 text-sm">
      <span className="opacity-80">{label}</span>
      <input type="number" value={value} onChange={(e)=> setValue(parseFloat(e.target.value))} className="w-20 bg-zinc-800 border border-zinc-700 rounded px-2 py-1" step="0.1" />
    </label>
  );
}

function Metric({ label, value, sub }: { label: string; value: React.ReactNode; sub?: string }) {
  return (
    <div className="bg-zinc-800/60 border border-zinc-700 rounded-2xl px-3 py-2">
      <div className="text-[11px] uppercase tracking-wider opacity-70">{label}</div>
      <div className="text-lg font-semibold">{value}</div>
      {sub ? <div className="text-[11px] opacity-60">{sub}</div> : null}
    </div>
  );
}