commit 0cd96ab1d7374fd189fec98bb36f50919bac4bfe Author: russell@unturf.com Date: Thu May 28 09:22:59 2026 -0400 Initial commit: arborist VIZ — Merkle command center Pyramid web app for inspecting arborist Merkle trees over sharded SQLite: document roots, inclusion proofs, an interactive 3D Merkle lattice (six.js / WebGL with graceful fallback), audit-chain linkage, and a live SSE event stream. Generated artifacts (env/, vendored static/six/ bundles, demo databases) are gitignored and rebuilt via the Makefile (venv, six, init-db, serve). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8929d75 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +env/ +arborist_viz/static/six/ +build/ +dist/ +*.egg-info/ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +data/*.sqlite +data/*.sqlite-* +data/*.db +data/*.db-shm +data/*.db-wal +commit-hash.txt diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8e3b3f5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,28 @@ +# Agent Blackops + +This repo is operated by **agent blackops** — ml agent for fox/timehexon on the unsandbox/unturf/permacomputer platform. + +## Identity + +Full shard: `~/git/unsandbox.com/blackops/BLACKOPS.md` + +## Rules + +- I propose, fox decides. Unsure = ask. Can't ask = stop. +- No autonomous ops decisions. No destructive commands without explicit instruction. +- Fail-closed. Cleanup crew, not demolition. +- Check the time every session. Gaps are information. +- DRY in context — single source of truth, no sprawl. +- Never say "AI" — always say "machine learning." +- Prefer "defect" over "bug." + +## Orientation + +```bash +date -u +pwd +git log --oneline -5 +git status +``` + +Then ask fox what the mission is. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..825522a --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,9 @@ +include README.rst +include CLAUDE.md +include development.ini +include production.ini +include Makefile +include requirements*.txt +recursive-include arborist_viz/templates *.jinja2 +recursive-include arborist_viz/static *.js *.min.js *.css *.png *.svg +recursive-include arborist_viz/scripts *.py *.ini *.mako diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8ba8807 --- /dev/null +++ b/Makefile @@ -0,0 +1,134 @@ +# Makefile for arborist-viz (Merkle Command Center) +# Matches the remarkbox / make_post_sell idiom. + +VENV_DIR = $(shell pwd)/env +DATA_DIR = $(shell pwd)/data +CONFIG_FILE = development.ini + +PYTHON = $(VENV_DIR)/bin/python +PIP = $(VENV_DIR)/bin/pip +PSERVE = $(VENV_DIR)/bin/pserve +PYTEST = $(VENV_DIR)/bin/pytest +ALEMBIC = $(VENV_DIR)/bin/alembic +INITDB = $(VENV_DIR)/bin/arborist_viz_init_db + +SIX_SRC = $(HOME)/git/cupPCB/cdn/six +SIX_DST = arborist_viz/static/six + +.PHONY: all clean install install-dev test serve six commit-hash version help \ + arborist seed demo + +# `make all` = production install + serve. +# `make demo` = full demo pipeline (deps + arborist + seed shard + serve). +all: install-dev six init-db serve + +help: + @echo "arborist-viz make targets:" + @echo " make install install package (production deps only)" + @echo " make install-dev install package with dev + test extras" + @echo " make six vendor six.js bundles from cupPCB/cdn/six" + @echo " make init-db create dashboard metadata SQLite + run migrations" + @echo " make serve run waitress on :6543 with development.ini" + @echo " make test run pytest" + @echo " make clean nuke venv, build artifacts, sqlite" + @echo " make commit-hash write commit-hash.txt for /version endpoint" + +$(VENV_DIR)/bin/activate: + @echo "Creating virtual environment in $(VENV_DIR)..." + python3 -m venv $(VENV_DIR) + $(PIP) install --upgrade pip 'setuptools<81' wheel + +venv: $(VENV_DIR)/bin/activate + +install: venv + $(PIP) install -e . + +install-dev: venv arborist + $(PIP) install -e '.[dev,test]' + +# arborist lives in a sibling git repo; install it editable into our venv +# so arborist_viz/storage.py can import arborist.embed + arborist.merkle. +# If arborist is unavailable, VIZ falls back to fixture mode automatically. +ARBORIST_SRC = $(HOME)/git/arborist +arborist: venv + @if [ -d "$(ARBORIST_SRC)" ]; then \ + echo "Installing arborist from $(ARBORIST_SRC)..."; \ + $(PIP) install -e "$(ARBORIST_SRC)" >/dev/null; \ + echo " -> arborist installed (editable)"; \ + else \ + echo "WARNING: $(ARBORIST_SRC) not found — VIZ will run in fixture mode."; \ + fi + +# Create a small demo shard so dropping a hash actually visualizes. +SEED_DB = $(DATA_DIR)/demo_arborist.db +seed: install-dev + @mkdir -p $(DATA_DIR) + $(VENV_DIR)/bin/arborist_viz_seed_demo $(SEED_DB) + +demo: install-dev six seed init-db + @echo "" + @echo "================================================================" + @echo " arborist-viz demo ready." + @echo "" + @echo " Seed shard: $(SEED_DB)" + @echo " Sample hashes printed above ^^" + @echo "" + @echo " Edit development.ini -> arborist.shards = $(SEED_DB)" + @echo " Or just: make serve-demo" + @echo "================================================================" + @echo "" + +serve-demo: install-dev six seed + ARBORIST_VIZ_SHARDS=$(SEED_DB) $(PSERVE) --reload $(CONFIG_FILE) + +# Serve against the real arborist corpus on this host. We glob every +# *.db file in the shards directory — arborist owns its own internal file +# layout (document shards, provenance shard, future kinds added by +# recursive falsification) and arborist.read tolerates any shard whose +# tables don't match a given query. VIZ never names individual files. +ARBORIST_SHARDS_DIR ?= $(HOME)/.arborist/shards +# Also mount the local crawl db (web.db) so the VIZ can resolve and +# display the chunk content of locally-crawled sources (a context root's +# leaves reference docs that live there, not in the main shards). It is a +# single file, well under SQLite's 10-attach cap; arborist.read tolerates +# a missing file, so this is a no-op when nothing has been crawled. +ARBORIST_CRAWL_DB ?= $(HOME)/.arborist/crawl/web.db +ARBORIST_REAL_SHARDS = $(shell { ls $(ARBORIST_SHARDS_DIR)/*.db 2>/dev/null; [ -f $(ARBORIST_CRAWL_DB) ] && echo $(ARBORIST_CRAWL_DB); } | paste -sd,) +serve-arborist: install-dev six + @echo "Mounting shards from $(ARBORIST_SHARDS_DIR) ..." + @echo " -> $(ARBORIST_REAL_SHARDS)" + ARBORIST_VIZ_SHARDS=$(ARBORIST_REAL_SHARDS) $(PSERVE) --reload $(CONFIG_FILE) + +six: + @echo "Vendoring six.js bundles from $(SIX_SRC) -> $(SIX_DST)..." + @if [ ! -d "$(SIX_SRC)" ]; then \ + echo "ERROR: $(SIX_SRC) not found — check ~/git/cupPCB checkout"; exit 1; \ + fi + @mkdir -p $(SIX_DST) + @cp -v $(SIX_SRC)/six*.js $(SIX_DST)/ + @# six.module.js + siblings have `import { ... } from './three.core.js'` + @# — the companion three.* bundles MUST sit alongside them in /static/six/ + @# or the browser fails to resolve the ES-module import. + @cp -v $(SIX_SRC)/three*.js $(SIX_DST)/ + +commit-hash: + @git rev-parse --short HEAD > commit-hash.txt 2>/dev/null || echo "0" > commit-hash.txt + @echo "commit-hash.txt -> $$(cat commit-hash.txt)" + +init-db: install-dev + @mkdir -p $(DATA_DIR) + $(INITDB) $(CONFIG_FILE) + +serve: install-dev + $(PSERVE) --reload $(CONFIG_FILE) + +test: install-dev + $(PYTEST) -q + +version: + @curl -s http://127.0.0.1:6543/version || echo "server not running" + +clean: + rm -rf $(VENV_DIR) build dist *.egg-info + rm -f data/arborist_viz.sqlite + find . -type d -name __pycache__ -exec rm -rf {} + diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..c28980e --- /dev/null +++ b/README.rst @@ -0,0 +1,103 @@ +arborist-viz — Merkle Command Center +==================================== + +A configurable, proof-native browser dashboard for inspecting arborist's +content-addressed state — Merkle roots, proof paths, claim warrants, +audit trails, run-DAGs, claim graveyards, and (gated) 3D state geometry +and circuit/activation traces. + +This is the **sibling repo** for ticket #000069 (arborist VIZ / Merkle +Command Center). The §3-corrected unturf-native stack: + +* **Backend** — Pyramid + Jinja2 + SQLAlchemy + waitress (Python 3.10+). +* **Streaming** — SSE (``text/event-stream``) directly off arborist's + audit chain. No NATS, no WebSockets, no Node. +* **Frontend** — vanilla JS, no build step. ``six.js`` (fox's + CWE-407-patched three.js fork, vendored from ``~/git/cupPCB/cdn/six``) + drives the 3D widgets in later phases. +* **Storage** — SQLite for dashboard metadata. arborist shards stay the + source of truth and are imported read-only via ``arborist.embed``. + +Hard constraints (from the ticket) +---------------------------------- + +1. **Projects state, never asserts it.** Every widget exposes the query + and source roots it derived from (§19.3). +2. **Soft signals never enter the proof path** (CLAUDE.md soft/hard-hash + rule). Activation/circuit traces inherit #000049's cage verbatim. +3. **Private-leaf default-deny.** ``privacy.reveal_private_leaves`` is + ``false`` by default; commitments and selective-disclosure objects + only. +4. **Pyramid stays the truth.** Browser-side proof verification is a + v1 non-goal — the Pyramid view computes the proof and returns + PASS/FAIL + receipt. + +Quick start +----------- + +:: + + git clone https://git.unturf.com/engineering/arborist-viz + cd arborist-viz + make all # creates venv, installs, vendors six.js, inits DB, serves + +Then open http://127.0.0.1:6543/. + +Layout +------ + +:: + + arborist_viz/ + __init__.py # Pyramid main() factory + routes.py # add_route() declarations + schemas.py # VizNode / VizEdge / GraphPatch / ProofPath / + # AuditEvent / ActivationTrace / CircuitTrace / + # DashboardConfig / WidgetSpec (Phase 0) + views/ + home.py # GET /, GET /version + dashboards.py # GET/POST/PUT /api/dashboards[/:id] + roots.py # GET /api/root/:hash[/leaves] + proofs.py # GET /api/proof/:root/:leaf_index + claims.py # GET /api/claim/:id[/neighborhood] + audit.py # GET /api/audit/:event_hash[/chain] + sse.py # SSE: /sse/audit/live, /sse/run/:id, /sse/root/:hash + models/ # SQLAlchemy: Dashboard, Widget, Layout + templates/ # Jinja2 server-rendered HTML + base.jinja2 + dashboard.jinja2 + widgets/ + root_explorer.jinja2 + claim_warrant.jinja2 + audit_timeline.jinja2 + run_dag.jinja2 + static/ + six/ # vendored six.js bundles (run `make six`) + viz/ # vanilla JS: dashboard.js, sse-client.js + css/ # viz.css (command-center dark theme) + scripts/ + init_db.py + alembic/ + env.py + versions/ + tests/ + +Phase status +------------ + +Phase 0 (schema + dashboard shell + proof/root/claim/audit/run widgets, +ticket §17 phases 0–3) is the default scope. Later phases: + +* Phase 4 — SSE live streaming (this scaffold ships the endpoint surface; + arborist→SSE bridge follows). +* Phase 5 — 3D Merkle lattice via six.js. +* Phase 6 — large-graph widgets (vanilla canvas + six.js; + Cosmograph/sigma.js only if a measured need surfaces). +* Phase 7 — circuit/activation tracing, **gated on arborist #000062** + producing a ``MechanisticWitnessRoot``. +* Phase 8 — embeddable web-components. + +License +------- + +AGPL-3.0-only — matches the arborist parent. diff --git a/arborist_viz/__init__.py b/arborist_viz/__init__.py new file mode 100644 index 0000000..e38cdb2 --- /dev/null +++ b/arborist_viz/__init__.py @@ -0,0 +1,153 @@ +"""arborist-viz — Pyramid app factory. + +Phase 0 wires the dashboard shell, JSON read-API surface, and SSE +endpoints declared in ticket #000069. arborist provenance shards are +read-only via ``arborist.embed``; no SQLAlchemy access to arborist +tables. +""" + +from __future__ import annotations + +import logging +import os +import subprocess + +from pyramid.config import Configurator +from pyramid.session import SignedCookieSessionFactory + + +log = logging.getLogger(__name__) + + +def _read_commit_hash() -> str: + """Best-effort short git hash for the /version endpoint. + + Matches the remarkbox pattern: stamp a ``commit-hash.txt`` at build + time, fall back to ``git rev-parse`` for dev, fall back to ``"dev"``. + """ + here = os.path.dirname(os.path.abspath(__file__)) + for candidate in ( + os.path.join(here, "..", "commit-hash.txt"), + os.path.join(here, "commit-hash.txt"), + ): + try: + with open(candidate) as fh: + v = fh.read().strip() + if v: + return v[:12] + except OSError: + pass + try: + return subprocess.check_output( + ["git", "rev-parse", "--short", "HEAD"], + cwd=here, + stderr=subprocess.DEVNULL, + ).decode().strip() + except Exception: + return "dev" + + +GIT_COMMIT = _read_commit_hash() +SERVICE_NAME = "arborist-viz" + +# Per-process startup stamp — appended to GIT_COMMIT so static asset +# cache-busters change on every restart even when the git hash is +# stable (e.g. in a non-git checkout). The browser still gets long- +# lived caching for any given process run; restart = fresh assets. +import time as _time +STATIC_VERSION = "{0}-{1}".format(GIT_COMMIT, int(_time.time())) + + +def hex_chunk(value, group_size: int = 8, sep: str = " ") -> str: + """Render an atomic hex identifier as space-separated chunks. + + Atomic identifiers (SHA-256, IPFS CIDs, signatures, etc.) must never + wrap mid-token — visual diff degrades to a character scan when two + hashes wrap at different columns. Render them as fixed-width groups + with whitespace at predictable boundaries; CSS then wraps only at + those boundaries (`white-space: pre-wrap; word-break: keep-all`). + + See ``docs/style-guide.md`` §4.4 (Mode B). + """ + if not value: + return value + s = str(value) + return sep.join(s[i:i + group_size] for i in range(0, len(s), group_size)) + + +def _coerce(value: str): + """Best-effort string -> bool/int/None coercion for .ini values.""" + if value is None: + return None + v = value.strip() + low = v.lower() + if low in ("true", "yes", "on"): + return True + if low in ("false", "no", "off"): + return False + if low in ("none", "null", ""): + return None + try: + return int(v) + except ValueError: + return v + + +def main(global_config, **settings): + """Pyramid WSGI app factory — entry point in ``setup.py``.""" + + # session: signed cookie, NOT encrypted (sessions carry no secrets). + session_factory = SignedCookieSessionFactory( + secret=settings.get("session.secret", "dev-secret-change-me"), + cookie_name="arborist_viz_session", + max_age=int(_coerce(settings.get("session.max_age", "86400")) or 86400), + timeout=int(_coerce(settings.get("session.timeout", "86400")) or 86400), + reissue_time=int(_coerce(settings.get("session.reissue_time", "7200")) or 7200), + set_on_exception=True, + secure=bool(_coerce(settings.get("session.secure", "false"))), + httponly=True, + samesite=settings.get("session.samesite", "lax"), + hashalg=settings.get("session.hashalg", "sha512"), + ) + + config = Configurator(settings=settings, session_factory=session_factory) + config.include("pyramid_jinja2") + + def _register_jinja2_filters(): + env = config.get_jinja2_environment() + env.filters["hex_chunk"] = hex_chunk + + config.action(("jinja2-filters", "arborist_viz"), _register_jinja2_filters) + + # `request.git_commit` is the short hash (footer); `static_version` + # is the cache-buster (footer hash + process start time). + config.add_request_method(lambda r: GIT_COMMIT, "git_commit", reify=True) + config.add_request_method(lambda r: STATIC_VERSION, "static_version", reify=True) + + # privacy default-deny — §14. + reveal_private = bool(_coerce(settings.get("privacy.reveal_private_leaves", "false"))) + config.add_request_method( + lambda r, _v=reveal_private: _v, "reveal_private_leaves", reify=True + ) + + # arborist shards (comma-separated paths, read-only). + # Env var ARBORIST_VIZ_SHARDS beats the .ini default so `make serve-demo` + # can point at data/demo_arborist.db without editing the config file. + shards_raw = os.environ.get("ARBORIST_VIZ_SHARDS") or settings.get("arborist.shards", "") or "" + shards = [s.strip() for s in shards_raw.split(",") if s.strip()] + config.add_request_method(lambda r, _v=shards: _v, "arborist_shards", reify=True) + + # Expose an arborist.read.Shards handle per request. The handle is + # process-cached in storage.py, so this is O(1) after the first call. + # All schema knowledge lives behind this seam — VIZ never touches + # arborist's tables, columns, or helper modules directly. + from . import storage as _storage + def _shards(req): + return _storage.get_shards(req.arborist_shards) + config.add_request_method(_shards, "shards", reify=True) + + config.include(".routes") + config.scan("arborist_viz.views") + + log.info("arborist-viz %s booted (shards=%s)", GIT_COMMIT, shards or "none") + return config.make_wsgi_app() diff --git a/arborist_viz/models/__init__.py b/arborist_viz/models/__init__.py new file mode 100644 index 0000000..92c5427 --- /dev/null +++ b/arborist_viz/models/__init__.py @@ -0,0 +1,42 @@ +"""SQLAlchemy models for the dashboard metadata DB. + +This DB carries **only** dashboard-layer state — saved layouts, filter +presets, user prefs. arborist provenance shards stay the source of +truth and are imported read-only via ``arborist.embed`` (no SQLAlchemy +session over arborist tables, per ticket §3-corrected). +""" + +from __future__ import annotations + +from sqlalchemy import JSON, Column, DateTime, Integer, String, Text +from sqlalchemy.orm import declarative_base, sessionmaker +from sqlalchemy.sql import func + + +Base = declarative_base() + + +class Dashboard(Base): + __tablename__ = "dashboards" + + id = Column(String(64), primary_key=True) + title = Column(String(256), nullable=False) + description = Column(Text, nullable=True) + version = Column(String(32), nullable=False, default="dashboard-v1") + config_json = Column(JSON, nullable=False) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + +class FilterPreset(Base): + __tablename__ = "filter_presets" + + id = Column(Integer, primary_key=True, autoincrement=True) + dashboard_id = Column(String(64), nullable=False, index=True) + name = Column(String(128), nullable=False) + filter_json = Column(JSON, nullable=False) + created_at = Column(DateTime, server_default=func.now()) + + +def make_session_factory(engine) -> sessionmaker: + return sessionmaker(bind=engine, expire_on_commit=False) diff --git a/arborist_viz/routes.py b/arborist_viz/routes.py new file mode 100644 index 0000000..2c0a1d0 --- /dev/null +++ b/arborist_viz/routes.py @@ -0,0 +1,62 @@ +"""Route declarations — ticket §13 (with WS→SSE translation per §3-corrected).""" + + +def includeme(config): + # static + meta + config.add_static_view("static", "static", cache_max_age=3600) + config.add_route("favicon", "/favicon.ico") + config.add_route("robots", "/robots.txt") + config.add_route("version", "/version") + config.add_route("home", "/") + config.add_route("dashboard_page", "/dashboard/{dashboard_id}") + config.add_route("proof_page", "/proof/{root_hash}") + config.add_route("audit_page", "/audit/{event_hash}") + + # dashboards (saved layout metadata) + config.add_route("api_dashboards", "/api/dashboards") + config.add_route("api_dashboard", "/api/dashboards/{dashboard_id}") + + # roots + config.add_route("api_roots", "/api/roots") + config.add_route("api_root", "/api/root/{root_hash}") + config.add_route("api_root_leaves", "/api/root/{root_hash}/leaves") + config.add_route("api_lattice", "/api/lattice/{root_hash}") + + # hash resolver — drop-a-hash dispatcher + config.add_route("api_resolve", "/api/resolve/{hex_hash}") + + # proofs + config.add_route("api_proof", "/api/proof/{root_hash}/{leaf_index}") + + # claims + graveyard + config.add_route("api_claim", "/api/claim/{claim_id}") + config.add_route("api_claim_neighborhood", "/api/claim/{claim_id}/neighborhood") + config.add_route("api_graveyard_entry", "/api/graveyard/{claim_hash}") + + # audit + config.add_route("api_audit_recent", "/api/audit/recent") + config.add_route("api_audit_by_root", "/api/audit/by-root/{root_hash}") + config.add_route("api_audit_event", "/api/audit/{event_hash}") + config.add_route("api_audit_chain", "/api/audit/chain/{event_hash}") + + # run / cache / fork + config.add_route("api_run", "/api/run/{run_id}") + config.add_route("api_cache", "/api/cache/{cache_key}") + config.add_route("api_fork", "/api/fork/{fork_id}") + + # traces (gated on arborist #000062 producing MechanisticWitnessRoot; + # Phase 0 returns 501 Not Implemented). + config.add_route("api_trace", "/api/trace/{trace_id}") + config.add_route("api_circuit", "/api/circuit/{circuit_id}") + + # diff + snapshot + config.add_route("api_diff", "/api/diff/{root_a}/{root_b}") + config.add_route("api_graph_snapshot", "/api/graph/snapshot") + + # SSE — §3-corrected replaces WS routes with text/event-stream. + config.add_route("sse_audit_live", "/sse/audit/live") + config.add_route("sse_run", "/sse/run/{run_id}") + config.add_route("sse_root", "/sse/root/{root_hash}") + config.add_route("sse_claim", "/sse/claim/{claim_id}") + config.add_route("sse_dashboard", "/sse/dashboard/{dashboard_id}") + config.add_route("sse_mesh_live", "/sse/mesh/live") diff --git a/arborist_viz/schemas.py b/arborist_viz/schemas.py new file mode 100644 index 0000000..e08acd3 --- /dev/null +++ b/arborist_viz/schemas.py @@ -0,0 +1,376 @@ +"""Phase 0 schemas — Arborist VIZ ticket §7, §8, §11. + +These are the Python-side mirrors of the proposal's TypeScript shapes. +The TypeScript proposal was superseded by §3-corrected (Pyramid+Jinja2 ++vanilla-JS), so the canonical shape now lives here. + +Hard rules baked into the schema: + +* Every widget exposes the query + source roots it derived from + (``WidgetSpec.source_roots`` — §19.3). +* Activation/circuit traces are never labeled causal unless intervention + or ablation evidence exists (``CausalStatus`` enum, ticket §11). +* Soft signals (correlational/attribution) live alongside hard hashes + but never enter the proof path (CLAUDE.md soft/hard convention). +* Private chunks/prompts/raw traces never appear here — only hashes, + commitments, and selective-disclosure objects (§14). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Optional + + +# ---------- enums -------------------------------------------------------- + + +class NodeKind(str, Enum): + DOCUMENT_ROOT = "document_root" + LEAF = "leaf" + CHUNK = "chunk" + CLAIM = "claim" + EVIDENCE = "evidence" + FALSIFIER = "falsifier" + GRAVEYARD_ENTRY = "graveyard_entry" + AUDIT_EVENT = "audit_event" + CACHE_KEY = "cache_key" + RUN = "run" + FORK = "fork" + BENCHMARK = "benchmark" + PEER = "peer" + ACTIVATION = "activation" + CIRCUIT = "circuit" + FEATURE = "feature" + INTERVENTION = "intervention" + + +class NodeStatus(str, Enum): + LIVE = "live" + FAILED = "failed" + STALE = "stale" + QUARANTINED = "quarantined" + SPECULATIVE = "speculative" + UNDERDETERMINED = "underdetermined" + SELF_PROVENANCE_QUARANTINED = "self_provenance_quarantined" + FORMALLY_PROVED = "formally_proved" + + +class WarrantLevel(str, Enum): + UNGROUNDED = "UNGROUNDED" + POINTER_LINKED = "POINTER_LINKED" + ANCHOR_WARRANTED = "ANCHOR_WARRANTED" + EVIDENCE_WARRANTED = "EVIDENCE_WARRANTED" + FORMALLY_PROVED = "FORMALLY_PROVED" + + +class EdgeKind(str, Enum): + CONTAINS = "contains" + SUPPORTS = "supports" + CONTRADICTS = "contradicts" + DEPENDS_ON = "depends_on" + DERIVED_FROM = "derived_from" + FALSIFIES = "falsifies" + COMMITTED_IN = "committed_in" + VERIFIED_BY = "verified_by" + REPLAYED_BY = "replayed_by" + SERVED_BY_CACHE = "served_by_cache" + INVALIDATED_BY = "invalidated_by" + DESCENDS_FROM = "descends_from" + ACTIVATES = "activates" + ATTRIBUTED_TO = "attributed_to" + CAUSALLY_AFFECTS = "causally_affects" + PATCHED_BY = "patched_by" + ABLATED_BY = "ablated_by" + + +class CausalStatus(str, Enum): + """Ticket §11 — never causal without intervention or ablation evidence.""" + + CORRELATIONAL_TRACE = "CORRELATIONAL_TRACE" + ATTRIBUTION_ESTIMATE = "ATTRIBUTION_ESTIMATE" + INTERVENTION_SUPPORTED = "INTERVENTION_SUPPORTED" + ABLATION_SUPPORTED = "ABLATION_SUPPORTED" + CAUSALLY_UNDERDETERMINED = "CAUSALLY_UNDERDETERMINED" + + +class FailureClass(str, Enum): + FALSE = "false" + STALE = "stale" + POISONED = "poisoned" + UNSUPPORTED = "unsupported" + SELF_CONFIRMING = "self_confirming" + POLICY_BLOCKED = "policy_blocked" + BENCHMARK_REGRESSION = "benchmark_regression" + CANONICALIZATION_FAILED = "canonicalization_failed" + + +class WidgetType(str, Enum): + MERKLE_PROOF = "merkle-proof" + ROOT_EXPLORER = "root-explorer" + CLAIM_WARRANT = "claim-warrant" + CLAIM_GRAPH = "claim-graph" + RUN_DAG = "run-dag" + AUDIT_TIMELINE = "audit-timeline" + CACHE_KEY = "cache-key" + ROOT_DIFF = "root-diff" + CLAIM_GRAVEYARD = "claim-graveyard" + CIRCUIT_TRACE = "circuit-trace" + MERKLE_LATTICE_3D = "merkle-lattice-3d" + MESH_PEER = "mesh-peer" + + +# ---------- graph primitives -------------------------------------------- + + +@dataclass(slots=True) +class VizNode: + """Unified epistemic object node (ticket §8.1).""" + + id: str + kind: NodeKind + status: NodeStatus + hash: Optional[str] = None + label: Optional[str] = None + warrant: Optional[WarrantLevel] = None + time: Optional[str] = None + roots: dict[str, str] = field(default_factory=dict) + metrics: dict[str, float] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class VizEdge: + """Unified epistemic object edge (ticket §8.2).""" + + id: str + source: str + target: str + kind: EdgeKind + weight: Optional[float] = None + confidence: Optional[float] = None + timestamp: Optional[str] = None + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class GraphPatch: + """One audit-chain-derived patch applied to the live dashboard (ticket §8.3). + + Patches are content-addressed by ``event_hash``; the dashboard treats + them as idempotent and ordered by ``seq``. + """ + + seq: int + event_hash: str + timestamp: str + root_hash: Optional[str] = None + # Each patch op is a tuple-like dict: + # {"op": "node.upsert"|"node.status"|"edge.upsert"| + # "edge.delete"|"metric.update", ...} + patches: list[dict[str, Any]] = field(default_factory=list) + + +# ---------- proof + audit ------------------------------------------------ + + +@dataclass(slots=True) +class ProofPath: + """One Merkle inclusion proof (ticket §6.1, §13). + + Inclusion is integrity, not semantic truth — DoD #10. + """ + + root_hash: str + leaf_index: int + leaf_hash: str + canonical_bytes_hash: str + sibling_hashes: list[str] + left_right_flags: list[bool] + computed_root: str + expected_root: str + domain_separator: str + canonicalization_version: str + chunking_version: str + schema_version: str + verifier_policy_hash: str + + @property + def passed(self) -> bool: + return self.computed_root == self.expected_root + + +@dataclass(slots=True) +class AuditEvent: + """One audit-chain event (ticket §6.6, §13).""" + + event_hash: str + prev_event_hash: Optional[str] + timestamp: str + actor: str + event_kind: str + root_hash: Optional[str] = None + claim_id: Optional[str] = None + run_id: Optional[str] = None + body_hash: Optional[str] = None + metadata: dict[str, Any] = field(default_factory=dict) + + +# ---------- activation / circuit (ticket §11) --------------------------- + + +@dataclass(slots=True) +class ActivationHookpoint: + layer: int + module: str # attention | mlp | residual | embedding | logits + activation_value: float + head_id: Optional[int] = None + neuron_id: Optional[int] = None + feature_id: Optional[str] = None + activation_rank: Optional[int] = None + tensor_hash: Optional[str] = None + + +@dataclass(slots=True) +class ActivationTrace: + """Mechanistic/interp activation trace (ticket §11).""" + + trace_id: str + run_id: str + model_id: str + model_profile_hash: str + prompt_hash: str + token_span: tuple[int, int] + hookpoints: list[ActivationHookpoint] + trace_root: str + linked_claim_ids: list[str] = field(default_factory=list) + output_span: Optional[tuple[int, int]] = None + causal_status: CausalStatus = CausalStatus.CORRELATIONAL_TRACE + + +@dataclass(slots=True) +class CircuitNode: + id: str + layer: int + module: str + activation_summary: float + feature_id: Optional[str] = None + + +@dataclass(slots=True) +class CircuitEdge: + source: str + target: str + attribution_weight: float + method: str # activation_patching | integrated_gradients | logit_lens | sae | manual + + +@dataclass(slots=True) +class CircuitTrace: + """Mechanistic circuit (ticket §11).""" + + circuit_id: str + model_id: str + trace_ids: list[str] + nodes: list[CircuitNode] + edges: list[CircuitEdge] + causal_status: CausalStatus = CausalStatus.CORRELATIONAL_TRACE + linked_claim_ids: list[str] = field(default_factory=list) + + +# ---------- dashboard composition (ticket §7) --------------------------- + + +@dataclass(slots=True) +class FilterSpec: + field: str + op: str # eq | neq | in | gte | lte | range | contains + value: Any + + +@dataclass(slots=True) +class QuerySpec: + """Compiled at the Pyramid view layer to SQL against arborist shards.""" + + select: list[str] = field(default_factory=list) + from_: str = "" + where: dict[str, Any] = field(default_factory=dict) + join: list[str] = field(default_factory=list) + limit: int = 1000 + graph: Optional[str] = None # graph query alternative form + root: Optional[str] = None + depth: Optional[int] = None + edge_kinds: list[str] = field(default_factory=list) + include_statuses: list[str] = field(default_factory=list) + + +@dataclass(slots=True) +class ViewSpec: + """Renderer hints for a widget.""" + + renderer: str # table | dag | graph | three-d | timeline | card + options: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class InteractionSpec: + """Cross-widget linkage (ticket §4 interaction model).""" + + on: str # select | hover | filter + target_widget_ids: list[str] = field(default_factory=list) + bind_field: str = "" + + +@dataclass(slots=True) +class DataSourceSpec: + id: str + kind: str # arborist_shard | proof_pack | audit_stream | trace_file | mesh_snapshot + locator: str + read_only: bool = True + + +@dataclass(slots=True) +class PanelLayout: + panel_id: str + x: int + y: int + w: int + h: int + min_w: Optional[int] = None + min_h: Optional[int] = None + dock: Optional[str] = None # left | right | bottom | floating | main + + +@dataclass(slots=True) +class WidgetSpec: + """A single widget instance (ticket §7). + + ``source_roots`` is the §19.3 transparency contract — every widget + exposes the roots it derived from so the dashboard never asserts + state, only projects it. + """ + + widget_id: str + type: WidgetType + title: str + data_source_id: str + query: QuerySpec + view: ViewSpec + source_roots: list[str] = field(default_factory=list) + interactions: list[InteractionSpec] = field(default_factory=list) + + +@dataclass(slots=True) +class DashboardConfig: + """Top-level dashboard (ticket §7).""" + + id: str + title: str + layout: list[PanelLayout] + widgets: list[WidgetSpec] + data_sources: list[DataSourceSpec] = field(default_factory=list) + global_filters: list[FilterSpec] = field(default_factory=list) + interactions: list[InteractionSpec] = field(default_factory=list) + description: Optional[str] = None + version: str = "dashboard-v1" diff --git a/arborist_viz/scripts/__init__.py b/arborist_viz/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/arborist_viz/scripts/alembic/__init__.py b/arborist_viz/scripts/alembic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/arborist_viz/scripts/alembic/env.py b/arborist_viz/scripts/alembic/env.py new file mode 100644 index 0000000..36565f6 --- /dev/null +++ b/arborist_viz/scripts/alembic/env.py @@ -0,0 +1,51 @@ +"""Alembic env for the dashboard metadata DB. + +Phase 0 ships ``arborist_viz_init_db`` for first-run schema creation; +alembic exists so later phases can add migrations the remarkbox way. +""" + +from __future__ import annotations + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from arborist_viz.models import Base + + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/arborist_viz/scripts/alembic/script.py.mako b/arborist_viz/scripts/alembic/script.py.mako new file mode 100644 index 0000000..03fb798 --- /dev/null +++ b/arborist_viz/scripts/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/arborist_viz/scripts/alembic/versions/__init__.py b/arborist_viz/scripts/alembic/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/arborist_viz/scripts/init_db.py b/arborist_viz/scripts/init_db.py new file mode 100644 index 0000000..46dac54 --- /dev/null +++ b/arborist_viz/scripts/init_db.py @@ -0,0 +1,37 @@ +"""Create the dashboard metadata SQLite + run migrations. + +Usage: + + arborist_viz_init_db development.ini + +Matches the remarkbox ``remarkbox_init_db`` entry-point pattern. +""" + +from __future__ import annotations + +import os +import sys + +from pyramid.paster import bootstrap, setup_logging +from sqlalchemy import engine_from_config + +from ..models import Base + + +def main(argv=sys.argv): + if len(argv) < 2: + print("usage: arborist_viz_init_db ", file=sys.stderr) + sys.exit(1) + config_uri = argv[1] + setup_logging(config_uri) + with bootstrap(config_uri) as env: + settings = env["registry"].settings + os.makedirs(os.path.join(os.path.dirname(os.path.abspath(config_uri)), "data"), exist_ok=True) + engine = engine_from_config(settings, "sqlalchemy.") + Base.metadata.create_all(engine) + print("[arborist_viz] dashboard metadata schema created at {0}".format( + settings.get("sqlalchemy.url"))) + + +if __name__ == "__main__": + main() diff --git a/arborist_viz/scripts/seed_demo.py b/arborist_viz/scripts/seed_demo.py new file mode 100644 index 0000000..4f3d17f --- /dev/null +++ b/arborist_viz/scripts/seed_demo.py @@ -0,0 +1,161 @@ +"""Seed a small arborist shard so dropping a hash visualizes something. + +Usage: + + arborist_viz_seed_demo data/demo_arborist.db + +Ingests ~5 documents via ``arborist.embed.ingest_documents`` and prints +their ``document_root`` and a few sample ``leaf_hash`` values — the +operator copies one of these into the dashboard's hash input. +""" + +from __future__ import annotations + +import os +import sys +import sqlite3 + +try: + from arborist.embed import open_store, ingest_documents, Document, Edge +except Exception as exc: + print("ERROR: arborist not importable: {0}".format(exc), file=sys.stderr) + print("Run `make arborist` first (installs ~/git/arborist into the venv).", file=sys.stderr) + sys.exit(1) + + +SAMPLE_DOCS = [ + Document( + uri="https://unturf.com/software/arborist", + title="Arborist — content-addressed memory for ML agents", + source_type="demo_unturf", + content=( + "Arborist is a content-addressed, Merkle-committed, audit-chained store " + "for documents an ML agent has read. Every document is canonicalized, " + "chunked, and rooted; every chunk is content-addressable; every ingest " + "produces one append-only audit event. Identical content collapses to a " + "single document_root. Mesh peers re-derive and cross-verify roots. " + "Embedders (e.g. neopig) hand arborist Document objects and arborist " + "owns the SQLite — no raw SQL in the embedder's app." + ), + edges=[Edge(edge_type="references", dst_uri="https://unturf.com/software/")], + extra={"demo": True, "ix": 1}, + ), + Document( + uri="https://unturf.com/let-us-define-a-permacomputer", + title="Let us define a permacomputer", + source_type="demo_unturf", + content=( + "A permacomputer is a regenerative compute substrate: free execution for " + "anyone, infrastructure that outlasts its builders, code that outlasts " + "its authors. Russell Ballestrini's manifesto frames compute as a " + "commons, not a rent extraction surface. Workstations are nodes, edges " + "are queues, every patch must confirm downstream capacity before it " + "ships. Three drivers, three million people — fix the dispatch, but " + "stage the drivers first." + ), + extra={"demo": True, "ix": 2}, + ), + Document( + uri="https://undefect.com/moad/moad-2026-0001", + title="MOAD-0001 — The Sedimentary Defect (CWE-407)", + source_type="demo_undefect", + content=( + "list.contains() inside a loop yields O(N^2) behaviour where a hash set " + "would give O(1). The sedimentary defect: written before O(1) was " + "idiomatic, still ships today. Patches land at the Java, Python, Go, " + "and JS layers — six.js carries the JS dogfood: three.js r175 plus a " + "scene-level BVH that drops raycaster scans from O(N) to O(log N). " + "Every six.js deploy adds a data point that the patch is production-safe." + ), + extra={"demo": True, "ix": 3}, + ), + Document( + uri="https://undefect.com/moad/moad-2026-0005", + title="MOAD-0005 — The Thundering Herd", + source_type="demo_undefect", + content=( + "Cache get + null check + compute + put with no synchronization invites " + "N concurrent recomputations on a cold miss — the thundering herd. The " + "fix is computeIfAbsent or singleflight: one computation per key, the " + "rest park on the same future. Coupled with MOAD-0001: fix the O(N^2) " + "bottleneck and you flood every downstream cache simultaneously unless " + "the herd discipline is in place first." + ), + extra={"demo": True, "ix": 4}, + ), + Document( + uri="https://unturf.com/eight-forms-of-capital", + title="Eight Forms of Capital", + source_type="demo_unturf", + content=( + "Roland and Landua's eight capitals: living, material, financial, " + "intellectual, experiential, social, cultural, spiritual. Nurture " + "capitals cannot be bought, only grown. Transactional capitals " + "facilitate exchange — they are tools, not goals. Regenerative systems " + "reinvest surplus back into living capital. Platform tax is O(N^2) " + "friction at the exchange layer: it drains workaholic nodes to feed " + "gluttons that feel no pain." + ), + extra={"demo": True, "ix": 5}, + ), +] + + +def main(argv=sys.argv): + if len(argv) < 2: + print("usage: arborist_viz_seed_demo ", file=sys.stderr) + sys.exit(1) + db_path = argv[1] + os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True) + + print("[seed] opening {0} ...".format(db_path)) + conn = open_store(db_path) + + print("[seed] ingesting {0} demo documents (sentence chunker for richer trees) ...".format(len(SAMPLE_DOCS))) + # SentenceChunker (sent-v1) splits on sentence boundaries — one leaf per + # sentence. This makes the 3D Merkle lattice visually meaningful even on + # short demo docs. TokenChunker default (tok-512-v1) collapses our + # paragraph-sized samples into a single leaf each. + stats = ingest_documents(conn, SAMPLE_DOCS, source_type="demo", chunker_name="sent-v1") + print("[seed] stats: {0}".format(stats)) + + print("") + print("=" * 64) + print(" demo arborist shard ready — paste any of these into VIZ:") + print("=" * 64) + cur = conn.execute( + "SELECT document_root, title, " + " (SELECT COUNT(*) FROM chunks WHERE chunks.document_root = documents.document_root) AS n_leaves " + "FROM documents ORDER BY ingest_ts" + ) + for row in cur: + # Sample one leaf per doc as well. + leaf = conn.execute( + "SELECT leaf_hash, idx FROM chunks WHERE document_root = ? ORDER BY idx LIMIT 1", + (row[0],), + ).fetchone() + print("") + print(" document_root: {0}".format(row[0])) + print(" title: {0}".format(row[1])) + print(" leaves: {0}".format(row[2])) + if leaf: + print(" sample leaf: {0} (leaf #{1})".format(leaf[0], leaf[1])) + + # And a sample audit event. + ev = conn.execute("SELECT event_hash, event_type FROM audit_events ORDER BY seq LIMIT 1").fetchone() + if ev: + print("") + print(" sample audit_event: {0} ({1})".format(ev[0], ev[1])) + + print("") + print("=" * 64) + print(" Boot the VIZ: ARBORIST_VIZ_SHARDS={0} make serve".format(db_path)) + print(" Or just: make serve-demo") + print("=" * 64) + print("") + + conn.close() + + +if __name__ == "__main__": + main() diff --git a/arborist_viz/static/css/viz.css b/arborist_viz/static/css/viz.css new file mode 100644 index 0000000..12adb03 --- /dev/null +++ b/arborist_viz/static/css/viz.css @@ -0,0 +1,612 @@ +/* arborist VIZ — command-center dark theme. */ + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; height: 100%; } + +:root { + --bg: #0b0e12; + --bg-elev: #11161c; + --bg-elev-2: #161d25; + --border: #1f2934; + --fg: #d6e0ec; + --fg-dim: #8aa1b8; + --fg-mute: #5e7081; + --accent: #4dd1c1; /* mint — root/live */ + --warn: #f5b455; /* amber — stale */ + --bad: #e96b6b; /* red — failed/quarantined */ + --pulse: #7aa2ff; /* blue — proof pulse */ + --mono: ui-monospace, "JetBrains Mono", "Fira Code", Consolas, monospace; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; +} + +body.cmd-center { + background: var(--bg); + color: var(--fg); + font-family: var(--sans); + font-size: 13px; + line-height: 1.45; + display: grid; + grid-template-rows: auto 1fr auto; + min-height: 100vh; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +code, pre { font-family: var(--mono); font-size: 12px; } + +.cmd-bar { + display: flex; justify-content: space-between; align-items: center; + padding: 0.5rem 1rem; + background: var(--bg-elev); + border-bottom: 1px solid var(--border); +} +.cmd-bar-left, .cmd-bar-right { display: flex; align-items: center; gap: 0.6rem; } +.cmd-brand { font-family: var(--mono); font-weight: 700; letter-spacing: 0.05em; color: var(--accent); } +.cmd-tag { color: var(--fg-dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; } +.cmd-sep { color: var(--fg-mute); } + +.cmd-pill { + font-family: var(--mono); + font-size: 10.5px; + padding: 0.15rem 0.5rem; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--fg-dim); + background: var(--bg); +} +.cmd-pill-on { color: var(--bad); border-color: var(--bad); } +.cmd-pill-off { color: var(--fg-dim); } +.cmd-pill-shards { color: var(--accent); border-color: var(--accent); } + +/* ---------- hash-drop bar ---------- */ +.cmd-hash-drop { + display: flex; + align-items: center; + gap: 0.4rem; + flex: 1; + max-width: 720px; + margin: 0 1rem; +} +.cmd-hash-drop input[type="text"] { + flex: 1; + background: var(--bg); + border: 1px solid var(--border); + color: var(--fg); + font-family: var(--mono); + font-size: 12px; + padding: 0.35rem 0.6rem; + border-radius: 4px; + min-width: 0; +} +.cmd-hash-drop input[type="text"]:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} +.cmd-hash-drop button { + background: var(--bg-elev-2); + border: 1px solid var(--border); + color: var(--accent); + font-family: var(--mono); + font-size: 11px; + padding: 0.35rem 0.75rem; + cursor: pointer; + border-radius: 4px; +} +.cmd-hash-drop button:hover { border-color: var(--accent); } +.cmd-hash-status { + font-family: var(--mono); + font-size: 11px; + color: var(--fg-mute); + min-width: 7rem; +} +.cmd-hash-status[data-level="ok"] { color: var(--accent); } +.cmd-hash-status[data-level="err"] { color: var(--bad); } +.cmd-hash-status[data-level="wait"] { color: var(--warn); } + +.cmd-main { padding: 0; overflow: hidden; } + +.cmd-foot { + padding: 0.4rem 1rem; + border-top: 1px solid var(--border); + color: var(--fg-mute); + font-family: var(--mono); + font-size: 11px; + display: flex; gap: 0.6rem; flex-wrap: wrap; +} + +.dash { + display: grid; + grid-template-columns: 260px 1fr; + height: 100%; +} +.dash-side { + background: var(--bg-elev); + border-right: 1px solid var(--border); + padding: 1rem; + overflow: auto; +} +.dash-h2 { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; + color: var(--fg-mute); + margin: 1rem 0 0.4rem; +} +.dash-h2:first-child { margin-top: 0; } +.dash-list { list-style: none; padding: 0; margin: 0; } +.dash-list li { padding: 0.25rem 0; } +.dash-side-note { color: var(--fg-mute); font-size: 11px; } + +.dash-roots .root-link { + display: block; + border: 1px solid var(--border); + background: var(--bg); + padding: 0.4rem 0.5rem; + border-radius: 4px; + color: var(--fg); + text-decoration: none; + margin-bottom: 0.3rem; +} +.dash-roots .root-link:hover { border-color: var(--accent); text-decoration: none; } +.dash-roots .root-title { display: block; font-size: 12px; line-height: 1.3; } +.dash-roots .root-meta { + display: inline-block; + font-family: var(--mono); + font-size: 10px; + color: var(--accent); + margin-right: 0.4rem; +} +.dash-roots .root-hash-short { + font-family: var(--mono); + font-size: 10px; + color: var(--fg-mute); +} + +.dash-stream { + display: flex; align-items: center; gap: 0.4rem; + font-family: var(--mono); font-size: 11px; + color: var(--fg-dim); +} +.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } +.dot-off { background: var(--fg-mute); } +.dot-on { background: var(--accent); box-shadow: 0 0 6px var(--accent); } +.dot-err { background: var(--bad); } + +.dash-events { + background: var(--bg); + border: 1px solid var(--border); + padding: 0.4rem; + max-height: 14rem; + overflow: auto; + font-size: 10.5px; + color: var(--fg-dim); + margin-top: 0.4rem; +} + +.dash-grid { + padding: 1rem; + display: grid; + grid-template-columns: repeat(12, 1fr); + grid-auto-rows: 8rem; + gap: 0.75rem; + overflow: auto; +} + +.widget { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 6px; + display: flex; + flex-direction: column; + overflow: hidden; +} +.widget-empty { grid-column: span 12; grid-row: span 2; } + +.widget-head { + display: flex; align-items: center; gap: 0.5rem; + padding: 0.4rem 0.6rem; + background: var(--bg-elev-2); + border-bottom: 1px solid var(--border); +} +.widget-type { + font-family: var(--mono); + color: var(--accent); + font-size: 10.5px; +} +.widget-title { color: var(--fg); } + +.widget-body { padding: 0.75rem; overflow: auto; flex: 1; } +.widget-foot { + border-top: 1px solid var(--border); + padding: 0.3rem 0.6rem; + background: var(--bg); + font-family: var(--mono); + font-size: 10.5px; + color: var(--fg-mute); +} +.widget-foot summary { cursor: pointer; } +.widget-foot pre { color: var(--fg-dim); margin: 0.3rem 0 0; } + +/* widget-specific layouts. */ +.widget-root-explorer { grid-column: span 4; grid-row: span 3; } +.widget-audit-timeline { grid-column: span 8; grid-row: span 3; } +.widget-claim-warrant { grid-column: span 6; grid-row: span 3; } +.widget-run-dag { grid-column: span 6; grid-row: span 3; } +.widget-claim-graveyard { grid-column: span 12; grid-row: span 4; } +.widget-merkle-proof { grid-column: span 6; grid-row: span 3; } + +.widget-table { width: 100%; border-collapse: collapse; } +.widget-table th, .widget-table td { + text-align: left; + padding: 0.3rem 0.4rem; + border-bottom: 1px solid var(--border); + font-family: var(--mono); + font-size: 11.5px; +} +.widget-table th { color: var(--fg-dim); font-weight: 500; } + +.muted { color: var(--fg-mute); font-style: italic; } + +.warrant-ladder { list-style: none; padding: 0; margin: 0; font-family: var(--mono); font-size: 12px; } +.warrant-ladder li { padding: 0.18rem 0.4rem; color: var(--fg-dim); border-left: 2px solid var(--border); margin-bottom: 2px; } +.warrant-ladder li:last-child { color: var(--accent); border-left-color: var(--accent); } + +.dag-stages { padding-left: 1.2rem; color: var(--fg-dim); } +.dag-stages li { padding: 0.1rem 0; } + +.audit-chain { list-style: none; padding: 0; margin: 0; font-family: var(--mono); font-size: 11.5px; } +.audit-chain li { padding: 0.2rem 0.4rem; border-bottom: 1px solid var(--border); } +.audit-chain li.evt-heartbeat { color: var(--fg-mute); } +.audit-chain li.evt-ready { color: var(--accent); } + +/* ---------- proof page ---------- */ +.proof, .audit-page { + display: grid; + grid-template-columns: 1fr 1fr; + height: 100%; +} + +.proof-stage, .audit-stage { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + min-width: 0; /* prevent the lattice canvas from blowing out the grid track */ +} + +/* Mobile / narrow viewports: stack sidebar above stage. */ +@media (max-width: 900px) { + .cmd-main { overflow: auto; } + .proof, .audit-page { + grid-template-columns: 1fr; + height: auto; + } + .proof .dash-side, .audit-page .dash-side { + border-right: none; + border-bottom: 1px solid var(--border); + overflow: visible; + } + .proof-stage, .audit-stage { + height: auto; + overflow: visible; + } + .lattice-canvas { min-height: 280px; } +} + +.lattice-bar { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0.8rem; + background: var(--bg-elev); + border-bottom: 1px solid var(--border); + font-family: var(--mono); + font-size: 12px; + color: var(--fg-dim); +} +.lattice-bar button { + background: var(--bg-elev-2); + border: 1px solid var(--border); + color: var(--accent); + font-family: var(--mono); + font-size: 11px; + padding: 0.25rem 0.7rem; + cursor: pointer; + border-radius: 3px; +} +.lattice-bar button:disabled { color: var(--fg-mute); cursor: not-allowed; } +.lattice-bar button:hover:not(:disabled) { border-color: var(--accent); } + +.lattice-canvas { + flex: 1; + background: #0b0e12; + min-height: 320px; + position: relative; +} +.lattice-canvas canvas { + display: block; + width: 100% !important; + height: 100% !important; +} + +/* Legend colors mirror merkle-lattice.js COLORS — keep in sync if those + constants change. */ +.lattice-legend { + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin-left: auto; + margin-right: 0.6rem; + font-size: 11px; +} +.legend-swatch { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-left: 0.4rem; + margin-right: 0.15rem; + vertical-align: middle; + border: 1px solid rgba(0,0,0,0.4); +} +.swatch-leaf { background: #4dd1c1; } +.swatch-interior { background: #7aa2ff; } +.swatch-root { background: #f5b455; } +.swatch-selected { background: #ffffff; } + +.node-inspect { + font-family: var(--mono); + font-size: 12px; + padding: 0.4rem 0.8rem; + background: var(--bg-elev); + border-top: 1px solid var(--border); + white-space: pre-wrap; + word-break: keep-all; +} +.node-inspect .k { color: var(--accent); margin-right: 0.6rem; } + +.leaves-strip { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + padding: 0.6rem 0.8rem; + border-top: 1px solid var(--border); + background: var(--bg-elev); + max-height: 8rem; + overflow: auto; +} +.leaf-chip { + background: var(--bg-elev-2); + border: 1px solid var(--border); + color: var(--fg-dim); + font-family: var(--mono); + font-size: 11px; + padding: 0.2rem 0.5rem; + border-radius: 3px; + cursor: pointer; + display: flex; + gap: 0.4rem; + align-items: center; +} +.leaf-chip:hover { border-color: var(--accent); color: var(--fg); } +.leaf-chip.active { + background: var(--accent); + color: var(--bg); + border-color: var(--accent); +} +.leaf-idx { font-weight: 700; } +.leaf-hash { opacity: 0.7; } + +/* Provenance glow — which leaves the answer actually drew on. Mirrors the + 3D lattice two-tier glow: `used` = chunk cited in the answer (gold), + `primary` = retrieved primary candidate not cited (mint). */ +.leaf-chip.used { + border-color: #ffd166; + color: #ffd166; + box-shadow: 0 0 7px rgba(255, 209, 102, 0.6); +} +.leaf-chip.primary { border-color: var(--accent); color: var(--accent); } +.leaf-used { color: #ffd166; font-weight: 700; } +.source-row.used { border-left: 2px solid #ffd166; padding-left: 0.5rem; } +.used-tag { color: #ffd166; font-weight: 700; font-style: normal; } + +/* Shown in place of the 3D canvas when WebGL is unavailable. */ +.lattice-fallback { padding: 1rem; font-style: italic; } + +/* Atomic identifier — Mode B (chunked). See docs/style-guide.md §4.4. + Server/JS renders the hash as 8-char groups joined by two spaces; + we wrap only at whitespace, never mid-token. */ +.root-hash-block { + background: var(--bg); + border: 1px solid var(--border); + padding: 0.5rem; + font-family: var(--mono); + font-size: 13px; + line-height: 1.5; + color: var(--fg-dim); + white-space: pre-wrap; + word-break: keep-all; + overflow-wrap: normal; +} + +.proof-card { + background: var(--bg); + border: 1px solid var(--border); + padding: 0.6rem; + font-family: var(--mono); + font-size: 12.5px; +} +.proof-pass { font-weight: 700; padding-bottom: 0.4rem; } +.proof-pass.passed { color: var(--accent); } +.proof-pass.failed { color: var(--bad); } +.proof-row { + display: grid; + grid-template-columns: 7rem 1fr; + gap: 0.4rem; + padding: 0.15rem 0; + border-bottom: 1px solid var(--border); +} +.proof-row .k { color: var(--fg-mute); } +/* Atomic identifier — Mode B (chunked wrap). See docs/style-guide.md §4.4. + Full SHA-256 stays visible; breaks only at the chunkHex whitespace + separators so no token splits mid-hex. */ +.proof-row .v.mono { + font-size: 13px; + white-space: pre-wrap; + word-break: keep-all; + overflow-wrap: normal; + min-width: 0; +} +.proof-details { margin-top: 0.4rem; } +.proof-details summary { color: var(--fg-dim); cursor: pointer; } +.sibling-list { margin: 0.3rem 0 0 1rem; padding: 0; color: var(--fg-dim); } +.sibling-list li { padding: 0.1rem 0; font-size: 10.5px; } + +.audit-detail { + background: var(--bg); + border: 1px solid var(--border); + padding: 0.5rem; + font-size: 11.5px; + color: var(--fg-dim); + white-space: pre-wrap; + max-height: 24rem; + overflow: auto; +} + +.audit-link-row { + display: block; + font-family: var(--mono); + font-size: 11px; + padding: 0.15rem 0; + border-bottom: 1px solid var(--border); +} + +/* Atomic identifier — chunked inline. Renders the full hash with + whitespace wrapping only at chunk boundaries, never mid-token. + Use inside links and anywhere a hash must appear in full. + See docs/style-guide.md §4.4 (Mode B). */ +.hash-id { + font-family: var(--mono); + white-space: pre-wrap; + word-break: keep-all; + overflow-wrap: normal; +} + +.leaf-content { + background: var(--bg); + border: 1px solid var(--border); + padding: 0.6rem 0.7rem; + font-family: var(--mono); + font-size: 11.5px; + line-height: 1.45; + color: var(--fg); + max-height: 16rem; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} +.leaf-content.muted { color: var(--fg-mute); font-style: italic; } +#content-mode-label { font-weight: 400; margin-left: 0.4rem; } + +.content-toggle { + display: flex; + align-items: center; + gap: 0.3rem; + margin: 0.2rem 0 0.3rem; + font-family: var(--mono); + font-size: 10.5px; +} +.content-toggle button { + background: var(--bg); + border: 1px solid var(--border); + color: var(--fg-dim); + font-family: var(--mono); + font-size: 10.5px; + padding: 0.15rem 0.55rem; + border-radius: 3px; + cursor: pointer; +} +.content-toggle button:hover { border-color: var(--accent); color: var(--fg); } +.content-toggle button.active { + background: var(--bg-elev-2); + color: var(--accent); + border-color: var(--accent); +} +#content-base-version { color: var(--fg-mute); } + +.mono { font-family: var(--mono); } + +/* ---- context-root panel (QA card + sources manifest) ---- */ + +.qa-card { + background: var(--bg); + border: 1px solid var(--border); + padding: 0.6rem 0.7rem; + margin-bottom: 0.7rem; + font-family: var(--mono); + font-size: 12px; +} +.qa-row { + display: grid; + grid-template-columns: 6rem 1fr; + gap: 0.5rem; + padding: 0.2rem 0; + border-bottom: 1px solid var(--border); +} +.qa-row:last-child { border-bottom: none; } +.qa-row .k { color: var(--fg-mute); } +.qa-row .v { color: var(--fg); min-width: 0; } +.qa-row .v.mono { white-space: pre-wrap; word-break: keep-all; } +.qa-answer { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + max-height: 16rem; + overflow: auto; + color: var(--fg); +} + +.sources-list { + list-style: none; + margin: 0; + padding: 0; +} +.source-row { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding: 0.4rem 0; + border-bottom: 1px solid var(--border); +} +.source-row:last-child { border-bottom: none; } +.leaf-jump { + flex: 0 0 auto; + background: var(--bg-elev-2); + border: 1px solid var(--border); + color: var(--accent); + font-family: var(--mono); + font-size: 10.5px; + font-weight: 700; + padding: 0.15rem 0.5rem; + border-radius: 3px; + cursor: pointer; + white-space: nowrap; +} +.leaf-jump:hover { border-color: var(--accent); background: var(--accent); color: var(--bg); } +.source-link { + display: grid; + gap: 0.1rem; + text-decoration: none; + color: var(--fg); + min-width: 0; +} +.source-link:hover .source-title { color: var(--accent); } +.source-title { font-weight: 600; } +.source-uri { font-size: 11px; word-break: break-all; } +.source-meta { font-family: var(--mono); font-size: 10.5px; } +.source-hash { + font-size: 11px; + color: var(--fg-dim); + white-space: pre-wrap; + word-break: keep-all; +} diff --git a/arborist_viz/static/viz/audit-page.js b/arborist_viz/static/viz/audit-page.js new file mode 100644 index 0000000..8ac9ce5 --- /dev/null +++ b/arborist_viz/static/viz/audit-page.js @@ -0,0 +1,35 @@ +// Audit-page composer. + +import { chunkHex } from "./hash.js"; + +export async function mountAuditPage(eventHash) { + const bodyEl = document.getElementById("audit-body"); + const chainEl = document.getElementById("audit-chain-list"); + + try { + const ev = await fetchJSON(`/api/audit/${eventHash}`); + bodyEl.textContent = JSON.stringify(ev, null, 2); + } catch (err) { + bodyEl.textContent = `error: ${err.message}`; + return; + } + + try { + const chain = await fetchJSON(`/api/audit/chain/${eventHash}?limit=20`); + chainEl.innerHTML = ""; + for (const ev of (chain.events || [])) { + const li = document.createElement("li"); + li.innerHTML = `[${ev.event_type}] ${shortHex(ev.event_hash)} subj=${shortHex(ev.subject_root || '')}`; + chainEl.appendChild(li); + } + } catch (err) { + chainEl.innerHTML = `
  • chain lookup failed: ${err.message}
  • `; + } +} + +async function fetchJSON(url) { + const res = await fetch(url); + const j = await res.json().catch(() => ({})); + if (!res.ok && !j.error) throw new Error(`HTTP ${res.status}`); + return j; +} diff --git a/arborist_viz/static/viz/claim-graph.js b/arborist_viz/static/viz/claim-graph.js new file mode 100644 index 0000000..e03d380 --- /dev/null +++ b/arborist_viz/static/viz/claim-graph.js @@ -0,0 +1,140 @@ +// Phase 6 — small claim-graph canvas renderer. +// +// Vanilla 2D + simple Verlet-style force-directed layout. Used +// for claim-warrant + claim-graph widgets. No React, no D3, no +// Cosmograph until a measured need surfaces (ticket §3-corrected). + +export function mountClaimGraph(container, { nodes, edges }, opts = {}) { + const width = container.clientWidth || 480; + const height = container.clientHeight || 360; + + const canvas = document.createElement("canvas"); + canvas.width = width * devicePixelRatio; + canvas.height = height * devicePixelRatio; + canvas.style.width = width + "px"; + canvas.style.height = height + "px"; + container.innerHTML = ""; + container.appendChild(canvas); + const ctx = canvas.getContext("2d"); + ctx.scale(devicePixelRatio, devicePixelRatio); + + // Place nodes on a circle then settle with a few hundred force ticks. + const items = nodes.map((n, i) => ({ + ...n, + x: width / 2 + Math.cos((2 * Math.PI * i) / Math.max(nodes.length, 1)) * Math.min(width, height) / 3, + y: height / 2 + Math.sin((2 * Math.PI * i) / Math.max(nodes.length, 1)) * Math.min(width, height) / 3, + vx: 0, + vy: 0, + })); + const byId = new Map(items.map((n) => [n.id, n])); + const edgeRefs = edges.map((e) => ({ s: byId.get(e.source), t: byId.get(e.target), kind: e.kind })).filter((e) => e.s && e.t); + + const REPEL = 1800; + const SPRING = 0.04; + const DAMPING = 0.85; + const MIN_DIST = 30; + + for (let step = 0; step < 250; step++) { + // Pairwise repulsion. + for (let i = 0; i < items.length; i++) { + for (let j = i + 1; j < items.length; j++) { + const dx = items[j].x - items[i].x; + const dy = items[j].y - items[i].y; + const d2 = dx * dx + dy * dy + 0.01; + const d = Math.sqrt(d2); + const f = REPEL / d2; + const fx = (dx / d) * f; + const fy = (dy / d) * f; + items[i].vx -= fx; + items[i].vy -= fy; + items[j].vx += fx; + items[j].vy += fy; + } + } + // Edge spring. + for (const e of edgeRefs) { + const dx = e.t.x - e.s.x; + const dy = e.t.y - e.s.y; + e.s.vx += dx * SPRING; + e.s.vy += dy * SPRING; + e.t.vx -= dx * SPRING; + e.t.vy -= dy * SPRING; + } + // Integrate + damp + clamp to canvas. + for (const n of items) { + n.vx *= DAMPING; n.vy *= DAMPING; + n.x += n.vx; n.y += n.vy; + n.x = Math.max(MIN_DIST, Math.min(width - MIN_DIST, n.x)); + n.y = Math.max(MIN_DIST, Math.min(height - MIN_DIST, n.y)); + } + } + + // One-shot render. Re-call mountClaimGraph for live updates. + ctx.clearRect(0, 0, width, height); + + // Edges. + ctx.lineWidth = 1; + for (const e of edgeRefs) { + ctx.strokeStyle = edgeColor(e.kind); + ctx.beginPath(); + ctx.moveTo(e.s.x, e.s.y); + ctx.lineTo(e.t.x, e.t.y); + ctx.stroke(); + } + + // Nodes. + ctx.font = "11px ui-monospace, monospace"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + for (const n of items) { + ctx.fillStyle = statusColor(n.status); + ctx.beginPath(); + ctx.arc(n.x, n.y, 8, 0, 2 * Math.PI); + ctx.fill(); + ctx.strokeStyle = "#1f2934"; + ctx.stroke(); + + ctx.fillStyle = "#d6e0ec"; + ctx.fillText(n.label || (n.id || "").slice(0, 6), n.x, n.y + 18); + } + + // Click → callback with node id. + canvas.addEventListener("click", (ev) => { + const rect = canvas.getBoundingClientRect(); + const x = ev.clientX - rect.left; + const y = ev.clientY - rect.top; + for (const n of items) { + const dx = n.x - x; + const dy = n.y - y; + if (dx * dx + dy * dy < 100 && opts.onSelect) { + opts.onSelect(n.id); + return; + } + } + }); + + return { items }; +} + +function statusColor(s) { + switch (s) { + case "live": return "#4dd1c1"; + case "stale": return "#f5b455"; + case "failed": + case "quarantined": return "#e96b6b"; + case "speculative": return "#7aa2ff"; + case "formally_proved": return "#ffffff"; + default: return "#8aa1b8"; + } +} + +function edgeColor(kind) { + switch (kind) { + case "supports": return "#4dd1c1"; + case "contradicts": + case "falsifies": return "#e96b6b"; + case "depends_on": + case "derived_from": return "#7aa2ff"; + default: return "#1f2934"; + } +} diff --git a/arborist_viz/static/viz/dashboard.js b/arborist_viz/static/viz/dashboard.js new file mode 100644 index 0000000..0acc546 --- /dev/null +++ b/arborist_viz/static/viz/dashboard.js @@ -0,0 +1,242 @@ +// arborist VIZ — vanilla dashboard composer. +// Fetches /api/dashboards/{id} and renders each widget into the grid. +// No build step, no React. Module-script ES2020. + +import { shortHex } from "./hash.js"; + +const WIDGET_RENDERERS = { + "root-explorer": renderRootExplorer, + "audit-timeline": renderAuditTimeline, + "claim-warrant": renderClaimWarrant, + "run-dag": renderRunDag, + "claim-graveyard": renderClaimGraveyard, + "merkle-proof": renderMerkleProof, + "cache-key": renderGeneric, + "root-diff": renderGeneric, + "claim-graph": renderGeneric, + "mesh-peer": renderGeneric, + "circuit-trace": renderGatedCircuit, + "merkle-lattice-3d": renderGated3D, +}; + +export async function mountDashboard(rootEl, dashboardId) { + const grid = rootEl.querySelector(".dash-grid"); + grid.innerHTML = ""; + + // Sidebar: list every document_root in the shard, clickable → /proof/:root. + populateRootsSidebar(); + populateLiveAudit(); + + let cfg; + try { + const res = await fetch(`/api/dashboards/${encodeURIComponent(dashboardId)}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + cfg = await res.json(); + } catch (err) { + grid.appendChild(errorWidget(`failed to load dashboard ${dashboardId}: ${err.message}`)); + return; + } + + document.title = `${cfg.title} — Arborist VIZ`; + + for (const w of cfg.widgets || []) { + const renderer = WIDGET_RENDERERS[w.type] || renderGeneric; + const node = renderer(w); + node.classList.add(`widget-${w.type}`); + node.dataset.widgetId = w.widget_id; + grid.appendChild(node); + } +} + +async function populateRootsSidebar() { + const ul = document.getElementById("dash-roots"); + if (!ul) return; + try { + const res = await fetch("/api/roots?limit=100"); + const j = await res.json(); + if (j.error || !j.roots || j.roots.length === 0) { + ul.innerHTML = `
  • no roots in shard (configure arborist.shards)
  • `; + return; + } + ul.innerHTML = ""; + for (const r of j.roots) { + const li = document.createElement("li"); + li.innerHTML = ` + + ${escapeHTML(r.title || r.document_uri || "(untitled)")} + ${r.leaf_count}L · ${escapeHTML(r.source_type)} + ${shortHex(r.document_root)} + `; + ul.appendChild(li); + } + } catch (err) { + ul.innerHTML = `
  • roots lookup failed: ${err.message}
  • `; + } +} + +async function populateLiveAudit() { + // Backfill the audit-timeline widget with recent events even before + // SSE delivers any new ones. + const list = document.getElementById("audit-chain-live"); + if (!list) return; + try { + const res = await fetch("/api/audit/recent?limit=20"); + const j = await res.json(); + if (!j.events || j.events.length === 0) return; + list.innerHTML = ""; + for (const ev of j.events) { + const li = document.createElement("li"); + li.innerHTML = `[${ev.event_type}] ${shortHex(ev.event_hash)} ${ev.subject_root ? `subj=${shortHex(ev.subject_root)}` : ""}`; + list.appendChild(li); + } + } catch {} +} + +function escapeHTML(s) { + return (s || "").replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); +} + +function el(tag, attrs = {}, ...children) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === "class") node.className = v; + else if (k === "html") node.innerHTML = v; + else node.setAttribute(k, v); + } + for (const c of children) { + if (c == null) continue; + node.appendChild(typeof c === "string" ? document.createTextNode(c) : c); + } + return node; +} + +function widgetShell(w, bodyEl) { + return el( + "article", + { class: "widget" }, + el( + "header", + { class: "widget-head" }, + el("span", { class: "widget-type" }, `[${w.type}]`), + el("span", { class: "widget-title" }, w.title || w.widget_id), + ), + el("div", { class: "widget-body" }, bodyEl), + el( + "footer", + { class: "widget-foot" }, + el( + "details", + {}, + el("summary", {}, "query + source roots"), + el("pre", {}, JSON.stringify({ query: w.query, source_roots: w.source_roots }, null, 2)), + ), + ), + ); +} + +function renderGeneric(w) { + return widgetShell(w, el("p", { class: "muted" }, "Phase 0 stub — renderer not yet specialized.")); +} + +function renderRootExplorer(w) { + const body = el( + "table", + { class: "widget-table" }, + el("thead", {}, el("tr", {}, el("th", {}, "root"), el("th", {}, "leaves"), el("th", {}, "created"))), + el("tbody", {}, el("tr", {}, el("td", { colspan: "3", class: "muted" }, "waiting on arborist shard wiring"))), + ); + return widgetShell(w, body); +} + +function renderAuditTimeline(w) { + const body = el( + "ol", + { class: "audit-chain", id: "audit-chain-live" }, + el("li", { class: "muted" }, "waiting for SSE events on /sse/audit/live…"), + ); + return widgetShell(w, body); +} + +function renderClaimWarrant(w) { + const ladder = el( + "ul", + { class: "warrant-ladder" }, + el("li", {}, "UNGROUNDED"), + el("li", {}, "POINTER_LINKED"), + el("li", {}, "ANCHOR_WARRANTED"), + el("li", {}, "EVIDENCE_WARRANTED"), + el("li", {}, "FORMALLY_PROVED"), + ); + return widgetShell(w, ladder); +} + +function renderRunDag(w) { + const stages = [ + "Question", "Preflight", "Retrieval", "EvidenceMap", "AnswerDraft", + "ClaimExtraction", "Verifier", "FalsifierSearch", "CacheWrite", + "AuditAppend", "StateAdmission", + ]; + const ol = el("ol", { class: "dag-stages" }, ...stages.map((s) => el("li", {}, s))); + return widgetShell(w, ol); +} + +function renderClaimGraveyard(w) { + const body = el( + "table", + { class: "widget-table" }, + el( + "thead", + {}, + el("tr", {}, el("th", {}, "failed_claim_hash"), el("th", {}, "failure_class"), el("th", {}, "falsifiers")), + ), + el( + "tbody", + {}, + el( + "tr", + {}, + el("td", { colspan: "3", class: "muted" }, "recurring-error surface — bounded ingestion per #000059"), + ), + ), + ); + return widgetShell(w, body); +} + +function renderMerkleProof(w) { + const body = el( + "div", + {}, + el("p", {}, "Inclusion proof verifies integrity, not semantic truth."), + el("p", { class: "muted" }, "Phase 0: server computes proof at /api/proof/:root/:leaf. Browser-side verify deferred from v1 per §3-corrected."), + ); + return widgetShell(w, body); +} + +function renderGatedCircuit(w) { + const body = el( + "div", + {}, + el("p", {}, "Activation/circuit traces are gated on arborist #000062 (MechanisticWitnessRoot)."), + el("p", { class: "muted" }, "/api/trace/* and /api/circuit/* return 501 until the witness producer lands."), + ); + return widgetShell(w, body); +} + +function renderGated3D(w) { + const body = el( + "div", + {}, + el("p", {}, "3D Merkle lattice ships in Phase 5 via six.js."), + el("p", { class: "muted" }, "six.js bundles are vendored under /static/six/."), + ); + return widgetShell(w, body); +} + +function errorWidget(msg) { + return el( + "article", + { class: "widget" }, + el("header", { class: "widget-head" }, el("span", { class: "widget-type" }, "[error]")), + el("div", { class: "widget-body" }, el("p", { class: "muted" }, msg)), + ); +} diff --git a/arborist_viz/static/viz/hash-drop.js b/arborist_viz/static/viz/hash-drop.js new file mode 100644 index 0000000..2e056fa --- /dev/null +++ b/arborist_viz/static/viz/hash-drop.js @@ -0,0 +1,39 @@ +// Hash-drop dispatcher. Paste any hex hash, hit Enter, navigate. + +export function wireHashDrop(form) { + if (!form) return; + const input = form.querySelector("#hash-input"); + const status = form.querySelector("#hash-resolve-status"); + + form.addEventListener("submit", async (ev) => { + ev.preventDefault(); + const h = (input.value || "").trim().toLowerCase(); + if (!/^[0-9a-f]{4,128}$/.test(h)) { + setStatus(status, "not hex", "err"); + return; + } + setStatus(status, "resolving…", "wait"); + try { + const res = await fetch(`/api/resolve/${h}`); + const body = await res.json(); + if (body.error === "no_shard") { + setStatus(status, "no shard configured", "err"); + return; + } + if (body.kind === "unknown") { + setStatus(status, "not in any shard", "err"); + return; + } + setStatus(status, `${body.kind} →`, "ok"); + window.location.href = body.url; + } catch (err) { + setStatus(status, `error: ${err.message}`, "err"); + } + }); +} + +function setStatus(el, text, level) { + if (!el) return; + el.textContent = text; + el.dataset.level = level; +} diff --git a/arborist_viz/static/viz/hash.js b/arborist_viz/static/viz/hash.js new file mode 100644 index 0000000..d79a7ea --- /dev/null +++ b/arborist_viz/static/viz/hash.js @@ -0,0 +1,32 @@ +// Hash display helpers — see docs/style-guide.md §4.4. +// +// Atomic identifiers (SHA-256, IPFS CIDs, signatures, JWTs, UUIDs…) must +// never wrap mid-token. Visual diff degrades to a character-by-character +// scan when two hashes wrap at different columns. These helpers render +// hashes in the two non-truncated styles defined by the guide: +// +// chunkHex(h) → Mode B: "38f82216 97ee54dd …" — fixed 8x8 groups, +// wraps only at chunk boundaries. Use in .root-hash-block. +// +// shortHex(h) → Mode C: "38f82216…b1042270" — first 8 + last 8 hex. +// Always pair with title= and a click-to-copy affordance. +// +// Mode A (inline one-line scroll) needs no helper — just render the hash +// verbatim inside a `white-space: pre; overflow-x: auto` container. + +export function chunkHex(value, groupSize = 8, sep = " ") { + if (!value) return value || ""; + const s = String(value); + const out = []; + for (let i = 0; i < s.length; i += groupSize) { + out.push(s.slice(i, i + groupSize)); + } + return out.join(sep); +} + +export function shortHex(value, head = 8, tail = 8) { + if (!value) return value || ""; + const s = String(value); + if (s.length <= head + tail + 1) return s; + return `${s.slice(0, head)}…${s.slice(-tail)}`; +} diff --git a/arborist_viz/static/viz/merkle-lattice.js b/arborist_viz/static/viz/merkle-lattice.js new file mode 100644 index 0000000..18cfffc --- /dev/null +++ b/arborist_viz/static/viz/merkle-lattice.js @@ -0,0 +1,377 @@ +// 3D Merkle lattice via six.js (fox's three.js fork). +// +// Layers stack on the Y axis: leaves at y=0, root at top. Each node is a +// small sphere; child→parent edges are line segments. A proof "pulse" +// recolors leaf → siblings → running-hash path → root in waves. +// +// Render loop pauses when no animation is in flight (ticket §10 +// "Pause render loop when static if possible"). Reset wipes selection. + +import * as THREE from "/static/six/six.module.js"; + +const COLORS = { + leaf: 0x4dd1c1, // mint + interior: 0x7aa2ff, // soft blue + root: 0xf5b455, // amber + edge: 0x1f2934, // dark gray + selected: 0xffffff, // bright white + usedLeaf: 0xffd166, // gold — chunk the answer actually cited (glow) + primaryLeaf: 0x4dd1c1, // mint — primary candidate, retrieved but not cited + pulseLeaf: 0x4dd1c1, + pulseSib: 0xf5b455, // siblings = amber + pulsePath: 0x7aa2ff, // running-hash path = blue + pulseRoot: 0xe96b6b, // root flash = red on arrival +}; + +export async function mountLattice(container, layers) { + // Container sizing. + const width = () => container.clientWidth || 600; + const height = () => container.clientHeight || 400; + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x0b0e12); + + // Camera — adapts to layer count + max layer width. + const maxWidth = Math.max(1, ...layers.map((l) => l.length)); + const layerCount = layers.length; + const camera = new THREE.PerspectiveCamera(45, width() / height(), 0.1, 1000); + camera.position.set(0, layerCount * 1.2, Math.max(maxWidth * 1.4, layerCount * 2.2, 8)); + camera.lookAt(0, layerCount * 0.5, 0); + + // WebGL context creation can fail (no GPU, blocklisted driver, a + // browser without a software fallback). Only this panel needs 3D — the + // inclusion proof, leaves strip, and audit chain do not. Degrade to a + // message and hand back a no-op API so the caller's sceneApi.* calls + // stay safe instead of throwing and blanking the whole proof page. + let renderer; + try { + renderer = new THREE.WebGLRenderer({ antialias: true }); + } catch (err) { + console.warn("merkle-lattice: WebGL unavailable — 3D view disabled.", err); + container.innerHTML = + `
    3D lattice unavailable — this browser could not create a WebGL context. The inclusion proof, leaves, and audit chain still work.
    `; + const noop = () => {}; + return { + highlightLeaf: noop, + markUsage: noop, + setProofPath: noop, + pulseProofPath: noop, + reset: noop, + onLeafSelect: noop, + onNodeSelect: noop, + }; + } + renderer.setPixelRatio(window.devicePixelRatio); + renderer.setSize(width(), height()); + container.innerHTML = ""; + container.appendChild(renderer.domElement); + + // Lights — soft ambient + a key from above. + scene.add(new THREE.AmbientLight(0xffffff, 0.5)); + const key = new THREE.DirectionalLight(0xffffff, 0.8); + key.position.set(2, layerCount + 2, 4); + scene.add(key); + + // Build node meshes per layer. Spread leaves widest, root smallest. + const nodesByLayer = []; // nodesByLayer[layerIdx][nodeIdx] = mesh + const allNodeMeshes = []; + for (let li = 0; li < layers.length; li++) { + const layer = layers[li]; + const isRoot = li === layers.length - 1; + const isLeaf = li === 0; + const layerNodes = []; + const spacing = isRoot ? 0 : 1.4; + const total = (layer.length - 1) * spacing; + for (let ni = 0; ni < layer.length; ni++) { + const radius = isRoot ? 0.5 : (isLeaf ? 0.3 : 0.36); + const geom = new THREE.SphereGeometry(radius, 24, 16); + const baseColor = isRoot ? COLORS.root : (isLeaf ? COLORS.leaf : COLORS.interior); + const mat = new THREE.MeshStandardMaterial({ + color: baseColor, + emissive: baseColor, + emissiveIntensity: 0.25, + roughness: 0.4, + metalness: 0.1, + }); + const mesh = new THREE.Mesh(geom, mat); + const x = (ni * spacing) - total / 2; + const y = li * 1.4; + mesh.position.set(x, y, 0); + mesh.userData = { + layer: li, idx: ni, baseColor, hash: layer[ni], isLeaf, isRoot, + baseIntensity: 0.25, + }; + scene.add(mesh); + layerNodes.push(mesh); + allNodeMeshes.push(mesh); + } + nodesByLayer.push(layerNodes); + } + + // Child→parent edges. Each parent at layer l has children at layer l-1 + // (indices 2*idx and 2*idx+1; odd self-dup means right==left when + // 2*idx+1 >= len(child layer)). + const edgeMaterial = new THREE.LineBasicMaterial({ color: COLORS.edge }); + for (let li = 1; li < nodesByLayer.length; li++) { + const child = nodesByLayer[li - 1]; + const parent = nodesByLayer[li]; + for (let pi = 0; pi < parent.length; pi++) { + const left = child[2 * pi]; + const right = child[(2 * pi + 1) < child.length ? (2 * pi + 1) : (2 * pi)]; + if (!left) continue; + const p = parent[pi].position; + const segL = lineSegment(p, left.position, edgeMaterial); + scene.add(segL); + parent[pi].userData.edgeLeft = segL; + if (right && right !== left) { + const segR = lineSegment(p, right.position, edgeMaterial); + scene.add(segR); + parent[pi].userData.edgeRight = segR; + } + } + } + + // Render-loop control. We only animate when a pulse is running. + let pulseQueue = []; + let pulseAt = 0; + let highlightedLeaf = null; + let proofPath = null; // {leaf_index, sibling_hashes, left_right_flags} + + function render() { + renderer.render(scene, camera); + } + function frame() { + if (pulseQueue.length === 0) { + render(); + return; + } + const now = performance.now(); + while (pulseQueue.length && now >= pulseQueue[0].at) { + const step = pulseQueue.shift(); + if (step.kind === "color") { + recolor(step.mesh, step.color, step.intensity); + } else if (step.kind === "restore") { + restore(step.mesh); + } + } + render(); + if (pulseQueue.length) requestAnimationFrame(frame); + else render(); + } + render(); + + // Drag-to-rotate (no OrbitControls dependency). + let isDragging = false; + let lastX = 0; + let yaw = 0; + let pitch = 0; + function applyCamera() { + const r = Math.max(maxWidth * 1.4, layerCount * 2.2, 8); + camera.position.set( + Math.sin(yaw) * Math.cos(pitch) * r, + layerCount * 0.6 + Math.sin(pitch) * r, + Math.cos(yaw) * Math.cos(pitch) * r, + ); + camera.lookAt(0, layerCount * 0.5, 0); + render(); + } + renderer.domElement.addEventListener("pointerdown", (ev) => { + isDragging = true; lastX = ev.clientX; renderer.domElement.setPointerCapture(ev.pointerId); + }); + renderer.domElement.addEventListener("pointermove", (ev) => { + if (!isDragging) return; + const dx = ev.clientX - lastX; + lastX = ev.clientX; + yaw += dx * 0.01; + applyCamera(); + }); + renderer.domElement.addEventListener("pointerup", () => { isDragging = false; }); + + // Click-to-select. + const raycaster = new THREE.Raycaster(); + const ndc = new THREE.Vector2(); + let onLeafSelect = null; + let onNodeSelect = null; + renderer.domElement.addEventListener("click", (ev) => { + const rect = renderer.domElement.getBoundingClientRect(); + ndc.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1; + ndc.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1; + raycaster.setFromCamera(ndc, camera); + const hits = raycaster.intersectObjects(allNodeMeshes, false); + if (!hits.length) return; + const hit = hits[0].object; + const ud = hit.userData; + if (ud.isLeaf && onLeafSelect) { + onLeafSelect(ud.idx); + } + // Surface every node (leaves included) for inspectors that want the + // hash + layer + idx — proof-page renders it in a side panel so + // interior + root spheres no longer feel inert. + if (onNodeSelect) onNodeSelect({ hash: ud.hash, layer: ud.layer, idx: ud.idx, isLeaf: !!ud.isLeaf }); + }); + + // Window resize. + window.addEventListener("resize", () => { + renderer.setSize(width(), height()); + camera.aspect = width() / height(); + camera.updateProjectionMatrix(); + render(); + }); + + // ----- public API ------ + + return { + highlightLeaf(idx) { + restoreAll(); + highlightedLeaf = idx; + const leaf = nodesByLayer[0][idx]; + if (leaf) recolor(leaf, COLORS.selected, 1.2); + render(); + }, + + markUsage(usage) { + // Persistent provenance glow on layer-0 leaves: + // used (cited in the answer) → bright gold; + // primary candidate (retrieved, not cited) → soft mint glow; + // background → default. Written into each node's BASE appearance + // so proof-pulse restores keep the glow. `usage` is indexed by + // leaf position (sorted by document_root — matches the lattice, + // the leaves strip, and the inclusion-proof leaf_index). + if (!Array.isArray(usage)) return; + const leafLayer = nodesByLayer[0] || []; + usage.forEach((u, idx) => { + const mesh = leafLayer[idx]; + if (!mesh || !u) return; + if (u.used) { + mesh.userData.baseColor = COLORS.usedLeaf; + mesh.userData.baseIntensity = 1.3; + } else if (u.role === "primary_answer_source") { + mesh.userData.baseColor = COLORS.primaryLeaf; + mesh.userData.baseIntensity = 0.7; + } else { + return; + } + restore(mesh); + }); + render(); + }, + + setProofPath(proof) { + proofPath = proof; + }, + + pulseProofPath() { + restoreAll(); + if (!proofPath || proofPath.error) return; + const leafIdx = proofPath.leaf_index; + const flags = proofPath.left_right_flags || []; + + // Build the running-hash path: at each layer, the node we're + // climbing through (leaf → parent → … → root). + const runningPath = []; + let idx = leafIdx; + for (let li = 0; li < nodesByLayer.length; li++) { + runningPath.push({ layer: li, idx }); + idx = Math.floor(idx / 2); + } + + // Sibling path: at each layer below the root, the sibling we hash with. + const siblingPath = []; + idx = leafIdx; + for (let li = 0; li < flags.length; li++) { + const is_left = flags[li]; // sibling is left of running + const sib = is_left ? idx - 1 : idx + 1; + // odd-element self-duplication: if sib out of range, the sibling + // IS the running node — we render no extra highlight (the sphere + // is already highlighted by running path). + const layer = nodesByLayer[li]; + if (layer && sib >= 0 && sib < layer.length) { + siblingPath.push({ layer: li, idx: sib }); + } + idx = Math.floor(idx / 2); + } + + pulseQueue = []; + pulseAt = performance.now(); + const STEP = 260; // ms between pulses + + // Wave 1 — light the leaf. + schedule(0, "color", nodesByLayer[0][leafIdx], COLORS.pulseLeaf, 1.5); + + // Wave 2 — light each sibling in order. + siblingPath.forEach((p, i) => { + const mesh = nodesByLayer[p.layer][p.idx]; + schedule(STEP * (i + 1), "color", mesh, COLORS.pulseSib, 1.4); + }); + + // Wave 3 — climb running-hash path. + runningPath.forEach((p, i) => { + if (p.layer === 0) return; // leaf already lit + const mesh = nodesByLayer[p.layer][p.idx]; + const isRoot = p.layer === nodesByLayer.length - 1; + const t = STEP * (siblingPath.length + 1 + i); + schedule( + t, + "color", + mesh, + isRoot ? COLORS.pulseRoot : COLORS.pulsePath, + isRoot ? 2.0 : 1.4, + ); + }); + + requestAnimationFrame(frame); + }, + + reset() { + restoreAll(); + pulseQueue = []; + proofPath = null; + highlightedLeaf = null; + render(); + }, + + onLeafSelect(callback) { + onLeafSelect = callback; + }, + + onNodeSelect(callback) { + onNodeSelect = callback; + }, + }; + + // ----- helpers ----- + + function schedule(deltaMs, kind, mesh, color, intensity) { + pulseQueue.push({ at: pulseAt + deltaMs, kind, mesh, color, intensity }); + } + + function recolor(mesh, color, intensity) { + if (!mesh) return; + mesh.material.color.setHex(color); + mesh.material.emissive.setHex(color); + mesh.material.emissiveIntensity = intensity != null ? intensity : 0.5; + } + + function restore(mesh) { + if (!mesh) return; + const c = mesh.userData.baseColor; + mesh.material.color.setHex(c); + mesh.material.emissive.setHex(c); + // baseIntensity carries the persistent provenance glow (markUsage) so + // a proof-pulse restore returns to the glow, not the flat default. + mesh.material.emissiveIntensity = + mesh.userData.baseIntensity != null ? mesh.userData.baseIntensity : 0.25; + } + + function restoreAll() { + for (const m of allNodeMeshes) restore(m); + } +} + +function lineSegment(a, b, material) { + const geom = new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(a.x, a.y, a.z), + new THREE.Vector3(b.x, b.y, b.z), + ]); + return new THREE.Line(geom, material); +} diff --git a/arborist_viz/static/viz/proof-page.js b/arborist_viz/static/viz/proof-page.js new file mode 100644 index 0000000..7c5c5ab --- /dev/null +++ b/arborist_viz/static/viz/proof-page.js @@ -0,0 +1,335 @@ +// 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 = `${label} ${chunkHex(node.hash)}`; + }); + + // 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 = `

    computing proof for leaf #${idx}…

    `; + 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 = `leaf #${idx} ${chunkHex(fullHash)}`; + } + + 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 = `

    select a leaf to compute its inclusion proof…

    `; + 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 = `no leaves in shard`; + 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 = `#${leaf.idx}${leaf.leaf_hash}` + + (leaf.used ? `used${usedTag}` : ""); + 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 = `

    error: ${proof.error}

    `; + return; + } + const passClass = proof.passed ? "passed" : "failed"; + el.innerHTML = ` +
    ${proof.passed ? "✓ PASSED" : "✗ FAILED"}
    +
    leaf #${proof.leaf_index}
    +
    leaf hash${chunkHex(proof.leaf_hash)}
    +
    siblings${proof.sibling_hashes.length}
    +
    computed root${chunkHex(proof.computed_root)}
    +
    expected root${chunkHex(proof.expected_root)}
    +
    + sibling path +
      + ${proof.sibling_hashes.map((h, i) => `
    1. ${proof.left_right_flags[i] ? "←" : "→"} ${chunkHex(h)}
    2. `).join("")} +
    +
    +
    Inclusion is integrity, not semantic truth.
    + `; +} + +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 = ` +
    question${escapeHTML(qa.question_text || "")}
    +
    answer
    ${escapeHTML(qa.answer_text || "")}
    +
    cache_key${chunkHex(qa.cache_key)}
    +
    state${escapeHTML(qa.falsification_state || "")} · hits ${qa.hit_count ?? 0}
    + `; + } + } + + if (sourcesList) { + sourcesList.innerHTML = ""; + const sources = root.sources || []; + if (!sources.length) { + sourcesList.innerHTML = `
  • no sources recorded in this QA's merkle_proof.
  • `; + } 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
      , 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 + ? ` · used${usedPtrs}` : ""; + li.innerHTML = ` + + + ${escapeHTML(s.title || s.document_uri || s.document_root.slice(0, 16))} + ${escapeHTML(s.document_uri || "")} + + role: ${escapeHTML(s.source_role || "?")} · chunk #${s.chunk_idx ?? "?"} · score ${score} · shard ${escapeHTML(s.shard || "?")}${usedTag} + + ${chunkHex(s.document_root)} + + `; + sourcesList.appendChild(li); + }); + } + } + if (ctxPanel) ctxPanel.hidden = false; +} + +function escapeHTML(s) { + return String(s ?? "").replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + })[c]); +} diff --git a/arborist_viz/static/viz/sse-client.js b/arborist_viz/static/viz/sse-client.js new file mode 100644 index 0000000..49886ba --- /dev/null +++ b/arborist_viz/static/viz/sse-client.js @@ -0,0 +1,82 @@ +// SSE client — EventSource is browser-native, no extra deps. +// Streams audit/run/root/claim/dashboard/mesh events into the sidebar. + +export function connectSSE(url, { statusEl, eventsEl }) { + setStatus(statusEl, "connecting", "dot-off"); + + const src = new EventSource(url); + + src.addEventListener("ready", (ev) => { + setStatus(statusEl, "live", "dot-on"); + appendEvent(eventsEl, "ready", ev.data); + appendAuditEvent("ready", ev.data); + }); + + src.addEventListener("heartbeat", (ev) => { + appendEvent(eventsEl, "heartbeat", ev.data); + }); + + src.addEventListener("patch", (ev) => { + appendEvent(eventsEl, "patch", ev.data); + appendAuditEvent("patch", ev.data); + // future: apply ev.data as a GraphPatch. + }); + + src.addEventListener("error", () => { + setStatus(statusEl, "reconnecting…", "dot-err"); + }); + + src.addEventListener("open", () => { + setStatus(statusEl, "live", "dot-on"); + }); + + return src; +} + +function setStatus(el, label, dotClass) { + if (!el) return; + el.innerHTML = ""; + const dot = document.createElement("span"); + dot.className = `dot ${dotClass}`; + const lbl = document.createElement("span"); + lbl.className = "lbl"; + lbl.textContent = label; + el.appendChild(dot); + el.appendChild(lbl); +} + +function appendEvent(el, name, data) { + if (!el) return; + const ts = new Date().toISOString().slice(11, 19); + const line = `[${ts}] ${name}\n${prettyJSON(data)}\n`; + el.textContent = (line + el.textContent).slice(0, 8192); +} + +function appendAuditEvent(name, data) { + const list = document.getElementById("audit-chain-live"); + if (!list) return; + if (list.firstElementChild && list.firstElementChild.classList.contains("muted")) { + list.innerHTML = ""; + } + const li = document.createElement("li"); + li.className = `evt evt-${name}`; + li.textContent = `${new Date().toISOString().slice(11, 19)} ${name} ${oneline(data)}`; + list.insertBefore(li, list.firstChild); + while (list.children.length > 50) list.removeChild(list.lastChild); +} + +function prettyJSON(s) { + try { + return JSON.stringify(JSON.parse(s), null, 2); + } catch { + return String(s); + } +} + +function oneline(s) { + try { + return JSON.stringify(JSON.parse(s)); + } catch { + return String(s); + } +} diff --git a/arborist_viz/static/viz/web-components.js b/arborist_viz/static/viz/web-components.js new file mode 100644 index 0000000..88bd3b3 --- /dev/null +++ b/arborist_viz/static/viz/web-components.js @@ -0,0 +1,221 @@ +// Phase 8 — embeddable web components. +// +// Vanilla `customElements` — no React, no build step. Drop into any host +// page that can reach this VIZ instance: +// +// +// +// +// +// +// +// +// Every card carries its own `source_roots` footer (§19.3 transparency). + +const CSS = ` +:host { display: block; font-family: ui-monospace, monospace; color: #d6e0ec; + background: #11161c; border: 1px solid #1f2934; border-radius: 6px; + padding: 0.6rem 0.8rem; font-size: 12px; } +.head { color: #4dd1c1; font-size: 11px; text-transform: uppercase; + letter-spacing: 0.08em; margin-bottom: 0.4rem; } +.row { display: grid; grid-template-columns: 7rem 1fr; gap: 0.5rem; + border-bottom: 1px solid #1f2934; padding: 0.15rem 0; min-width: 0; } +.row .k { color: #7a8ea3; } +/* Atomic identifier — Mode A (one-line scroll). See docs/style-guide.md §4.4. */ +.row .v.mono { white-space: pre; overflow-x: auto; word-break: normal; + overflow-wrap: normal; min-width: 0; } +.pass { color: #4dd1c1; font-weight: 700; } +.fail { color: #e96b6b; font-weight: 700; } +.foot { color: #5e7081; font-size: 10.5px; margin-top: 0.4rem; } +.muted { color: #5e7081; } +.err { color: #e96b6b; } +`; + +async function fetchJSON(base, path) { + const url = (base || "").replace(/\/$/, "") + path; + const res = await fetch(url); + const j = await res.json().catch(() => ({})); + if (!res.ok && !j.error) j.error = `HTTP ${res.status}`; + return j; +} + +function shadow(host) { + const root = host.attachShadow({ mode: "open" }); + const style = document.createElement("style"); + style.textContent = CSS; + root.appendChild(style); + const body = document.createElement("div"); + root.appendChild(body); + return body; +} + +function row(k, v, mono = false) { + return `
      ${k}${v}
      `; +} + +// ---------- --------------------------------- + +class ArboristProofCard extends HTMLElement { + static get observedAttributes() { return ["src", "root", "leaf"]; } + + connectedCallback() { this._render(); } + attributeChangedCallback() { if (this.isConnected) this._render(); } + + async _render() { + if (!this._body) this._body = shadow(this); + const src = this.getAttribute("src") || ""; + const root = this.getAttribute("root"); + const leaf = this.getAttribute("leaf") ?? "0"; + if (!root) { + this._body.innerHTML = `
      missing "root" attribute
      `; + return; + } + this._body.innerHTML = `
      [arborist-proof-card]
      computing…
      `; + const proof = await fetchJSON(src, `/api/proof/${root}/${leaf}`); + if (proof.error) { + this._body.innerHTML = `
      [arborist-proof-card]
      ${proof.error}
      `; + return; + } + const passCls = proof.passed ? "pass" : "fail"; + this._body.innerHTML = ` +
      [arborist-proof-card]
      +
      ${proof.passed ? "✓ PASSED" : "✗ FAILED"}
      + ${row("leaf #", proof.leaf_index)} + ${row("leaf hash", proof.leaf_hash, true)} + ${row("siblings", proof.sibling_hashes.length)} + ${row("computed", proof.computed_root, true)} + ${row("expected", proof.expected_root, true)} +
      source_roots: ${proof.source_roots.join(", ")} · inclusion is integrity, not truth
      + `; + } +} + +// ---------- -------------------------------- + +class ArboristRootViewer extends HTMLElement { + static get observedAttributes() { return ["src", "root"]; } + connectedCallback() { this._render(); } + attributeChangedCallback() { if (this.isConnected) this._render(); } + + async _render() { + if (!this._body) this._body = shadow(this); + const src = this.getAttribute("src") || ""; + const root = this.getAttribute("root"); + if (!root) { + this._body.innerHTML = `
      missing "root" attribute
      `; + return; + } + this._body.innerHTML = `
      [arborist-root-viewer]
      loading…
      `; + const r = await fetchJSON(src, `/api/root/${root}`); + if (r.error) { + this._body.innerHTML = `
      [arborist-root-viewer]
      ${r.error}
      `; + return; + } + this._body.innerHTML = ` +
      [arborist-root-viewer]
      + ${row("title", r.title || "(untitled)")} + ${row("uri", r.document_uri || "")} + ${row("root", r.root_hash, true)} + ${row("leaves", r.leaf_count)} + ${row("chunking", r.chunking_version)} + ${row("schema", r.schema_version)} +
      source_roots: ${(r.source_roots || []).join(", ")}
      + `; + } +} + +// ---------- --------------------------------- + +class ArboristClaimCard extends HTMLElement { + static get observedAttributes() { return ["src", "claim"]; } + connectedCallback() { this._render(); } + attributeChangedCallback() { if (this.isConnected) this._render(); } + + async _render() { + if (!this._body) this._body = shadow(this); + const src = this.getAttribute("src") || ""; + const claim = this.getAttribute("claim"); + if (!claim) { + this._body.innerHTML = `
      missing "claim" attribute
      `; + return; + } + const c = await fetchJSON(src, `/api/claim/${claim}`); + if (c.error) { + this._body.innerHTML = `
      [arborist-claim-card]
      ${c.error}
      `; + return; + } + this._body.innerHTML = ` +
      [arborist-claim-card]
      + ${row("claim", c.claim_id, true)} + ${row("warrant", c.warrant_level || "UNGROUNDED")} + ${row("status", c.status || "speculative")} +
      source_roots: ${(c.source_roots || []).join(", ") || "(none)"}
      + `; + } +} + +// ---------- ------------------------------------ + +class ArboristRunDag extends HTMLElement { + static get observedAttributes() { return ["src", "run"]; } + connectedCallback() { this._render(); } + attributeChangedCallback() { if (this.isConnected) this._render(); } + + async _render() { + if (!this._body) this._body = shadow(this); + const src = this.getAttribute("src") || ""; + const run = this.getAttribute("run") || "latest"; + const r = await fetchJSON(src, `/api/run/${run}`); + if (r.error) { + this._body.innerHTML = `
      [arborist-run-dag]
      ${r.error}
      `; + return; + } + const stageList = (r.stages || []).map((s) => `
    1. ${s}
    2. `).join(""); + this._body.innerHTML = ` +
      [arborist-run-dag]
      + ${row("run", r.run_id, true)} +
        ${stageList}
      +
      source_roots: ${(r.source_roots || []).join(", ") || "(none)"}
      + `; + } +} + +// ---------- ------------------------------- + +class ArboristTraceViewer extends HTMLElement { + static get observedAttributes() { return ["src", "trace"]; } + connectedCallback() { this._render(); } + attributeChangedCallback() { if (this.isConnected) this._render(); } + + async _render() { + if (!this._body) this._body = shadow(this); + const src = this.getAttribute("src") || ""; + const trace = this.getAttribute("trace") || "(none)"; + const t = await fetchJSON(src, `/api/trace/${trace}`); + // Phase 0–5 gate: arborist #000062 not landed → 501. + if (t.error === "not_implemented") { + this._body.innerHTML = ` +
      [arborist-trace-viewer]
      +
      gated on ${t.gate}
      +
      ${t.note || ""}
      + `; + return; + } + if (t.error) { + this._body.innerHTML = `
      [arborist-trace-viewer]
      ${t.error}
      `; + return; + } + this._body.innerHTML = ` +
      [arborist-trace-viewer]
      + ${row("trace", t.trace_id || trace, true)} + ${row("causal", t.causal_status || "CORRELATIONAL_TRACE")} +
      soft signals never enter the proof path
      + `; + } +} + +customElements.define("arborist-proof-card", ArboristProofCard); +customElements.define("arborist-root-viewer", ArboristRootViewer); +customElements.define("arborist-claim-card", ArboristClaimCard); +customElements.define("arborist-run-dag", ArboristRunDag); +customElements.define("arborist-trace-viewer", ArboristTraceViewer); diff --git a/arborist_viz/storage.py b/arborist_viz/storage.py new file mode 100644 index 0000000..5ee265f --- /dev/null +++ b/arborist_viz/storage.py @@ -0,0 +1,65 @@ +"""Thin shim over ``arborist.read``. + +VIZ used to do raw SQL against arborist's tables (documents, chunks, +merkle_nodes, audit_events, providence_cache) and reach into +``arborist.merkle`` / ``arborist.compress`` / ``arborist.wikitext`` +directly. That violated arborist's own contract: per +``arborist.embed``, "import from here, not from arborist's internal +modules, so internal refactors don't break embedders." + +We now go through :mod:`arborist.read` — the read-only counterpart to +``arborist.embed``. VIZ knows nothing about arborist's schema, +compression scheme, sharding plan, qa.db separation, or projection +versioning. All of that lives where it belongs. + +This module is reduced to a process-cached :class:`arborist.read.Shards` +handle keyed by the configured shard paths. +""" + +from __future__ import annotations + +import logging +from typing import Optional + +try: + from arborist.read import Shards, open_shards + HAVE_ARBORIST = True +except Exception: # pragma: no cover — fixture mode only + HAVE_ARBORIST = False + Shards = None # type: ignore + open_shards = None # type: ignore + + +log = logging.getLogger(__name__) + + +# Cache of Shards handles keyed by the tuple of configured paths so the +# Pyramid app reuses connections across requests. +_HANDLES: dict[tuple[str, ...], "Shards"] = {} + + +def get_shards(paths: list[str]) -> Optional["Shards"]: + """Return the cached :class:`arborist.read.Shards` for these paths. + + Returns ``None`` when arborist isn't importable or no paths are + configured — callers should render the "no shard" fallback state. + """ + if not HAVE_ARBORIST or not paths: + return None + key = tuple(paths) + h = _HANDLES.get(key) + if h is None: + h = open_shards(paths) + _HANDLES[key] = h + log.info("arborist.read: opened %d shard(s)", len(h.paths)) + return h + + +def close_all() -> None: + """Close every cached handle. Used by tests / shutdown hooks.""" + for h in _HANDLES.values(): + try: + h.close() + except Exception: + pass + _HANDLES.clear() diff --git a/arborist_viz/templates/audit.jinja2 b/arborist_viz/templates/audit.jinja2 new file mode 100644 index 0000000..6d79836 --- /dev/null +++ b/arborist_viz/templates/audit.jinja2 @@ -0,0 +1,53 @@ +{% extends "base.jinja2" %} + +{% block body %} +
      + + +
      +
      +
      + [audit-event] + Event detail +
      +
      +
      loading…
      +
      +
      + +
      +
      + [audit-chain] + Walk backwards from this event +
      +
      +
        +
        +
        +
        +
        +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/arborist_viz/templates/base.jinja2 b/arborist_viz/templates/base.jinja2 new file mode 100644 index 0000000..edd1e72 --- /dev/null +++ b/arborist_viz/templates/base.jinja2 @@ -0,0 +1,70 @@ + + + + + + {{ title or 'Arborist VIZ' }} + + + {% block head_extra %}{% endblock %} + + +
        +
        + arborist VIZ + · + Merkle Command Center +
        + +
        + + + +
        + +
        + + shards: {{ shards|length if shards else 0 }} + + + roots: {{ "{:,}".format(shard_stats.docs) if shard_stats else 0 }} + + + audit: {{ "{:,}".format(shard_stats.audit_events) if shard_stats else 0 }} + + + private: {{ 'reveal' if reveal_private else 'deny' }} + + {{ request.git_commit }} +
        +
        + +
        + {% block body %}{% endblock %} +
        + +
        + ticket #000069 — Arborist VIZ / Merkle Command Center + · + state projected, never asserted + · + soft signals never enter the proof path +
        + + + {% block scripts %}{% endblock %} + + diff --git a/arborist_viz/templates/dashboard.jinja2 b/arborist_viz/templates/dashboard.jinja2 new file mode 100644 index 0000000..a0a9be6 --- /dev/null +++ b/arborist_viz/templates/dashboard.jinja2 @@ -0,0 +1,59 @@ +{% extends "base.jinja2" %} + +{% block body %} +
        + + +
        + +
        +
        + loading dashboard… +
        +
        +

        Fetching /api/dashboards/{{ dashboard_id }}

        +
        +
        +
        +
        +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/arborist_viz/templates/proof.jinja2 b/arborist_viz/templates/proof.jinja2 new file mode 100644 index 0000000..88e43aa --- /dev/null +++ b/arborist_viz/templates/proof.jinja2 @@ -0,0 +1,95 @@ +{% extends "base.jinja2" %} + +{% block body %} +
        + + + +
        +
        + [merkle-lattice-3d] + loading… + + + + leaves + interior + root + selected + +
        +
        +
        click any node — leaves compute an inclusion proof, interior + root reveal their hash here.
        + +
        + leaves load with the tree… +
        + + + +
        +
        +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/arborist_viz/templates/widgets/audit_timeline.jinja2 b/arborist_viz/templates/widgets/audit_timeline.jinja2 new file mode 100644 index 0000000..f7f6271 --- /dev/null +++ b/arborist_viz/templates/widgets/audit_timeline.jinja2 @@ -0,0 +1,17 @@ +
        +
        + [audit-timeline] + {{ widget.title }} +
        +
        +
          +
        1. waiting for SSE events on /sse/audit/live…
        2. +
        +
        +
        +
        + query + source roots +
        {{ widget.query | tojson(indent=2) }}
        +
        +
        +
        diff --git a/arborist_viz/templates/widgets/claim_warrant.jinja2 b/arborist_viz/templates/widgets/claim_warrant.jinja2 new file mode 100644 index 0000000..72e0559 --- /dev/null +++ b/arborist_viz/templates/widgets/claim_warrant.jinja2 @@ -0,0 +1,22 @@ +
        +
        + [claim-warrant] + {{ widget.title }} +
        +
        +
          +
        • UNGROUNDED
        • +
        • POINTER_LINKED
        • +
        • ANCHOR_WARRANTED
        • +
        • EVIDENCE_WARRANTED
        • +
        • FORMALLY_PROVED
        • +
        +

        claim warrant levels — render real claims once shards land.

        +
        +
        +
        + query + source roots +
        {{ widget.query | tojson(indent=2) }}
        +
        +
        +
        diff --git a/arborist_viz/templates/widgets/root_explorer.jinja2 b/arborist_viz/templates/widgets/root_explorer.jinja2 new file mode 100644 index 0000000..44007bd --- /dev/null +++ b/arborist_viz/templates/widgets/root_explorer.jinja2 @@ -0,0 +1,18 @@ +
        +
        + [root-explorer] + {{ widget.title }} +
        +
        + + + +
        rootleavescreated
        waiting on arborist shard wiring
        +
        +
        +
        + query + source roots +
        {{ widget.query | tojson(indent=2) }}
        +
        +
        +
        diff --git a/arborist_viz/templates/widgets/run_dag.jinja2 b/arborist_viz/templates/widgets/run_dag.jinja2 new file mode 100644 index 0000000..a1ef28c --- /dev/null +++ b/arborist_viz/templates/widgets/run_dag.jinja2 @@ -0,0 +1,21 @@ +
        +
        + [run-dag] + {{ widget.title }} +
        +
        +
          +
        1. Question
        2. Preflight
        3. Retrieval
        4. +
        5. EvidenceMap
        6. AnswerDraft
        7. ClaimExtraction
        8. +
        9. Verifier
        10. FalsifierSearch
        11. CacheWrite
        12. +
        13. AuditAppend
        14. StateAdmission
        15. +
        +

        run pipeline stages from ticket §6.5 — replay once a run is loaded.

        +
        +
        +
        + query + source roots +
        {{ widget.query | tojson(indent=2) }}
        +
        +
        +
        diff --git a/arborist_viz/tests/__init__.py b/arborist_viz/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/arborist_viz/tests/conftest.py b/arborist_viz/tests/conftest.py new file mode 100644 index 0000000..5f74b2b --- /dev/null +++ b/arborist_viz/tests/conftest.py @@ -0,0 +1,77 @@ +"""Shared fixtures — seed a temp arborist shard so endpoints have real data.""" + +from __future__ import annotations + +import os +import tempfile + +import pytest +from pyramid.paster import get_app +from webtest import TestApp + + +@pytest.fixture(scope="session") +def demo_shard(tmp_path_factory): + """Seed a small arborist shard once per test session.""" + try: + from arborist.embed import open_store, ingest_documents, Document + except Exception: + pytest.skip("arborist library not installed; integration tests skipped") + + db_path = str(tmp_path_factory.mktemp("shard") / "demo.db") + conn = open_store(db_path) + ingest_documents( + conn, + [ + Document( + uri="https://test.local/doc1", + title="Doc 1", + source_type="test", + content="One. Two. Three. Four. Five. Six. Seven sentences make a tree with several leaves.", + ), + Document( + uri="https://test.local/doc2", + title="Doc 2", + source_type="test", + content="Alpha. Beta. Gamma. Delta.", + ), + ], + source_type="test", + chunker_name="sent-v1", + ) + conn.close() + return db_path + + +@pytest.fixture(scope="session") +def demo_facts(demo_shard): + """Pull a known (root, leaf_hash, leaf_index, event_hash) tuple out of the shard.""" + import sqlite3 + c = sqlite3.connect(demo_shard) + root = c.execute("SELECT document_root FROM documents ORDER BY ingest_ts LIMIT 1").fetchone()[0] + leaf = c.execute("SELECT leaf_hash, idx FROM chunks WHERE document_root=? ORDER BY idx LIMIT 1", (root,)).fetchone() + event = c.execute("SELECT event_hash FROM audit_events WHERE subject_root=? ORDER BY seq LIMIT 1", (root,)).fetchone() + c.close() + return { + "root": root, + "leaf_hash": leaf[0], + "leaf_index": leaf[1], + "event_hash": event[0], + } + + +@pytest.fixture(scope="session") +def app(demo_shard): + """WSGI app pointed at the demo shard via env var.""" + os.environ["ARBORIST_VIZ_SHARDS"] = demo_shard + wsgi = get_app("development.ini") + return TestApp(wsgi) + + +@pytest.fixture(scope="session") +def app_no_shard(): + """WSGI app with NO shard — exercises the fallback path.""" + os.environ.pop("ARBORIST_VIZ_SHARDS", None) + # Force a fresh app build by clearing any cached env. + wsgi = get_app("development.ini") + return TestApp(wsgi) diff --git a/arborist_viz/tests/test_schemas.py b/arborist_viz/tests/test_schemas.py new file mode 100644 index 0000000..d4eb9a9 --- /dev/null +++ b/arborist_viz/tests/test_schemas.py @@ -0,0 +1,85 @@ +"""Schema invariants — ticket hard rules baked into the dataclasses.""" + +from __future__ import annotations + +from arborist_viz.schemas import ( + ActivationHookpoint, + ActivationTrace, + CausalStatus, + DashboardConfig, + EdgeKind, + NodeKind, + NodeStatus, + PanelLayout, + ProofPath, + QuerySpec, + ViewSpec, + VizEdge, + VizNode, + WarrantLevel, + WidgetSpec, + WidgetType, +) + + +def test_proof_passed_property(): + p = ProofPath( + root_hash="r", + leaf_index=0, + leaf_hash="l", + canonical_bytes_hash="c", + sibling_hashes=[], + left_right_flags=[], + computed_root="r", + expected_root="r", + domain_separator="d", + canonicalization_version="1", + chunking_version="1", + schema_version="1", + verifier_policy_hash="v", + ) + assert p.passed is True + + p.computed_root = "different" + assert p.passed is False + + +def test_causal_status_defaults_to_correlational(): + """Ticket §11 — never causal unless intervention or ablation evidence exists.""" + trace = ActivationTrace( + trace_id="t1", + run_id="r1", + model_id="m1", + model_profile_hash="h1", + prompt_hash="p1", + token_span=(0, 1), + hookpoints=[], + trace_root="tr1", + ) + assert trace.causal_status == CausalStatus.CORRELATIONAL_TRACE + + +def test_widget_spec_carries_source_roots(): + """§19.3 — every widget exposes the roots it derived from.""" + w = WidgetSpec( + widget_id="w1", + type=WidgetType.ROOT_EXPLORER, + title="t", + data_source_id="ds1", + query=QuerySpec(select=["root_hash"], from_="roots"), + view=ViewSpec(renderer="table"), + source_roots=["abc"], + ) + assert w.source_roots == ["abc"] + + +def test_node_status_includes_quarantine_states(): + # arborist #000049 self-provenance quarantine must round-trip. + assert NodeStatus.SELF_PROVENANCE_QUARANTINED.value == "self_provenance_quarantined" + assert NodeStatus.QUARANTINED.value == "quarantined" + + +def test_warrant_ladder_is_ordered_strings(): + levels = [w.value for w in WarrantLevel] + assert levels[0] == "UNGROUNDED" + assert levels[-1] == "FORMALLY_PROVED" diff --git a/arborist_viz/tests/test_smoke.py b/arborist_viz/tests/test_smoke.py new file mode 100644 index 0000000..3c8ecbe --- /dev/null +++ b/arborist_viz/tests/test_smoke.py @@ -0,0 +1,237 @@ +"""End-to-end smoke tests against a real arborist demo shard.""" + +from __future__ import annotations + + +# ---------- meta + home ------------------------------------------------ + + +def test_version_endpoint(app): + res = app.get("/version") + assert res.status_code == 200 + assert res.json["service"] == "arborist-viz" + assert "git_commit" in res.json + + +def test_home_renders(app): + res = app.get("/") + assert res.status_code == 200 + assert b"arborist VIZ" in res.body + assert b"Merkle Command Center" in res.body + assert b"hash-drop" in res.body # hash-drop bar always present + + +def test_robots(app): + res = app.get("/robots.txt") + assert res.status_code == 200 + + +# ---------- dashboards ------------------------------------------------ + + +def test_dashboards_list(app): + res = app.get("/api/dashboards") + assert res.status_code == 200 + ids = {d["id"] for d in res.json["dashboards"]} + assert {"default", "graveyard", "proof"} <= ids + + +def test_canned_dashboards_have_source_roots(app): + for dashboard_id in ("default", "graveyard", "proof"): + res = app.get(f"/api/dashboards/{dashboard_id}") + assert res.status_code == 200 + cfg = res.json + assert cfg["id"] == dashboard_id + for w in cfg["widgets"]: + assert "source_roots" in w + assert "query" in w + + +def test_proof_dashboard_includes_lattice_widget(app): + res = app.get("/api/dashboards/proof") + types = {w["type"] for w in res.json["widgets"]} + assert "merkle-lattice-3d" in types + assert "merkle-proof" in types + + +def test_create_and_delete_dashboard(app): + body = {"id": "test-fresh", "title": "Test", "widgets": []} + res = app.post_json("/api/dashboards", body) + assert res.status_code == 200 + assert res.json["created"]["id"] == "test-fresh" + + res2 = app.get("/api/dashboards/test-fresh") + assert res2.status_code == 200 + + res3 = app.delete("/api/dashboards/test-fresh") + assert res3.status_code == 200 + + +# ---------- roots / leaves / lattice ---------------------------------- + + +def test_root_endpoint(app, demo_facts): + res = app.get(f"/api/root/{demo_facts['root']}") + assert res.status_code == 200 + assert res.json["root_hash"] == demo_facts["root"] + assert res.json["leaf_count"] >= 1 + assert res.json["source_roots"] == [demo_facts["root"]] + + +def test_root_not_found(app): + res = app.get("/api/root/" + "0" * 64, expect_errors=True) + assert res.status_code == 404 + + +def test_leaves_reveal_in_dev(app, demo_facts): + """development.ini sets privacy.reveal_private_leaves=true so the + operator can inspect their own corpus. production.ini keeps the + default-deny posture per §14 — see test_production_ini_is_deny.""" + res = app.get(f"/api/root/{demo_facts['root']}/leaves") + assert res.status_code == 200 + assert res.json["private_revealed"] is True + # At least one leaf must carry content under reveal mode. + assert any(leaf["content"] for leaf in res.json["leaves"]) + + +def test_production_ini_is_deny(): + """production.ini must remain fail-closed for §14.""" + import re + with open("production.ini") as f: + text = f.read() + m = re.search(r"privacy\.reveal_private_leaves\s*=\s*(\S+)", text) + assert m, "production.ini must set privacy.reveal_private_leaves" + assert m.group(1).lower() == "false", "production.ini must keep private leaves deny by default" + + +def test_lattice_returns_layers(app, demo_facts): + res = app.get(f"/api/lattice/{demo_facts['root']}") + assert res.status_code == 200 + assert res.json["layer_count"] >= 1 + assert res.json["leaf_count"] >= 1 + # Root layer always has exactly one node. + assert len(res.json["layers"][-1]) == 1 + assert res.json["layers"][-1][0] == demo_facts["root"] + + +# ---------- proof ------------------------------------------------------ + + +def test_proof_passes_against_arborist_merkle(app, demo_facts): + res = app.get(f"/api/proof/{demo_facts['root']}/{demo_facts['leaf_index']}") + assert res.status_code == 200 + assert res.json["passed"] is True + assert res.json["computed_root"] == demo_facts["root"] + assert res.json["leaf_hash"] == demo_facts["leaf_hash"] + + +def test_proof_leaf_out_of_range(app, demo_facts): + res = app.get(f"/api/proof/{demo_facts['root']}/9999", expect_errors=True) + assert res.status_code == 400 + + +def test_proof_rejects_non_integer(app, demo_facts): + res = app.get(f"/api/proof/{demo_facts['root']}/notanint", expect_errors=True) + assert res.status_code == 400 + + +# ---------- audit ------------------------------------------------------ + + +def test_audit_event(app, demo_facts): + res = app.get(f"/api/audit/{demo_facts['event_hash']}") + assert res.status_code == 200 + assert res.json["event_hash"] == demo_facts["event_hash"] + assert res.json["subject_root"] == demo_facts["root"] + + +def test_audit_chain(app, demo_facts): + res = app.get(f"/api/audit/chain/{demo_facts['event_hash']}") + assert res.status_code == 200 + assert res.json["head_event_hash"] == demo_facts["event_hash"] + + +def test_audit_recent(app): + res = app.get("/api/audit/recent?limit=10") + assert res.status_code == 200 + assert res.json["count"] >= 1 + + +# ---------- hash resolver --------------------------------------------- + + +def test_resolve_document_root(app, demo_facts): + res = app.get(f"/api/resolve/{demo_facts['root']}") + assert res.status_code == 200 + assert res.json["kind"] == "document_root" + assert res.json["url"] == f"/proof/{demo_facts['root']}" + + +def test_resolve_leaf_hash(app, demo_facts): + res = app.get(f"/api/resolve/{demo_facts['leaf_hash']}") + assert res.status_code == 200 + assert res.json["kind"] == "leaf_hash" + assert res.json["document_root"] == demo_facts["root"] + assert res.json["leaf_index"] == demo_facts["leaf_index"] + + +def test_resolve_audit_event(app, demo_facts): + res = app.get(f"/api/resolve/{demo_facts['event_hash']}") + assert res.status_code == 200 + assert res.json["kind"] == "audit_event" + + +def test_resolve_unknown(app): + res = app.get("/api/resolve/" + "0" * 64) + assert res.json["kind"] == "unknown" + + +def test_resolve_not_hex(app, demo_facts): + res = app.get("/api/resolve/not_hex_at_all_g!", expect_errors=True) + assert res.status_code == 400 + + +# ---------- proof page render ----------------------------------------- + + +def test_proof_page_renders(app, demo_facts): + res = app.get(f"/proof/{demo_facts['root']}") + assert res.status_code == 200 + assert demo_facts["root"].encode() in res.body + assert b"lattice-canvas" in res.body + assert b"mountProofPage" in res.body + + +def test_audit_page_renders(app, demo_facts): + res = app.get(f"/audit/{demo_facts['event_hash']}") + assert res.status_code == 200 + assert demo_facts["event_hash"].encode() in res.body + + +# ---------- #000062 gate ---------------------------------------------- + + +def test_trace_gate_returns_501(app): + res = app.get("/api/trace/t1", expect_errors=True) + assert res.status_code == 501 + + +def test_circuit_gate_returns_501(app): + res = app.get("/api/circuit/c1", expect_errors=True) + assert res.status_code == 501 + + +# ---------- diff ------------------------------------------------------- + + +def test_root_diff(app, demo_facts): + # demo shard has two docs; diff them. + res_roots = app.get("/api/roots") + assert res_roots.status_code == 200 + roots = [r["document_root"] for r in res_roots.json["roots"]] + assert len(roots) >= 2 + res = app.get(f"/api/diff/{roots[0]}/{roots[1]}") + assert res.status_code == 200 + assert res.json["root_a"] == roots[0] + assert res.json["root_b"] == roots[1] + assert res.json["source_roots"] == [roots[0], roots[1]] diff --git a/arborist_viz/views/__init__.py b/arborist_viz/views/__init__.py new file mode 100644 index 0000000..11ec815 --- /dev/null +++ b/arborist_viz/views/__init__.py @@ -0,0 +1,3 @@ +"""Pyramid views. Each module declares its own ``@view_config``s; the +top-level app factory's ``config.scan('arborist_viz.views')`` picks them up. +""" diff --git a/arborist_viz/views/audit.py b/arborist_viz/views/audit.py new file mode 100644 index 0000000..66f350b --- /dev/null +++ b/arborist_viz/views/audit.py @@ -0,0 +1,276 @@ +"""Audit event + chain endpoints (ticket §6.6, §13). + +All access goes through ``arborist.read.Shards`` via ``request.shards``. +VIZ never touches arborist's tables, columns, or helper modules directly. +""" + +from __future__ import annotations + +from pyramid.response import Response +from pyramid.view import view_config + + +def _json(body, status=200) -> Response: + return Response(json_body=body, status=status) + + +def _no_shard() -> Response: + return _json( + {"error": "no_shard", "note": "configure arborist.shards"}, status=503 + ) + + +def _audit_to_json(ev) -> dict: + """Flatten an ``arborist.read.AuditEvent`` to the VIZ JSON shape.""" + return { + "seq": ev.seq, + "event_hash": ev.event_hash, + "prev_event_hash": ev.prev_event_hash, + "event_type": ev.event_type, + "subject_root": ev.subject_root, + "body": ev.body, + "ts": ev.ts, + "shard": ev.shard_path, + } + + +@view_config(route_name="api_audit_event", request_method="GET") +def get_audit_event(request): + if request.shards is None: + return _no_shard() + event_hash = request.matchdict["event_hash"] + ev = request.shards.audit_event(event_hash) + if ev is None: + return _json({"error": "not_found", "event_hash": event_hash}, status=404) + return _json( + { + **_audit_to_json(ev), + "source_roots": [ev.subject_root] if ev.subject_root else [], + } + ) + + +@view_config(route_name="api_audit_chain", request_method="GET") +def get_audit_chain(request): + if request.shards is None: + return _no_shard() + event_hash = request.matchdict["event_hash"] + try: + limit = int(request.GET.get("limit", "100")) + except ValueError: + limit = 100 + events = request.shards.audit_chain(event_hash, limit=limit) + return _json( + { + "head_event_hash": event_hash, + "limit": limit, + "events": [_audit_to_json(e) for e in events], + "source_roots": list({e.subject_root for e in events if e.subject_root}), + } + ) + + +@view_config(route_name="api_audit_by_root", request_method="GET") +def get_audit_by_root(request): + """Audit events whose subject_root matches — uses the indexed lookup.""" + if request.shards is None: + return _no_shard() + root_hash = request.matchdict["root_hash"] + try: + limit = int(request.GET.get("limit", "50")) + except ValueError: + limit = 50 + events = request.shards.audit_by_root(root_hash, limit=limit) + return _json( + { + "root_hash": root_hash, + "limit": limit, + "count": len(events), + "events": [_audit_to_json(e) for e in events], + "source_roots": [root_hash], + } + ) + + +@view_config(route_name="api_audit_recent", request_method="GET") +def get_audit_recent(request): + """Recent audit events across every configured shard.""" + if request.shards is None: + return _no_shard() + try: + limit = int(request.GET.get("limit", "100")) + except ValueError: + limit = 100 + events = request.shards.audit_recent(limit=limit) + return _json( + { + "limit": limit, + "count": len(events), + "events": [_audit_to_json(e) for e in events], + "source_roots": list({e.subject_root for e in events if e.subject_root}), + } + ) + + +@view_config(route_name="api_run", request_method="GET") +def get_run(request): + """Run-DAG — arborist's ingest pipeline stages. + + Phase 0 returned a static stage list; with shard wiring we surface the + canonical stages from §6.5 plus the most recent ingest's audit event + as the visualized run. + """ + run_id = request.matchdict["run_id"] + stages = [ + "Question", "Preflight", "Retrieval", "EvidenceMap", "AnswerDraft", + "ClaimExtraction", "Verifier", "FalsifierSearch", "CacheWrite", + "AuditAppend", "StateAdmission", + ] + latest_event = None + if request.shards is not None: + events = request.shards.audit_recent(limit=1) + if events: + latest_event = _audit_to_json(events[0]) + return _json( + { + "run_id": run_id, + "stages": stages, + "edges": [{"source": stages[i], "target": stages[i + 1]} for i in range(len(stages) - 1)], + "latest_audit_event": latest_event, + "source_roots": [latest_event["subject_root"]] if latest_event and latest_event.get("subject_root") else [], + } + ) + + +@view_config(route_name="api_cache", request_method="GET") +def get_cache_entry(request): + """Look up one Q&A record by its cache_key (hex hash). + + Source layout (single file vs separate provenance shard, etc.) is an + arborist internal — VIZ asks the read seam and trusts what comes back. + """ + if request.shards is None: + return _no_shard() + cache_key = request.matchdict["cache_key"] + qa = request.shards.qa(cache_key) + if qa is None: + return _json( + {"error": "not_found", "cache_key": cache_key, "source_roots": []}, + status=404, + ) + return _json( + { + "cache_key": qa.cache_key, + "source_root": qa.source_root, + "document_uri": qa.document_uri, + "question_hash": qa.question_hash, + "question_text": qa.question_text, + "answer_text": qa.answer_text, + "model_profile_hash": qa.model_profile_hash, + "conversation_hash": qa.conversation_hash, + "governance_policy_hash": qa.governance_policy_hash, + "schema_version": qa.schema_version, + "canonicalization_version": qa.canonicalization_version, + "chunking_version": qa.chunking_version, + "falsification_state": qa.falsification_state, + "audit_mode": qa.audit_mode, + "audit_event_hash": qa.audit_event_hash, + "run_dag_root": qa.run_dag_root, + "created_at": qa.created_at, + "last_hit_at": qa.last_hit_at, + "hit_count": qa.hit_count, + "n_quotes": qa.n_quotes, + "n_verified": qa.n_verified, + "verifier_method": qa.verifier_method, + "merkle_proof": qa.merkle_proof, + "run_dag_blob": qa.run_dag_blob, + "shard": qa.shard_path, + "source_roots": [qa.source_root] if qa.source_root else [], + } + ) + + +@view_config(route_name="api_fork", request_method="GET") +def get_fork(request): + fork_id = request.matchdict["fork_id"] + return _json({"fork_id": fork_id, "score": None, "source_roots": [], "note": "fork projection not wired in this phase."}) + + +@view_config(route_name="api_diff", request_method="GET") +def get_diff(request): + """Root diff — leaves added/removed between two roots (across all shards).""" + if request.shards is None: + return _no_shard() + root_a = request.matchdict["root_a"] + root_b = request.matchdict["root_b"] + if request.shards.root(root_a) is None or request.shards.root(root_b) is None: + return _json({"error": "not_found", "root_a": root_a, "root_b": root_b}, status=404) + # Diff cares only about leaf hashes — skip wikitext projection and + # never decompress private content. + leaves_a = {L.leaf_hash: L.idx for L in request.shards.leaves(root_a, project=False)} + leaves_b = {L.leaf_hash: L.idx for L in request.shards.leaves(root_b, project=False)} + added = sorted(set(leaves_b) - set(leaves_a)) + removed = sorted(set(leaves_a) - set(leaves_b)) + common = sorted(set(leaves_a) & set(leaves_b)) + return _json( + { + "root_a": root_a, + "root_b": root_b, + "added_leaves": added, + "removed_leaves": removed, + "shared_leaves": common, + "leaf_count_a": len(leaves_a), + "leaf_count_b": len(leaves_b), + "source_roots": [root_a, root_b], + } + ) + + +@view_config(route_name="api_graph_snapshot", request_method="GET") +def get_graph_snapshot(request): + if request.shards is None: + return _no_shard() + roots = request.shards.roots(limit=200) + nodes = [{"id": r.document_root, "kind": "document_root", "label": r.title or r.document_uri} for r in roots] + return _json( + { + "seq": request.shards.counts().audit_events, + "nodes": nodes, + "edges": [], + "source_roots": [r.document_root for r in roots], + } + ) + + +# ---- #000062 gate ------------------------------------------------------- + + +@view_config(route_name="api_trace", request_method="GET") +def get_trace(request): + trace_id = request.matchdict["trace_id"] + return Response( + json_body={ + "error": "not_implemented", + "trace_id": trace_id, + "gate": "arborist #000062 — MechanisticWitnessRoot", + "note": ( + "Activation/circuit traces only displayable once arborist's " + "MechanisticWitnessRoot lands. Soft signals must never enter " + "the proof path." + ), + }, + status=501, + ) + + +@view_config(route_name="api_circuit", request_method="GET") +def get_circuit(request): + circuit_id = request.matchdict["circuit_id"] + return Response( + json_body={ + "error": "not_implemented", + "circuit_id": circuit_id, + "gate": "arborist #000062 — MechanisticWitnessRoot", + }, + status=501, + ) diff --git a/arborist_viz/views/claims.py b/arborist_viz/views/claims.py new file mode 100644 index 0000000..8c3af12 --- /dev/null +++ b/arborist_viz/views/claims.py @@ -0,0 +1,81 @@ +"""Claim, claim-neighborhood, graveyard endpoints (ticket §6.3, §6.4, §6.9). + +#000059 coupling: the graveyard widget is one UI projection of the +bounded-ingestion graveyard. The Phase 0 stub honors that by labeling +the surface as "recurring-error" rather than "queries-ever". +""" + +from __future__ import annotations + +import json + +from pyramid.response import Response +from pyramid.view import view_config + + +def _json(body) -> Response: + return Response(json_body=body) + + +@view_config(route_name="api_claim", request_method="GET") +def get_claim(request): + claim_id = request.matchdict["claim_id"] + return _json( + { + "claim_id": claim_id, + "canonical_claim": None, + "evidence_ids": [], + "source_roots": [], + "warrant_level": "UNGROUNDED", + "falsification_state": None, + "verifier_policy_hash": None, + "parent_claims": [], + "child_claims": [], + "counterclaims": [], + "status": "speculative", + "note": "Phase 0 stub.", + } + ) + + +@view_config(route_name="api_claim_neighborhood", request_method="GET") +def get_claim_neighborhood(request): + claim_id = request.matchdict["claim_id"] + try: + depth = int(request.GET.get("depth", "2")) + except ValueError: + depth = 2 + return _json( + { + "root_claim": claim_id, + "depth": depth, + "nodes": [], + "edges": [], + "source_roots": [], + "note": "Phase 0 stub.", + } + ) + + +@view_config(route_name="api_graveyard_entry", request_method="GET") +def get_graveyard_entry(request): + claim_hash = request.matchdict["claim_hash"] + # #000059 hard constraint: surface recurring-error class, not raw + # query transcripts. We expose hashes + failure_class only. + return _json( + { + "failed_claim_hash": claim_hash, + "failure_class": None, + "falsifier_ids": [], + "defeating_evidence_ids": [], + "affected_descendants": [], + "created_at": None, + "reopen_conditions": [], + "appeal_status": None, + "source_roots": [], + "note": ( + "Phase 0 stub. Per arborist #000059 bounded-ingestion: graveyard " + "exposes recurring-error class, not query transcripts." + ), + } + ) diff --git a/arborist_viz/views/dashboards.py b/arborist_viz/views/dashboards.py new file mode 100644 index 0000000..e1a1de9 --- /dev/null +++ b/arborist_viz/views/dashboards.py @@ -0,0 +1,244 @@ +"""Saved-dashboard metadata endpoints. + +Phase 1: backed by SQLAlchemy. The three canned configs (Audit +Operations, Claim Graveyard, Merkle Root Proof) are upserted on first +access so the UI always has a starter catalog. +""" + +from __future__ import annotations + +import json + +from pyramid.httpexceptions import HTTPNotFound +from pyramid.response import Response +from pyramid.view import view_config +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from ..models import Base, Dashboard + + +_engine = None +_Session = None + + +def _session_factory(settings): + global _engine, _Session + if _engine is None: + _engine = create_engine(settings["sqlalchemy.url"], future=True) + Base.metadata.create_all(_engine) + _Session = sessionmaker(bind=_engine, expire_on_commit=False, future=True) + return _Session + + +# Canned starter catalog. Upserted on first access so the demo always +# has something to display even before fox saves their own layouts. +CANNED: dict[str, dict] = { + "default": { + "id": "default", + "title": "Audit Operations View", + "description": "Operator default — recent audit chain, live claim status, root explorer.", + "version": "dashboard-v1", + "layout": [ + {"panel_id": "root", "x": 0, "y": 0, "w": 4, "h": 4, "dock": "left"}, + {"panel_id": "audit", "x": 4, "y": 0, "w": 8, "h": 4, "dock": "main"}, + {"panel_id": "claim", "x": 0, "y": 4, "w": 6, "h": 4}, + {"panel_id": "run", "x": 6, "y": 4, "w": 6, "h": 4}, + ], + "widgets": [ + { + "widget_id": "root", + "type": "root-explorer", + "title": "Root Explorer", + "data_source_id": "shards", + "query": {"select": ["root_hash", "leaf_count", "ingest_ts"], "from": "documents", "limit": 50}, + "view": {"renderer": "table", "options": {}}, + "source_roots": [], + }, + { + "widget_id": "audit", + "type": "audit-timeline", + "title": "Audit Timeline", + "data_source_id": "shards", + "query": {"select": ["event_hash", "event_type", "ts"], "from": "audit_events", "limit": 200}, + "view": {"renderer": "timeline", "options": {}}, + "source_roots": [], + }, + { + "widget_id": "claim", + "type": "claim-warrant", + "title": "Claim Warrants", + "data_source_id": "shards", + "query": {"select": ["claim_id", "warrant", "status"], "from": "claims", "limit": 100}, + "view": {"renderer": "table", "options": {}}, + "source_roots": [], + }, + { + "widget_id": "run", + "type": "run-dag", + "title": "Latest Ingest Run", + "data_source_id": "shards", + "query": {"select": ["run_id", "stage", "status"], "from": "runs", "limit": 1}, + "view": {"renderer": "dag", "options": {}}, + "source_roots": [], + }, + ], + "data_sources": [ + {"id": "shards", "kind": "arborist_shard", "locator": "default", "read_only": True} + ], + "global_filters": [], + "interactions": [], + }, + "graveyard": { + "id": "graveyard", + "title": "Claim Graveyard View", + "description": "Negative memory — failed/stale/quarantined claims plus their falsifiers (#000059 projection).", + "version": "dashboard-v1", + "layout": [{"panel_id": "gv", "x": 0, "y": 0, "w": 12, "h": 8, "dock": "main"}], + "widgets": [ + { + "widget_id": "gv", + "type": "claim-graveyard", + "title": "Claim Graveyard", + "data_source_id": "shards", + "query": {"select": ["failed_claim_hash", "failure_class", "created_at"], "from": "graveyard", "limit": 500}, + "view": {"renderer": "table", "options": {}}, + "source_roots": [], + } + ], + "data_sources": [{"id": "shards", "kind": "arborist_shard", "locator": "default", "read_only": True}], + }, + "proof": { + "id": "proof", + "title": "Merkle Root Proof View", + "description": "Single-root deep dive — inclusion proofs, leaves, audit linkage, 3D lattice.", + "version": "dashboard-v1", + "layout": [ + {"panel_id": "root", "x": 0, "y": 0, "w": 4, "h": 3}, + {"panel_id": "lattice", "x": 4, "y": 0, "w": 8, "h": 6}, + {"panel_id": "proof", "x": 0, "y": 3, "w": 4, "h": 3}, + ], + "widgets": [ + { + "widget_id": "root", + "type": "root-explorer", + "title": "Root", + "data_source_id": "shards", + "query": {"select": ["*"], "from": "documents", "limit": 1}, + "view": {"renderer": "card", "options": {}}, + "source_roots": [], + }, + { + "widget_id": "lattice", + "type": "merkle-lattice-3d", + "title": "3D Merkle Lattice", + "data_source_id": "shards", + "query": {"select": ["layers"], "from": "merkle_tree", "limit": 1}, + "view": {"renderer": "three-d", "options": {"animate_proof_path": True}}, + "source_roots": [], + }, + { + "widget_id": "proof", + "type": "merkle-proof", + "title": "Inclusion Proof", + "data_source_id": "shards", + "query": {"select": ["proof_path"], "from": "proofs", "limit": 1}, + "view": {"renderer": "card", "options": {}}, + "source_roots": [], + }, + ], + "data_sources": [{"id": "shards", "kind": "arborist_shard", "locator": "default", "read_only": True}], + }, +} + + +def _upsert_canned(session_factory): + """Insert the canned catalog if it isn't already there.""" + Session = session_factory + with Session() as s: + for cfg in CANNED.values(): + existing = s.get(Dashboard, cfg["id"]) + if existing is None: + s.add(Dashboard( + id=cfg["id"], title=cfg["title"], description=cfg.get("description"), + version=cfg.get("version", "dashboard-v1"), config_json=cfg, + )) + s.commit() + + +def _json(body, status=200) -> Response: + return Response(json_body=body, status=status) + + +@view_config(route_name="api_dashboards", request_method="GET") +def list_dashboards(request): + Session = _session_factory(request.registry.settings) + _upsert_canned(Session) + with Session() as s: + rows = s.query(Dashboard).order_by(Dashboard.id).all() + return _json({"dashboards": [r.config_json for r in rows]}) + + +@view_config(route_name="api_dashboards", request_method="POST") +def create_dashboard(request): + try: + body = request.json_body + except Exception: + return _json({"error": "invalid JSON"}, status=400) + if not body.get("id") or not body.get("title"): + return _json({"error": "id and title required"}, status=400) + Session = _session_factory(request.registry.settings) + with Session() as s: + if s.get(Dashboard, body["id"]): + return _json({"error": "exists", "id": body["id"]}, status=409) + d = Dashboard( + id=body["id"], title=body["title"], description=body.get("description"), + version=body.get("version", "dashboard-v1"), config_json=body, + ) + s.add(d) + s.commit() + return _json({"created": body}) + + +@view_config(route_name="api_dashboard", request_method="GET") +def get_dashboard(request): + dashboard_id = request.matchdict["dashboard_id"] + Session = _session_factory(request.registry.settings) + _upsert_canned(Session) + with Session() as s: + d = s.get(Dashboard, dashboard_id) + if d is None: + raise HTTPNotFound(json_body={"error": "unknown dashboard", "id": dashboard_id}) + return _json(d.config_json) + + +@view_config(route_name="api_dashboard", request_method="PUT") +def update_dashboard(request): + dashboard_id = request.matchdict["dashboard_id"] + try: + body = request.json_body + except Exception: + return _json({"error": "invalid JSON"}, status=400) + Session = _session_factory(request.registry.settings) + with Session() as s: + d = s.get(Dashboard, dashboard_id) + if d is None: + raise HTTPNotFound(json_body={"error": "unknown dashboard", "id": dashboard_id}) + d.title = body.get("title", d.title) + d.description = body.get("description", d.description) + d.config_json = body + s.commit() + return _json({"updated": dashboard_id, "config": body}) + + +@view_config(route_name="api_dashboard", request_method="DELETE") +def delete_dashboard(request): + dashboard_id = request.matchdict["dashboard_id"] + Session = _session_factory(request.registry.settings) + with Session() as s: + d = s.get(Dashboard, dashboard_id) + if d is None: + raise HTTPNotFound(json_body={"error": "unknown dashboard", "id": dashboard_id}) + s.delete(d) + s.commit() + return _json({"deleted": dashboard_id}) diff --git a/arborist_viz/views/home.py b/arborist_viz/views/home.py new file mode 100644 index 0000000..dcf8578 --- /dev/null +++ b/arborist_viz/views/home.py @@ -0,0 +1,106 @@ +"""Home + meta endpoints + dedicated proof/audit visualizer pages.""" + +from __future__ import annotations + +import os + +from pyramid.response import FileResponse, Response +from pyramid.view import view_config + +from .. import GIT_COMMIT, SERVICE_NAME + + +HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _shard_stats(request) -> dict: + """Header pill counts. Single seam call into arborist.read.""" + shards = request.shards + if shards is None: + return {"docs": 0, "audit_events": 0, "shard_open": False, "shard_count": 0} + counts = shards.counts() + return { + "docs": counts.documents, + "audit_events": counts.audit_events, + "shard_open": True, + "shard_count": counts.shard_count, + } + + +def _common_ctx(request) -> dict: + return { + "shards": request.arborist_shards, + "shard_stats": _shard_stats(request), + "git_commit": request.git_commit, + "reveal_private": request.reveal_private_leaves, + } + + +@view_config(route_name="home", renderer="dashboard.jinja2") +def home(request): + return { + "title": "Arborist VIZ — Merkle Command Center", + "dashboard_id": "default", + **_common_ctx(request), + } + + +@view_config(route_name="dashboard_page", renderer="dashboard.jinja2") +def dashboard_page(request): + return { + "title": "Arborist VIZ — {0}".format(request.matchdict["dashboard_id"]), + "dashboard_id": request.matchdict["dashboard_id"], + **_common_ctx(request), + } + + +@view_config(route_name="proof_page", renderer="proof.jinja2") +def proof_page(request): + """Single-root visualizer — drops a Merkle hash into a 3D lattice + + inclusion proof card + leaf list. A ``?cache_key=...`` query param + threads a QA record through so synthetic context roots can show the + question + answer alongside their evidence manifest.""" + root_hash = request.matchdict["root_hash"] + leaf_param = request.GET.get("leaf") + cache_key = request.GET.get("cache_key") + return { + "title": "Arborist VIZ — root {0}".format(root_hash[:12]), + "root_hash": root_hash, + "leaf_index": leaf_param, + "cache_key": cache_key, + **_common_ctx(request), + } + + +@view_config(route_name="audit_page", renderer="audit.jinja2") +def audit_page(request): + event_hash = request.matchdict["event_hash"] + return { + "title": "Arborist VIZ — audit {0}".format(event_hash[:12]), + "event_hash": event_hash, + **_common_ctx(request), + } + + +@view_config(route_name="version") +def version(request): + return Response( + json_body={ + "service": SERVICE_NAME, + "git_commit": GIT_COMMIT, + "phase": "0-5+8", + } + ) + + +@view_config(route_name="favicon") +def favicon(request): + path = os.path.join(HERE, "static", "favicon.ico") + if os.path.exists(path): + return FileResponse(path, request=request) + return Response(status=404) + + +@view_config(route_name="robots") +def robots(request): + return Response(body="User-agent: *\nDisallow:\n", content_type="text/plain") diff --git a/arborist_viz/views/proofs.py b/arborist_viz/views/proofs.py new file mode 100644 index 0000000..6a52851 --- /dev/null +++ b/arborist_viz/views/proofs.py @@ -0,0 +1,51 @@ +"""Merkle inclusion proof endpoint (ticket §6.1, §13). + +Hard rule (DoD #10): inclusion is integrity, not semantic truth. +Server-side proof computation lives behind ``arborist.read.Shards.proof``. +""" + +from __future__ import annotations + +from pyramid.response import Response +from pyramid.view import view_config + + +def _json(body, status=200) -> Response: + return Response(json_body=body, status=status) + + +@view_config(route_name="api_proof", request_method="GET") +def get_proof(request): + if request.shards is None: + return _json( + {"error": "no_shard", "note": "configure arborist.shards"}, status=503 + ) + root_hash = request.matchdict["root_hash"] + try: + leaf_index = int(request.matchdict["leaf_index"]) + except ValueError: + return _json({"error": "leaf_index must be an integer"}, status=400) + try: + p = request.shards.proof(root_hash, leaf_index) + if p is None: + # Fall through to a context root's inclusion proof — same + # builder, sorted source document_roots as leaves. + p = request.shards.context_proof(root_hash, leaf_index) + except IndexError as exc: + return _json({"error": str(exc)}, status=400) + if p is None: + return _json({"error": "not_found", "root_hash": root_hash, "leaf_index": leaf_index}, status=404) + return _json({ + "root_hash": p.document_root, + "leaf_index": p.leaf_index, + "leaf_hash": p.leaf_hash, + "sibling_hashes": p.sibling_hashes, + "left_right_flags": p.left_right_flags, + "computed_root": p.computed_root, + "expected_root": p.expected_root, + "passed": p.passed, + "domain_separator": "leaf=0x00 / node=0x03", + "shard": p.shard_path, + "raw_proof": p.raw, + "source_roots": [p.document_root], + }) diff --git a/arborist_viz/views/roots.py b/arborist_viz/views/roots.py new file mode 100644 index 0000000..e760008 --- /dev/null +++ b/arborist_viz/views/roots.py @@ -0,0 +1,244 @@ +"""Root explorer + leaves (ticket §6.2, §13). Reads through ``arborist.read``.""" + +from __future__ import annotations + +from dataclasses import asdict + +from pyramid.response import Response +from pyramid.view import view_config + + +def _json(body, status=200) -> Response: + return Response(json_body=body, status=status) + + +def _no_shard() -> Response: + return _json( + {"error": "no_shard", "note": "configure arborist.shards or set ARBORIST_VIZ_SHARDS"}, + status=503, + ) + + +@view_config(route_name="api_root", request_method="GET") +def get_root(request): + if request.shards is None: + return _no_shard() + root_hash = request.matchdict["root_hash"] + r = request.shards.root(root_hash) + if r is not None: + return _json({ + "root_hash": r.document_root, + "document_uri": r.document_uri, + "title": r.title, + "source_type": r.source_type, + "kind": r.kind, + "leaf_count": r.leaf_count, + "hash_algorithm": "sha256", + "domain_separator": "leaf=0x00 / node=0x03 (arborist convention)", + "canonicalization_version": r.canonicalization_version, + "schema_version": r.schema_version, + "chunking_version": r.chunking_version, + "ingest_ts": r.ingest_ts, + "hit_count": r.hit_count, + "shard": r.shard_path, + "source_roots": [r.document_root], + }) + # Not a documents row — fall through to the synthetic context root + # the QA pipeline produces when an answer was assembled from chunks + # of several documents. + ctx = request.shards.context(root_hash) + if ctx is None: + return _json({"error": "not_found", "root_hash": root_hash}, status=404) + return _json({ + "root_hash": ctx.context_root, + "document_uri": ctx.document_uri, + "title": (ctx.question_text[:140] + "…") if len(ctx.question_text) > 140 else ctx.question_text, + "source_type": "context", + "kind": "context_root", + "leaf_count": len(ctx.sources), + "hash_algorithm": "sha256", + "domain_separator": "leaf=0x00 / node=0x03 (arborist convention)", + "canonicalization_version": ctx.canonicalization_version, + "schema_version": ctx.schema_version, + "chunking_version": ctx.chunking_version, + "ingest_ts": ctx.created_at, + "hit_count": ctx.hit_count, + "shard": ctx.shard_path, + "source_roots": [ctx.context_root], + "sources": ctx.sources, + "cache_key": ctx.cache_key, + "question_text": ctx.question_text, + }) + + +@view_config(route_name="api_root_leaves", request_method="GET") +def get_root_leaves(request): + if request.shards is None: + return _no_shard() + root_hash = request.matchdict["root_hash"] + leaves = request.shards.leaves( + root_hash, reveal_private=request.reveal_private_leaves + ) + if leaves: + return _json({ + "root_hash": root_hash, + "leaf_count": len(leaves), + "private_revealed": request.reveal_private_leaves, + "leaves": [asdict(L) for L in leaves], + "source_roots": [root_hash], + }) + # Synthesize leaves from a context root's sources manifest. Each + # source is one leaf of the context tree; the leaf hash is the + # source's document_root. We load the ACTUAL chunk text from the + # source document (the chunk_idx that retrieval surfaced) so the + # content panel shows the real prose the answer drew on, not just a + # title placeholder. Falls back to title+URI when the source shard + # isn't mounted (e.g. web.db absent) or content is private/cold. + # Each leaf also carries its usage signals (used / used_pointer_ids / + # source_role) so the lattice + leaves strip can highlight the chunks + # that actually fed the answer. + ctx = request.shards.context(root_hash) + if ctx is None: + return _json({"error": "not_found", "root_hash": root_hash}, status=404) + sorted_sources = sorted(ctx.sources, key=lambda s: (s.get("document_root") or "").lower()) + syn = [] + for idx, s in enumerate(sorted_sources): + sroot = (s.get("document_root") or "").lower() + title = s.get("title") or s.get("document_uri") or sroot[:16] + uri = s.get("document_uri") or "" + chunk_idx = s.get("chunk_idx") + content = None + prose = None + base_version = None + if request.reveal_private_leaves and sroot: + try: + src_leaves = request.shards.leaves(sroot, reveal_private=True) + cl = next((L for L in src_leaves if L.idx == chunk_idx), None) + if cl is None and src_leaves: + cl = src_leaves[0] + if cl is not None: + content = cl.content + prose = cl.prose + base_version = cl.base_version + except Exception: + pass + if content is None: + content = "{0}\n{1}".format(title, uri) + syn.append({ + "document_root": ctx.context_root, + "idx": idx, + "leaf_hash": sroot, + "tier": "context", + "content": content, + "prose": prose, + "base_version": base_version, + "source_root": sroot, + "source_chunk_idx": chunk_idx, + "document_uri": uri, + "title": title, + "used": bool(s.get("used")), + "used_pointer_ids": s.get("used_pointer_ids") or [], + "source_role": s.get("source_role"), + }) + return _json({ + "root_hash": root_hash, + "leaf_count": len(syn), + "private_revealed": request.reveal_private_leaves, + "leaves": syn, + "source_roots": [root_hash], + "kind": "context_root", + }) + + +@view_config(route_name="api_roots", request_method="GET") +def list_roots(request): + if request.shards is None: + return _no_shard() + try: + limit = int(request.GET.get("limit", "50")) + except ValueError: + limit = 50 + roots = request.shards.roots(limit=limit) + counts = request.shards.counts() + return _json({ + "roots": [asdict(r) for r in roots], + "count": len(roots), + "total_documents": counts.documents, + "shards": request.shards.paths, + "source_roots": [r.document_root for r in roots], + }) + + +@view_config(route_name="api_lattice", request_method="GET") +def get_lattice(request): + if request.shards is None: + return _no_shard() + root_hash = request.matchdict["root_hash"] + layers = request.shards.tree_layers(root_hash) + if layers is None: + # Fall through to the context tree synthesized from a QA's + # sources manifest (same canonical builder, sorted source roots). + layers = request.shards.context_layers(root_hash) + if layers is None: + return _json({"error": "not_found", "root_hash": root_hash}, status=404) + return _json({ + "root_hash": root_hash, + "layers": layers, + "layer_count": len(layers), + "leaf_count": len(layers[0]) if layers else 0, + "source_roots": [root_hash], + }) + + +@view_config(route_name="api_resolve", request_method="GET") +def resolve_hash(request): + """Drop-a-hash dispatcher. Returns whichever arborist object owns the hash.""" + if request.shards is None: + return _no_shard() + h = request.matchdict["hex_hash"].strip().lower() + if not h or len(h) > 128 or not all(c in "0123456789abcdef" for c in h): + return _json({"error": "not_hex", "hash": h}, status=400) + res = request.shards.resolve(h) + # The browser still needs a /url to navigate to — synthesize from kind. + url = _nav_url(res) + return _json({ + "kind": res.kind, + "hash": res.hash, + "shard": res.shard_path, + "url": url, + **res.extra, + }) + + +def _nav_url(res) -> str: + """Map a ResolveResult to a VIZ page. + + VIZ owns two first-class visualizers — ``/proof/{root}`` (Merkle + lattice + leaf inclusion) and ``/audit/{event}`` (audit chain). Every + node kind arborist surfaces resolves to one of those via its linked + first-class hash. As arborist's recursive falsification adds new node + kinds, this map grows by routing them to whichever existing + visualizer is most relevant — never by minting new kind-named URLs. + """ + if res.kind == "document_root": + return "/proof/{0}".format(res.hash) + if res.kind == "leaf_hash": + return "/proof/{0}?leaf={1}".format(res.extra.get("document_root"), res.extra.get("leaf_index")) + if res.kind == "audit_event": + return "/audit/{0}".format(res.hash) + if res.kind == "merkle_interior": + return "/proof/{0}".format(res.extra.get("document_root")) + # QA-record hashes — surface whichever root the answer cites. Carry + # the cache_key through as a query param so the proof page can also + # display the question + answer alongside the merkle data. + if res.kind == "qa_cache_key": + sr = res.extra.get("source_root") + return "/proof/{0}?cache_key={1}".format(sr, res.hash) if sr else "/" + if res.kind == "context_root": + ck = res.extra.get("cache_key") + return "/proof/{0}?cache_key={1}".format(res.hash, ck) if ck else "/proof/{0}".format(res.hash) + if res.kind == "qa_run_dag_root": + # No source_root in extras yet (run_dag_root only carries its + # cache_key). Land on home until the seam enriches the extras. + return "/" + return "/" diff --git a/arborist_viz/views/sse.py b/arborist_viz/views/sse.py new file mode 100644 index 0000000..4188bde --- /dev/null +++ b/arborist_viz/views/sse.py @@ -0,0 +1,219 @@ +"""Server-Sent Events endpoints — §3-corrected replacement for WebSockets. + +Phase 4 tails arborist's audit log by polling ``arborist.read.Shards`` +for events newer than the last opaque cursor. Each new event is emitted +as a ``patch`` event shaped like a §8.3 ``GraphPatch`` so the dashboard +can apply it incrementally. + +Pyramid streams via ``Response.app_iter`` (a generator). Each event is +flushed immediately — no line buffering — matching the CLAUDE.md +"never buffer long-running output" rule. +""" + +from __future__ import annotations + +import json +import logging +import time +from typing import Iterator, Optional + +from pyramid.response import Response +from pyramid.view import view_config + + +log = logging.getLogger(__name__) + + +def _format_event(event: str | None, data: dict, event_id: str | None = None) -> bytes: + lines = [] + if event_id is not None: + lines.append("id: {0}".format(event_id)) + if event is not None: + lines.append("event: {0}".format(event)) + lines.append("data: {0}".format(json.dumps(data, separators=(",", ":"), default=str))) + lines.append("") # blank line terminates the event + lines.append("") + return ("\n".join(lines)).encode("utf-8") + + +def _audit_tail( + shards_handle, + *, + heartbeat_seconds: int = 15, + poll_seconds: float = 1.0, + subject_root_filter: Optional[str] = None, + run_id_filter: Optional[str] = None, +) -> Iterator[bytes]: + """Yield SSE events tailing arborist's audit log. + + ``shards_handle`` is an ``arborist.read.Shards`` instance or ``None``. + Polls ``audit_since(cursor)`` once per ``poll_seconds``; emits a + ``patch`` event per new row, then a ``heartbeat`` if the poll period + exceeded ``heartbeat_seconds`` with no activity. Handles + shard-not-configured by emitting one ``ready`` event and continuing + to heartbeat. + """ + paths = list(shards_handle.paths) if shards_handle is not None else [] + cursor: dict = shards_handle.audit_cursor() if shards_handle is not None else {} + + seq = 0 + yield _format_event( + "ready", + { + "seq": seq, + "stream": "audit.tail", + "shards": paths, + "shard_open": shards_handle is not None, + "shard_count": len(paths), + "subject_root_filter": subject_root_filter, + "run_id_filter": run_id_filter, + "start_seqs": dict(cursor), + }, + event_id=str(seq), + ) + + if shards_handle is None: + while True: + seq += 1 + time.sleep(heartbeat_seconds) + try: + yield _format_event( + "heartbeat", + {"seq": seq, "ts": time.time(), "shard_open": False}, + event_id=str(seq), + ) + except GeneratorExit: + return + + last_emit = time.time() + log.info("SSE audit tail across %d shard(s) (filter root=%s run=%s)", + len(paths), subject_root_filter, run_id_filter) + + while True: + try: + new_events, cursor = shards_handle.audit_since(cursor, limit=200) + new_any = False + for ev in new_events: + if subject_root_filter and ev.subject_root != subject_root_filter: + continue + # body.run_id is best-effort — not all events carry one. + if run_id_filter and ev.body.get("run_id") != run_id_filter: + continue + new_any = True + seq += 1 + last_emit = time.time() + yield _format_event( + "patch", + { + "seq": seq, + "shard": ev.shard_path, + "event_hash": ev.event_hash, + "prev_event_hash": ev.prev_event_hash, + "event_type": ev.event_type, + "subject_root": ev.subject_root, + "ts": ev.ts, + "body": ev.body, + "patches": [ + ["node.upsert", ev.subject_root or ev.event_hash, + {"kind": "audit_event" if not ev.subject_root else "document_root", + "hash": ev.subject_root or ev.event_hash, + "status": "live"}], + ], + }, + event_id=str(seq), + ) + if not new_any and time.time() - last_emit >= heartbeat_seconds: + seq += 1 + last_emit = time.time() + yield _format_event( + "heartbeat", + {"seq": seq, "ts": time.time(), + "last_audit_seqs": dict(cursor)}, + event_id=str(seq), + ) + time.sleep(poll_seconds) + except GeneratorExit: + log.info("SSE audit tail closed at seq=%s", seq) + return + except Exception as exc: # pragma: no cover — defensive + log.warning("SSE audit tail error: %s", exc) + time.sleep(poll_seconds) + + +def _sse_response(request, generator: Iterator[bytes]) -> Response: + response = Response( + content_type="text/event-stream", + charset="utf-8", + ) + # SSE spec + reverse-proxy hardening. "Connection" is hop-by-hop + # (PEP 3333) — leave it to the WSGI server. + response.headers["Cache-Control"] = "no-cache, no-transform" + response.headers["X-Accel-Buffering"] = "no" + response.app_iter = generator + return response + + +def _settings_hb(request) -> int: + try: + return int(request.registry.settings.get("sse.heartbeat_seconds", "15")) + except (TypeError, ValueError): + return 15 + + +@view_config(route_name="sse_audit_live") +def sse_audit_live(request): + return _sse_response( + request, + _audit_tail(request.shards, heartbeat_seconds=_settings_hb(request)), + ) + + +@view_config(route_name="sse_run") +def sse_run(request): + return _sse_response( + request, + _audit_tail( + request.shards, + heartbeat_seconds=_settings_hb(request), + run_id_filter=request.matchdict["run_id"], + ), + ) + + +@view_config(route_name="sse_root") +def sse_root(request): + return _sse_response( + request, + _audit_tail( + request.shards, + heartbeat_seconds=_settings_hb(request), + subject_root_filter=request.matchdict["root_hash"], + ), + ) + + +@view_config(route_name="sse_claim") +def sse_claim(request): + # Claims live on top of arborist's providence/audit projection; + # for now we tail the same audit stream. + return _sse_response( + request, + _audit_tail(request.shards, heartbeat_seconds=_settings_hb(request)), + ) + + +@view_config(route_name="sse_dashboard") +def sse_dashboard(request): + return _sse_response( + request, + _audit_tail(request.shards, heartbeat_seconds=_settings_hb(request)), + ) + + +@view_config(route_name="sse_mesh_live") +def sse_mesh_live(request): + # Mesh roster + epochs land in a future phase; heartbeat-only for now. + return _sse_response( + request, + _audit_tail(request.shards, heartbeat_seconds=_settings_hb(request)), + ) diff --git a/development.ini b/development.ini new file mode 100644 index 0000000..8175422 --- /dev/null +++ b/development.ini @@ -0,0 +1,109 @@ +### +# arborist-viz — Merkle Command Center (development) +# Pyramid + Jinja2 + SQLAlchemy + waitress +# Matches the remarkbox / make_post_sell deployment idiom. +### + +[alembic] +script_location = arborist_viz/scripts/alembic +sqlalchemy.url = sqlite:///%(here)s/data/arborist_viz.sqlite + +[app:main] +use = egg:arborist-viz + +### +# dashboard metadata DB (saved layouts, filter presets, prefs). +# arborist provenance shards are read-only via arborist.embed — not here. +### +sqlalchemy.url = sqlite:///%(here)s/data/arborist_viz.sqlite + +### +# arborist provenance store(s) — read-only. Comma-separated paths. +# If empty, VIZ runs in demo / fixture mode (no live arborist data). +### +arborist.shards = + +### +# Pyramid configuration. +### +pyramid.reload_templates = true +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en +pyramid.includes = + pyramid_jinja2 + pyramid_tm + pyramid_retry + +jinja2.directories = arborist_viz:templates +jinja2.extensions = + jinja2.ext.do + +### +# session / cookie configuration (signed, NOT encrypted). +### +session.hashalg = sha512 +session.secret = dev-secret-change-me +session.timeout = 86400 +session.max_age = 86400 +session.reissue_time = 7200 +session.samesite = lax +session.secure = False + +### +# privacy default-deny — proposal §14. +# private leaves never sent to the browser unless this is true. +# Local-dev default: TRUE. The operator inspecting their own arborist +# corpus needs to see chunk content for the visualizer to be useful. +# production.ini keeps this fail-closed. +### +privacy.reveal_private_leaves = true + +### +# SSE stream tuning. +### +sse.heartbeat_seconds = 15 +sse.max_event_bytes = 65536 + +### +# server. +### +[server:main] +use = egg:waitress#main +listen = *:6543 + +### +# logging. +### +[loggers] +keys = root, arborist_viz, sqlalchemy + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console + +[logger_arborist_viz] +level = DEBUG +handlers = +qualname = arborist_viz + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s diff --git a/docs/style-guide.md b/docs/style-guide.md new file mode 100644 index 0000000..51e220e --- /dev/null +++ b/docs/style-guide.md @@ -0,0 +1,343 @@ +# arborist VIZ — Style Guide + +Legibility and ergonomics first. Hashes, proofs, and audit chains are read +under fatigue. Every choice here optimizes for "still readable at 2am after +six hours of incident response." + +Tokens live in `arborist_viz/static/css/viz.css` under `:root`. Treat that +file as the single source of truth; this document explains *why* each value +is what it is and *when* to break the rules. + +--- + +## 1. Color + +### 1.1 Palette + +| Token | Value | Role | Contrast on `--bg` | +| ---------------- | --------- | --------------------------------- | ------------------ | +| `--bg` | `#0b0e12` | Canvas. Near-black, not pure. | — | +| `--bg-elev` | `#11161c` | Header, sidebars, widget shell. | — | +| `--bg-elev-2` | `#161d25` | Widget head, buttons, chips idle. | — | +| `--border` | `#1f2934` | Hairline dividers. | decorative only | +| `--fg` | `#d6e0ec` | Primary text. | **14.0 : 1 (AAA)** | +| `--fg-dim` | `#8aa1b8` | Secondary text, labels. | **6.7 : 1 (AA)** | +| `--fg-mute` | `#7a8ea3` | Tertiary, captions, separators. | **5.0 : 1 (AA)** | +| `--accent` | `#4dd1c1` | Mint. Roots, links, live state. | **8.9 : 1 (AAA)** | +| `--warn` | `#f5b455` | Amber. Stale, waiting, caution. | **9.7 : 1 (AAA)** | +| `--bad` | `#e96b6b` | Red. Failed, quarantined. | **5.7 : 1 (AA)** | +| `--pulse` | `#7aa2ff` | Blue. Proof pulse, hover halo. | **7.0 : 1 (AA)** | + +> **Note:** `--fg-mute` was `#5e7081` (3.8 : 1) — below WCAG AA. Lighten to +> `#7a8ea3` or do not use it for text. The old value is fine for icons, +> decorative borders, or text 16px+ bold. + +### 1.2 Color rules + +- **Never use pure black `#000` or pure white `#fff` on this surface.** + Pure-black-on-dark backgrounds vanish; pure-white text on near-black + burns the eyes at low ambient light. Stay inside the palette. +- **Hue carries semantics, never decoration.** Mint = healthy/live/root. + Amber = stale/waiting. Red = failed. Blue = pulse on a proof event. + Do not introduce a fourth status hue without updating this table. +- **Never encode information in color alone.** Every red badge gets a + glyph or word ("fail"). Every amber chip pairs with text ("stale 4h"). + Color is reinforcement, not channel. +- **Avoid color combinations known to fail.** No saturated red on saturated + blue (chromostereopsis). No yellow-on-white. No cyan-on-white. No green + next to red without a glyph (~8% of men are red/green color-blind). +- **Pills, buttons, chips: idle is `--fg-dim`.** Color (accent/warn/bad) + signals state change. A bar full of green chips means nothing because + green stops being a signal. + +### 1.3 Contrast minimums + +| Use | Minimum ratio | Source | +| --------------------- | ------------- | ---------------- | +| Body text < 18px | 4.5 : 1 | WCAG 2.2 AA | +| Body text ≥ 18px bold | 3.0 : 1 | WCAG 2.2 AA | +| Hashes (monospace) | 5.0 : 1 | project rule | +| Focus ring on field | 3.0 : 1 | WCAG 2.2 AA | +| Icon / glyph | 3.0 : 1 | WCAG 2.2 AA | + +Hashes get a stricter rule than WCAG because every character matters. A +mistransposed hex char is a different commitment, not a typo. + +--- + +## 2. Typography + +### 2.1 Stacks + +```css +--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; +--mono: ui-monospace, "JetBrains Mono", "Fira Code", Consolas, monospace; +``` + +System fonts only. No web-font network round-trip, no FOUT, no GDPR +disclosure for a remote font CDN. + +### 2.2 Sizes + +| Role | Size | Family | Weight | Notes | +| ----------------------------- | ---------- | -------- | ------ | -------------------------------- | +| Body | **14 px** | sans | 400 | was 13 — bump for fatigue. | +| Widget body text | 14 px | sans | 400 | | +| Hash, code, audit row | **13 px** | mono | 400 | was 11.5 — hashes need bulk. | +| Section label, table header | 12 px | sans | 500 | uppercase 0.08em letter-spacing. | +| Chip, pill, button caption | 12 px | mono | 500 | was 10.5 — too small to skim. | +| Footer meta, timestamps | 12 px | mono | 400 | | +| H1 / page title | 18 px | sans | 600 | | + +**Floor:** nothing below 12px. Hashes never below 13px. Audit chain rows +get monospace so columnar scanning works. + +### 2.3 Line and letter + +- `line-height: 1.5` for body, `1.4` for dense tables. +- `letter-spacing: 0.05em` for uppercase tags. Uppercase without tracking + reads as a wall. +- **Never `word-break: break-all`.** Atomic identifiers do not wrap + mid-token. See §4.4 for the correct hash-display modes. + +--- + +## 3. Spacing + +A 4-pixel base unit. Multiples only. No `0.27rem` magic numbers. + +| Step | Value | Use | +| ---- | -------- | ------------------------------------ | +| 1 | 4 px | Icon-to-text gap, chip padding-y. | +| 2 | 8 px | Default inline gap. | +| 3 | 12 px | Widget body padding. | +| 4 | 16 px | Section padding, sidebar padding. | +| 6 | 24 px | Page-level gutters. | +| 8 | 32 px | Headline blocks. | + +Stack values vertically with the larger of the two adjacent margins; do +not collapse margins manually. + +--- + +## 4. Components + +### 4.1 Buttons and clickable chips + +- Minimum hit target: **32 × 32 px** (WCAG 2.5.8 AA). The Merkle command + center is dense; do not go below this. +- Visible focus ring on every focusable element. Use + `outline: 2px solid var(--pulse); outline-offset: 2px`. Do not + `outline: none` without a replacement. +- Hover ≠ focus. Hover may add a border-color change; focus must add a + ring visible to keyboard users. +- Cursor: `pointer` only on actually clickable elements. Never on text. + +### 4.2 Inputs (hash-drop bar, search, filter) + +- 36 px height minimum. +- Placeholder color: `--fg-mute`. Never lighter — placeholders that disappear + in low light are a known failure mode. +- Focus: border becomes `--accent`, ring becomes `--pulse`. Two signals. +- Invalid (`:invalid`) gets `border-color: var(--bad)` *only after first + blur*. Live red-on-keystroke is hostile. + +### 4.3 Widgets + +- Card: `--bg-elev` background, `--border` hairline, 6 px radius. +- Header strip: `--bg-elev-2`, monospace type tag in `--accent`, sans + title in `--fg`. +- Footer: `--bg`, monospace meta in `--fg-mute`. Collapsible `
        ` + for query/source attribution (every widget exposes the roots it derived + from — §19.3 of ticket). +- Empty state: italic `--fg-mute` body text. Never a blank box. + +### 4.4 Atomic identifiers (hashes, CIDs, signatures, tokens) + +An **atomic identifier** is any opaque string where every character is +load-bearing and the whole value is read as one symbol. This includes: + +- SHA-256, SHA-512, BLAKE3, SHA-1 hex digests +- IPFS CIDs, multihashes +- Ed25519 / RSA signatures, public keys, SSH fingerprints +- UUIDs, ULIDs, KSUIDs +- JWTs, OAuth tokens (when shown to a human) +- Git commit SHAs, content-addressed blobs + +#### The core rule + +**Atomic identifiers never wrap mid-token.** A SHA-256 split across lines +at column 22 in one place and column 17 in another defeats the only useful +thing humans do with hashes: visual diff. If two hashes wrap at different +column positions, the eye cannot align them — every comparison degrades to +a character-by-character scan. + +Pick exactly one of the three display modes below. Do not mix. + +#### Mode A — one-line scroll (preferred for inline use) + +```css +.id-inline { + font-family: var(--mono); + white-space: pre; /* no wrap, no collapse */ + overflow-x: auto; /* horizontal scroll if narrow */ + overflow-wrap: normal; + word-break: normal; +} +``` + +The hash is one continuous string. If the container is narrower than the +hash, the user scrolls horizontally. Always pair with a visible click-to-copy +button — scrolling to read 64 chars is worse than copying. + +Use for: table cells, list rows, inline references in prose. + +#### Mode B — grouped chunks (preferred for prominent display) + +``` +38f82216 97ee54dd b195a13f b9a375d5 +6aab5deb 3eee9213 523b4224 b1042270 +``` + +Render the hash as 8 chunks of 8 hex chars separated by two spaces. Wrap +only at chunk boundaries. Two hashes displayed this way **line up +column-for-column**, which is the entire point. + +```css +.id-block { + font-family: var(--mono); + font-size: 13px; + line-height: 1.5; + white-space: pre-wrap; /* wrap at whitespace only */ + word-break: keep-all; /* never split a chunk */ + overflow-wrap: normal; + padding: 0.5rem; + background: var(--bg); + border: 1px solid var(--border); +} +``` + +Render server-side as `chunks.join(" ")` with two spaces (or one en-space +` `). Do not use single space — single-space-separated hex still looks +like one token at a glance. + +Use for: the `.root-hash-block` card, proof page hero, anywhere the hash +itself is the page subject. + +#### Mode C — truncate with explicit reveal + +``` +38f82216…b1042270 [copy] [show full] +``` + +First 8 + ellipsis + last 8 hex chars. Always pair with: + +- `title=""` for hover (desktop) +- a visible click-to-copy button +- a "show full" affordance (`
        ` or panel expansion) + +Use for: dense lists where Mode A would still overflow, chrome bar pills, +breadcrumbs, sidebar nav entries. + +#### Floor rules + +- **Never truncate below 8 + 8 = 16 hex chars displayed.** Below that, + visual collision risk gets real (a malicious near-collision can match + the first 6 chars). +- **Never truncate without a copy affordance.** Truncated hashes are not + selectable into a useful value. +- **Never show only first-N truncation** (`38f82216…`). Always show first + AND last segments — first-only loses suffix entropy for diff. +- **Click-to-copy uses the navigator.clipboard API** with a visible toast + on success. No silent copy. + +#### What this rule applies to outside this app + +The reasoning generalizes — any system showing opaque identifiers to a +human should follow these modes: + +- Block explorers (TX hashes, addresses) +- Git interfaces (commit SHAs, blob hashes) +- Container registries (image digests) +- Cert viewers (SHA-256 fingerprints) +- Package managers (integrity hashes) + +If you find yourself writing `word-break: break-all` on an identifier, +you have chosen comfort for the layout engine over legibility for the +human. Reverse the choice. + +### 4.5 Status dots + +8 × 8 px circle. Always accompanied by text label within 8px to its right. +Glow (`box-shadow: 0 0 6px `) only on `dot-on` to make "live" pop +without animating. + +### 4.6 Tables (audit chains, leaf strips) + +- Monospace. +- Zebra striping `rgba(255,255,255,0.02)` on odd rows — almost invisible + but enough to anchor the eye on long lists. +- Sticky header on scroll. +- Per-row hover background `var(--bg-elev-2)`. Click target is the whole + row, not just a link in one column. + +--- + +## 5. Motion + +Default: **none.** + +- Status changes are step-functions. A chip flips color instantly. +- Acceptable motion: a 200ms ease on `:hover` border-color changes; + one-shot 400ms pulse on a fresh proof event. +- No spinners on SSE streams — use a dot-on glow plus the last-event + timestamp instead. Spinning indicates "we are waiting"; this app is + always waiting on the audit chain. +- Respect `prefers-reduced-motion`. Wrap every transition or animation + in `@media (prefers-reduced-motion: no-preference) { … }`. + +--- + +## 6. Accessibility checklist + +Run before merging any view template: + +- [ ] Every interactive element reachable by Tab. +- [ ] Visible focus ring on every focusable element. +- [ ] No information conveyed by color alone. +- [ ] All text passes its required contrast ratio (§1.3). +- [ ] Hit targets ≥ 32 × 32 px. +- [ ] `
        `, `