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).
This commit is contained in:
russell@unturf.com 2026-05-28 09:22:59 -04:00
commit 0cd96ab1d7
No known key found for this signature in database
55 changed files with 5985 additions and 0 deletions

15
.gitignore vendored Normal file
View file

@ -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

28
CLAUDE.md Normal file
View file

@ -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.

9
MANIFEST.in Normal file
View file

@ -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

134
Makefile Normal file
View file

@ -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 {} +

103
README.rst Normal file
View file

@ -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.

153
arborist_viz/__init__.py Normal file
View file

@ -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()

View file

@ -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)

62
arborist_viz/routes.py Normal file
View file

@ -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")

376
arborist_viz/schemas.py Normal file
View file

@ -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"

View file

View file

View file

@ -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()

View file

@ -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"}

View file

@ -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 <config_uri>", 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()

View file

@ -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 <db_path>", 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()

View file

@ -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;
}

View file

@ -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 = `<span class="lbl">[${ev.event_type}]</span> <a href="/audit/${ev.event_hash}" class="mono" title="${ev.event_hash}">${shortHex(ev.event_hash)}</a> subj=<a href="/proof/${ev.subject_root}" class="mono" title="${ev.subject_root || ''}">${shortHex(ev.subject_root || '')}</a>`;
chainEl.appendChild(li);
}
} catch (err) {
chainEl.innerHTML = `<li class="muted">chain lookup failed: ${err.message}</li>`;
}
}
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;
}

View file

@ -0,0 +1,140 @@
// Phase 6 — small claim-graph canvas renderer.
//
// Vanilla 2D <canvas> + 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";
}
}

View file

@ -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 = `<li class="muted">no roots in shard (configure arborist.shards)</li>`;
return;
}
ul.innerHTML = "";
for (const r of j.roots) {
const li = document.createElement("li");
li.innerHTML = `
<a href="/proof/${r.document_root}" class="root-link" title="${r.document_root}">
<span class="root-title">${escapeHTML(r.title || r.document_uri || "(untitled)")}</span>
<span class="root-meta">${r.leaf_count}L · ${escapeHTML(r.source_type)}</span>
<code class="root-hash-short">${shortHex(r.document_root)}</code>
</a>`;
ul.appendChild(li);
}
} catch (err) {
ul.innerHTML = `<li class="muted">roots lookup failed: ${err.message}</li>`;
}
}
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 = `<span class="lbl">[${ev.event_type}]</span> <a href="/audit/${ev.event_hash}" class="mono" title="${ev.event_hash}">${shortHex(ev.event_hash)}</a> ${ev.subject_root ? `subj=<a class="mono" href="/proof/${ev.subject_root}" title="${ev.subject_root}">${shortHex(ev.subject_root)}</a>` : ""}`;
list.appendChild(li);
}
} catch {}
}
function escapeHTML(s) {
return (s || "").replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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)),
);
}

View file

@ -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;
}

View file

@ -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)}`;
}

View file

@ -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 =
`<div class="lattice-fallback muted">3D lattice unavailable — this browser could not create a WebGL context. The inclusion proof, leaves, and audit chain still work.</div>`;
const noop = () => {};
return {
highlightLeaf: noop,
markUsage: noop,
setProofPath: noop,
pulseProofPath: noop,
reset: noop,
onLeafSelect: noop,
onNodeSelect: noop,
};
}
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(width(), height());
container.innerHTML = "";
container.appendChild(renderer.domElement);
// Lights — soft ambient + a key from above.
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
const key = new THREE.DirectionalLight(0xffffff, 0.8);
key.position.set(2, layerCount + 2, 4);
scene.add(key);
// Build node meshes per layer. Spread leaves widest, root smallest.
const nodesByLayer = []; // nodesByLayer[layerIdx][nodeIdx] = mesh
const allNodeMeshes = [];
for (let li = 0; li < layers.length; li++) {
const layer = layers[li];
const isRoot = li === layers.length - 1;
const isLeaf = li === 0;
const layerNodes = [];
const spacing = isRoot ? 0 : 1.4;
const total = (layer.length - 1) * spacing;
for (let ni = 0; ni < layer.length; ni++) {
const radius = isRoot ? 0.5 : (isLeaf ? 0.3 : 0.36);
const geom = new THREE.SphereGeometry(radius, 24, 16);
const baseColor = isRoot ? COLORS.root : (isLeaf ? COLORS.leaf : COLORS.interior);
const mat = new THREE.MeshStandardMaterial({
color: baseColor,
emissive: baseColor,
emissiveIntensity: 0.25,
roughness: 0.4,
metalness: 0.1,
});
const mesh = new THREE.Mesh(geom, mat);
const x = (ni * spacing) - total / 2;
const y = li * 1.4;
mesh.position.set(x, y, 0);
mesh.userData = {
layer: li, idx: ni, baseColor, hash: layer[ni], isLeaf, isRoot,
baseIntensity: 0.25,
};
scene.add(mesh);
layerNodes.push(mesh);
allNodeMeshes.push(mesh);
}
nodesByLayer.push(layerNodes);
}
// Child→parent edges. Each parent at layer l has children at layer l-1
// (indices 2*idx and 2*idx+1; odd self-dup means right==left when
// 2*idx+1 >= len(child layer)).
const edgeMaterial = new THREE.LineBasicMaterial({ color: COLORS.edge });
for (let li = 1; li < nodesByLayer.length; li++) {
const child = nodesByLayer[li - 1];
const parent = nodesByLayer[li];
for (let pi = 0; pi < parent.length; pi++) {
const left = child[2 * pi];
const right = child[(2 * pi + 1) < child.length ? (2 * pi + 1) : (2 * pi)];
if (!left) continue;
const p = parent[pi].position;
const segL = lineSegment(p, left.position, edgeMaterial);
scene.add(segL);
parent[pi].userData.edgeLeft = segL;
if (right && right !== left) {
const segR = lineSegment(p, right.position, edgeMaterial);
scene.add(segR);
parent[pi].userData.edgeRight = segR;
}
}
}
// Render-loop control. We only animate when a pulse is running.
let pulseQueue = [];
let pulseAt = 0;
let highlightedLeaf = null;
let proofPath = null; // {leaf_index, sibling_hashes, left_right_flags}
function render() {
renderer.render(scene, camera);
}
function frame() {
if (pulseQueue.length === 0) {
render();
return;
}
const now = performance.now();
while (pulseQueue.length && now >= pulseQueue[0].at) {
const step = pulseQueue.shift();
if (step.kind === "color") {
recolor(step.mesh, step.color, step.intensity);
} else if (step.kind === "restore") {
restore(step.mesh);
}
}
render();
if (pulseQueue.length) requestAnimationFrame(frame);
else render();
}
render();
// Drag-to-rotate (no OrbitControls dependency).
let isDragging = false;
let lastX = 0;
let yaw = 0;
let pitch = 0;
function applyCamera() {
const r = Math.max(maxWidth * 1.4, layerCount * 2.2, 8);
camera.position.set(
Math.sin(yaw) * Math.cos(pitch) * r,
layerCount * 0.6 + Math.sin(pitch) * r,
Math.cos(yaw) * Math.cos(pitch) * r,
);
camera.lookAt(0, layerCount * 0.5, 0);
render();
}
renderer.domElement.addEventListener("pointerdown", (ev) => {
isDragging = true; lastX = ev.clientX; renderer.domElement.setPointerCapture(ev.pointerId);
});
renderer.domElement.addEventListener("pointermove", (ev) => {
if (!isDragging) return;
const dx = ev.clientX - lastX;
lastX = ev.clientX;
yaw += dx * 0.01;
applyCamera();
});
renderer.domElement.addEventListener("pointerup", () => { isDragging = false; });
// Click-to-select.
const raycaster = new THREE.Raycaster();
const ndc = new THREE.Vector2();
let onLeafSelect = null;
let onNodeSelect = null;
renderer.domElement.addEventListener("click", (ev) => {
const rect = renderer.domElement.getBoundingClientRect();
ndc.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
ndc.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(ndc, camera);
const hits = raycaster.intersectObjects(allNodeMeshes, false);
if (!hits.length) return;
const hit = hits[0].object;
const ud = hit.userData;
if (ud.isLeaf && onLeafSelect) {
onLeafSelect(ud.idx);
}
// Surface every node (leaves included) for inspectors that want the
// hash + layer + idx — proof-page renders it in a side panel so
// interior + root spheres no longer feel inert.
if (onNodeSelect) onNodeSelect({ hash: ud.hash, layer: ud.layer, idx: ud.idx, isLeaf: !!ud.isLeaf });
});
// Window resize.
window.addEventListener("resize", () => {
renderer.setSize(width(), height());
camera.aspect = width() / height();
camera.updateProjectionMatrix();
render();
});
// ----- public API ------
return {
highlightLeaf(idx) {
restoreAll();
highlightedLeaf = idx;
const leaf = nodesByLayer[0][idx];
if (leaf) recolor(leaf, COLORS.selected, 1.2);
render();
},
markUsage(usage) {
// Persistent provenance glow on layer-0 leaves:
// used (cited in the answer) → bright gold;
// primary candidate (retrieved, not cited) → soft mint glow;
// background → default. Written into each node's BASE appearance
// so proof-pulse restores keep the glow. `usage` is indexed by
// leaf position (sorted by document_root — matches the lattice,
// the leaves strip, and the inclusion-proof leaf_index).
if (!Array.isArray(usage)) return;
const leafLayer = nodesByLayer[0] || [];
usage.forEach((u, idx) => {
const mesh = leafLayer[idx];
if (!mesh || !u) return;
if (u.used) {
mesh.userData.baseColor = COLORS.usedLeaf;
mesh.userData.baseIntensity = 1.3;
} else if (u.role === "primary_answer_source") {
mesh.userData.baseColor = COLORS.primaryLeaf;
mesh.userData.baseIntensity = 0.7;
} else {
return;
}
restore(mesh);
});
render();
},
setProofPath(proof) {
proofPath = proof;
},
pulseProofPath() {
restoreAll();
if (!proofPath || proofPath.error) return;
const leafIdx = proofPath.leaf_index;
const flags = proofPath.left_right_flags || [];
// Build the running-hash path: at each layer, the node we're
// climbing through (leaf → parent → … → root).
const runningPath = [];
let idx = leafIdx;
for (let li = 0; li < nodesByLayer.length; li++) {
runningPath.push({ layer: li, idx });
idx = Math.floor(idx / 2);
}
// Sibling path: at each layer below the root, the sibling we hash with.
const siblingPath = [];
idx = leafIdx;
for (let li = 0; li < flags.length; li++) {
const is_left = flags[li]; // sibling is left of running
const sib = is_left ? idx - 1 : idx + 1;
// odd-element self-duplication: if sib out of range, the sibling
// IS the running node — we render no extra highlight (the sphere
// is already highlighted by running path).
const layer = nodesByLayer[li];
if (layer && sib >= 0 && sib < layer.length) {
siblingPath.push({ layer: li, idx: sib });
}
idx = Math.floor(idx / 2);
}
pulseQueue = [];
pulseAt = performance.now();
const STEP = 260; // ms between pulses
// Wave 1 — light the leaf.
schedule(0, "color", nodesByLayer[0][leafIdx], COLORS.pulseLeaf, 1.5);
// Wave 2 — light each sibling in order.
siblingPath.forEach((p, i) => {
const mesh = nodesByLayer[p.layer][p.idx];
schedule(STEP * (i + 1), "color", mesh, COLORS.pulseSib, 1.4);
});
// Wave 3 — climb running-hash path.
runningPath.forEach((p, i) => {
if (p.layer === 0) return; // leaf already lit
const mesh = nodesByLayer[p.layer][p.idx];
const isRoot = p.layer === nodesByLayer.length - 1;
const t = STEP * (siblingPath.length + 1 + i);
schedule(
t,
"color",
mesh,
isRoot ? COLORS.pulseRoot : COLORS.pulsePath,
isRoot ? 2.0 : 1.4,
);
});
requestAnimationFrame(frame);
},
reset() {
restoreAll();
pulseQueue = [];
proofPath = null;
highlightedLeaf = null;
render();
},
onLeafSelect(callback) {
onLeafSelect = callback;
},
onNodeSelect(callback) {
onNodeSelect = callback;
},
};
// ----- helpers -----
function schedule(deltaMs, kind, mesh, color, intensity) {
pulseQueue.push({ at: pulseAt + deltaMs, kind, mesh, color, intensity });
}
function recolor(mesh, color, intensity) {
if (!mesh) return;
mesh.material.color.setHex(color);
mesh.material.emissive.setHex(color);
mesh.material.emissiveIntensity = intensity != null ? intensity : 0.5;
}
function restore(mesh) {
if (!mesh) return;
const c = mesh.userData.baseColor;
mesh.material.color.setHex(c);
mesh.material.emissive.setHex(c);
// baseIntensity carries the persistent provenance glow (markUsage) so
// a proof-pulse restore returns to the glow, not the flat default.
mesh.material.emissiveIntensity =
mesh.userData.baseIntensity != null ? mesh.userData.baseIntensity : 0.25;
}
function restoreAll() {
for (const m of allNodeMeshes) restore(m);
}
}
function lineSegment(a, b, material) {
const geom = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(a.x, a.y, a.z),
new THREE.Vector3(b.x, b.y, b.z),
]);
return new THREE.Line(geom, material);
}

View file

@ -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 = `<span class="k">${label}</span> <span class="v mono">${chunkHex(node.hash)}</span>`;
});
// 4) Audit linkage — indexed lookup by subject_root (cheap even on a
// 3.5M-event chain).
try {
const j = await getJSON(`/api/audit/by-root/${rootHash}?limit=10`);
const linked = j.events || [];
if (linked.length === 0) {
auditLink.textContent = "no audit events for this root";
} else {
auditLink.innerHTML = "";
for (const ev of linked.slice(0, 8)) {
const a = document.createElement("a");
a.href = `/audit/${ev.event_hash}`;
a.className = "audit-link-row hash-id";
a.title = ev.event_hash;
a.textContent = `${ev.event_type} ${chunkHex(ev.event_hash)}`;
auditLink.appendChild(a);
}
}
} catch (err) {
auditLink.textContent = `audit lookup failed: ${err.message}`;
}
let lastProof = null;
async function loadProof(idx) {
proofCard.innerHTML = `<p class="muted">computing proof for leaf #${idx}…</p>`;
sceneApi.highlightLeaf(idx);
const proof = await getJSON(`/api/proof/${rootHash}/${idx}`);
lastProof = proof;
renderProofCard(proofCard, proof);
sceneApi.setProofPath(proof);
btnPulse.disabled = false;
// Mirror the canvas-click inspector update so chip clicks also
// surface the full leaf hash (no truncation).
const inspect = document.getElementById("node-inspect");
if (inspect) {
const fullHash = proof.leaf_hash || leaves[idx]?.leaf_hash || "";
inspect.classList.remove("muted");
inspect.innerHTML = `<span class="k">leaf #${idx}</span> <span class="v mono">${chunkHex(fullHash)}</span>`;
}
currentLeafIdx = idx;
renderLeafContent();
}
function renderLeafContent() {
const leaf = currentLeafIdx == null ? null : leaves[currentLeafIdx];
if (!leaf) return;
if (leaf.content == null) {
leafContent.classList.add("muted");
leafContent.textContent = leavesRes.private_revealed
? "(no stored content for this leaf — cold tier?)"
: "private-leaf default-deny — set privacy.reveal_private_leaves=true in the .ini to view content.";
if (contentToggle) contentToggle.hidden = true;
return;
}
// Show toggle only when a prose projection exists AND differs from raw.
const hasProse = leaf.prose != null && leaf.prose !== leaf.content;
if (contentToggle) contentToggle.hidden = !hasProse;
if (contentBaseVersion) {
contentBaseVersion.textContent = hasProse && leaf.base_version
? `· ${leaf.base_version} projection`
: "";
}
leafContent.classList.remove("muted");
if (contentMode === "prose" && hasProse) {
leafContent.textContent = leaf.prose;
} else {
leafContent.textContent = leaf.content;
}
if (contentToggle) {
contentToggle.querySelectorAll("button").forEach((b) => {
b.classList.toggle("active", b.dataset.mode === contentMode);
});
}
}
if (contentToggle) {
contentToggle.addEventListener("click", (ev) => {
const btn = ev.target.closest("button[data-mode]");
if (!btn) return;
contentMode = btn.dataset.mode;
renderLeafContent();
});
}
// Context manifest "leaf #N" badges → highlight + prove that leaf in
// the current tree. Delegated so it works regardless of when the async
// renderContextExtras populated the list.
const sourcesListEl = document.getElementById("sources-list");
if (sourcesListEl) {
sourcesListEl.addEventListener("click", (ev) => {
const badge = ev.target.closest(".leaf-jump");
if (!badge) return;
ev.preventDefault();
const idx = parseInt(badge.dataset.leaf, 10);
if (!Number.isNaN(idx)) loadProof(idx);
});
}
btnPulse.addEventListener("click", () => {
if (lastProof) sceneApi.pulseProofPath();
else if (leaves.length) loadProof(0);
});
btnReset.addEventListener("click", () => {
sceneApi.reset();
proofCard.innerHTML = `<p class="muted">select a leaf to compute its inclusion proof…</p>`;
lastProof = null;
});
if (initialLeafIndex != null && leaves.length > initialLeafIndex) {
await loadProof(initialLeafIndex);
sceneApi.pulseProofPath();
} else if (leaves.length > 0) {
// Auto-select the first leaf so the dashboard is visually populated
// the moment the page lands.
await loadProof(0);
}
}
function renderLeavesStrip(el, leaves, onSelect) {
el.innerHTML = "";
if (!leaves.length) {
el.innerHTML = `<span class="muted">no leaves in shard</span>`;
return;
}
for (const leaf of leaves) {
const btn = document.createElement("button");
const usedTag = leaf.used && leaf.used_pointer_ids && leaf.used_pointer_ids.length
? ` (${leaf.used_pointer_ids.join(",")})` : "";
btn.className = "leaf-chip"
+ (leaf.used ? " used" : (leaf.source_role === "primary_answer_source" ? " primary" : ""));
btn.title = leaf.used ? `${leaf.leaf_hash}\nused${usedTag}` : leaf.leaf_hash;
btn.innerHTML = `<span class="leaf-idx">#${leaf.idx}</span><span class="leaf-hash">${leaf.leaf_hash}</span>`
+ (leaf.used ? `<span class="leaf-used">used${usedTag}</span>` : "");
btn.addEventListener("click", () => {
el.querySelectorAll(".leaf-chip.active").forEach((n) => n.classList.remove("active"));
btn.classList.add("active");
onSelect(leaf.idx);
});
el.appendChild(btn);
}
}
function renderProofCard(el, proof) {
if (proof.error) {
el.innerHTML = `<p class="muted">error: ${proof.error}</p>`;
return;
}
const passClass = proof.passed ? "passed" : "failed";
el.innerHTML = `
<div class="proof-pass ${passClass}">${proof.passed ? "✓ PASSED" : "✗ FAILED"}</div>
<div class="proof-row"><span class="k">leaf #</span><span class="v">${proof.leaf_index}</span></div>
<div class="proof-row"><span class="k">leaf hash</span><span class="v mono">${chunkHex(proof.leaf_hash)}</span></div>
<div class="proof-row"><span class="k">siblings</span><span class="v">${proof.sibling_hashes.length}</span></div>
<div class="proof-row"><span class="k">computed root</span><span class="v mono">${chunkHex(proof.computed_root)}</span></div>
<div class="proof-row"><span class="k">expected root</span><span class="v mono">${chunkHex(proof.expected_root)}</span></div>
<details class="proof-details">
<summary>sibling path</summary>
<ol class="sibling-list">
${proof.sibling_hashes.map((h, i) => `<li title="${h}">${proof.left_right_flags[i] ? "←" : "→"} ${chunkHex(h)}</li>`).join("")}
</ol>
</details>
<div class="muted">Inclusion is integrity, not semantic truth.</div>
`;
}
async function getJSON(url) {
const res = await fetch(url);
const j = await res.json().catch(() => ({}));
if (!res.ok && !j.error) j.error = `HTTP ${res.status}`;
return j;
}
async function renderContextExtras(root, cacheKey) {
// Additive overlay for context-root pages — the lattice + leaves +
// inclusion-proof widgets already render the merkle tree built from
// sorted source document_roots. This adds the QA card (question +
// answer) and a richer sources manifest with per-source links.
const ctxPanel = document.getElementById("context-panel");
const qaCard = document.getElementById("qa-card");
const sourcesList = document.getElementById("sources-list");
const effectiveCacheKey = cacheKey || root.cache_key;
if (effectiveCacheKey && qaCard) {
const qa = await getJSON(`/api/cache/${effectiveCacheKey}`);
if (!qa.error) {
qaCard.hidden = false;
qaCard.innerHTML = `
<div class="qa-row"><span class="k">question</span><span class="v">${escapeHTML(qa.question_text || "")}</span></div>
<div class="qa-row"><span class="k">answer</span><pre class="v qa-answer">${escapeHTML(qa.answer_text || "")}</pre></div>
<div class="qa-row"><span class="k">cache_key</span><span class="v mono">${chunkHex(qa.cache_key)}</span></div>
<div class="qa-row"><span class="k">state</span><span class="v">${escapeHTML(qa.falsification_state || "")} · hits ${qa.hit_count ?? 0}</span></div>
`;
}
}
if (sourcesList) {
sourcesList.innerHTML = "";
const sources = root.sources || [];
if (!sources.length) {
sourcesList.innerHTML = `<li class="muted">no sources recorded in this QA's merkle_proof.</li>`;
} else {
// The context tree's leaves are the source document_roots sorted
// lexicographically (matches arborist.qa.query._context_root and
// the read seam's _context_leaf_hashes). Number every source by
// its position in THAT order so the manifest agrees with the leaf
// chips, the lattice, and the inclusion proof's leaf_index — all
// 0-based. (Was an <ol>, which auto-numbered 1-based, over the
// unsorted score-ranked list — two different orderings + two
// different bases.)
const ordered = [...sources].sort((a, b) =>
(a.document_root || "").toLowerCase().localeCompare((b.document_root || "").toLowerCase())
);
ordered.forEach((s, leafIdx) => {
const li = document.createElement("li");
li.className = "source-row" + (s.used ? " used" : "");
const score = typeof s.score === "number" ? s.score.toFixed(1) : "";
const usedPtrs = s.used_pointer_ids && s.used_pointer_ids.length
? ` (${escapeHTML(s.used_pointer_ids.join(","))})` : "";
const usedTag = s.used
? ` · <span class="used-tag">used${usedPtrs}</span>` : "";
li.innerHTML = `
<button type="button" class="leaf-jump" data-leaf="${leafIdx}" title="highlight leaf #${leafIdx} in the tree">leaf #${leafIdx}</button>
<a class="source-link" href="/proof/${encodeURIComponent(s.document_root)}">
<span class="source-title">${escapeHTML(s.title || s.document_uri || s.document_root.slice(0, 16))}</span>
<span class="source-uri muted">${escapeHTML(s.document_uri || "")}</span>
<span class="source-meta muted">
role: ${escapeHTML(s.source_role || "?")} · chunk #${s.chunk_idx ?? "?"} · score ${score} · shard ${escapeHTML(s.shard || "?")}${usedTag}
</span>
<span class="source-hash mono">${chunkHex(s.document_root)}</span>
</a>
`;
sourcesList.appendChild(li);
});
}
}
if (ctxPanel) ctxPanel.hidden = false;
}
function escapeHTML(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[c]);
}

View file

@ -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);
}
}

View file

@ -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:
//
// <script type="module" src="https://viz.example/static/viz/web-components.js"></script>
// <arborist-proof-card src="https://viz.example" root="abc…" leaf="0"></arborist-proof-card>
// <arborist-root-viewer src="https://viz.example" root="abc…"></arborist-root-viewer>
// <arborist-claim-card src="https://viz.example" claim="claim:xyz"></arborist-claim-card>
// <arborist-run-dag src="https://viz.example" run="run:42"></arborist-run-dag>
// <arborist-trace-viewer src="https://viz.example" trace="t:1"></arborist-trace-viewer>
//
// 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 `<div class="row"><span class="k">${k}</span><span class="v${mono ? " mono" : ""}">${v}</span></div>`;
}
// ---------- <arborist-proof-card> ---------------------------------
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 = `<div class="err">missing "root" attribute</div>`;
return;
}
this._body.innerHTML = `<div class="head">[arborist-proof-card]</div><div class="muted">computing…</div>`;
const proof = await fetchJSON(src, `/api/proof/${root}/${leaf}`);
if (proof.error) {
this._body.innerHTML = `<div class="head">[arborist-proof-card]</div><div class="err">${proof.error}</div>`;
return;
}
const passCls = proof.passed ? "pass" : "fail";
this._body.innerHTML = `
<div class="head">[arborist-proof-card]</div>
<div class="${passCls}">${proof.passed ? "✓ PASSED" : "✗ FAILED"}</div>
${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)}
<div class="foot">source_roots: ${proof.source_roots.join(", ")} · inclusion is integrity, not truth</div>
`;
}
}
// ---------- <arborist-root-viewer> --------------------------------
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 = `<div class="err">missing "root" attribute</div>`;
return;
}
this._body.innerHTML = `<div class="head">[arborist-root-viewer]</div><div class="muted">loading…</div>`;
const r = await fetchJSON(src, `/api/root/${root}`);
if (r.error) {
this._body.innerHTML = `<div class="head">[arborist-root-viewer]</div><div class="err">${r.error}</div>`;
return;
}
this._body.innerHTML = `
<div class="head">[arborist-root-viewer]</div>
${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)}
<div class="foot">source_roots: ${(r.source_roots || []).join(", ")}</div>
`;
}
}
// ---------- <arborist-claim-card> ---------------------------------
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 = `<div class="err">missing "claim" attribute</div>`;
return;
}
const c = await fetchJSON(src, `/api/claim/${claim}`);
if (c.error) {
this._body.innerHTML = `<div class="head">[arborist-claim-card]</div><div class="err">${c.error}</div>`;
return;
}
this._body.innerHTML = `
<div class="head">[arborist-claim-card]</div>
${row("claim", c.claim_id, true)}
${row("warrant", c.warrant_level || "UNGROUNDED")}
${row("status", c.status || "speculative")}
<div class="foot">source_roots: ${(c.source_roots || []).join(", ") || "(none)"}</div>
`;
}
}
// ---------- <arborist-run-dag> ------------------------------------
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 = `<div class="head">[arborist-run-dag]</div><div class="err">${r.error}</div>`;
return;
}
const stageList = (r.stages || []).map((s) => `<li>${s}</li>`).join("");
this._body.innerHTML = `
<div class="head">[arborist-run-dag]</div>
${row("run", r.run_id, true)}
<ol style="margin:0.4rem 0 0 1rem;padding:0;color:#8aa1b8">${stageList}</ol>
<div class="foot">source_roots: ${(r.source_roots || []).join(", ") || "(none)"}</div>
`;
}
}
// ---------- <arborist-trace-viewer> -------------------------------
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 = `
<div class="head">[arborist-trace-viewer]</div>
<div class="muted">gated on ${t.gate}</div>
<div class="foot">${t.note || ""}</div>
`;
return;
}
if (t.error) {
this._body.innerHTML = `<div class="head">[arborist-trace-viewer]</div><div class="err">${t.error}</div>`;
return;
}
this._body.innerHTML = `
<div class="head">[arborist-trace-viewer]</div>
${row("trace", t.trace_id || trace, true)}
${row("causal", t.causal_status || "CORRELATIONAL_TRACE")}
<div class="foot">soft signals never enter the proof path</div>
`;
}
}
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);

65
arborist_viz/storage.py Normal file
View file

@ -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()

View file

@ -0,0 +1,53 @@
{% extends "base.jinja2" %}
{% block body %}
<div class="audit-page" data-event-hash="{{ event_hash }}">
<aside class="dash-side">
<h2 class="dash-h2">Event</h2>
<pre class="root-hash-block" id="event-hash">{{ event_hash | hex_chunk }}</pre>
<h2 class="dash-h2">Live stream</h2>
<div class="dash-stream" id="sse-status">
<span class="dot dot-off"></span>
<span class="lbl">disconnected</span>
</div>
<pre class="dash-events" id="sse-events" aria-live="polite"></pre>
</aside>
<section class="audit-stage">
<article class="widget">
<header class="widget-head">
<span class="widget-type">[audit-event]</span>
<span class="widget-title">Event detail</span>
</header>
<div class="widget-body">
<pre id="audit-body" class="audit-detail">loading…</pre>
</div>
</article>
<article class="widget">
<header class="widget-head">
<span class="widget-type">[audit-chain]</span>
<span class="widget-title">Walk backwards from this event</span>
</header>
<div class="widget-body">
<ol id="audit-chain-list" class="audit-chain"></ol>
</div>
</article>
</section>
</div>
{% endblock %}
{% block scripts %}
<script type="module">
import { mountAuditPage } from "{{ request.static_url('arborist_viz:static/viz/audit-page.js') }}?v={{ request.static_version }}";
import { connectSSE } from "{{ request.static_url('arborist_viz:static/viz/sse-client.js') }}?v={{ request.static_version }}";
const evHash = document.querySelector(".audit-page").dataset.eventHash;
mountAuditPage(evHash);
connectSSE("/sse/audit/live", {
statusEl: document.getElementById("sse-status"),
eventsEl: document.getElementById("sse-events"),
});
</script>
{% endblock %}

View file

@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title or 'Arborist VIZ' }}</title>
<link rel="stylesheet" href="{{ request.static_url('arborist_viz:static/css/viz.css') }}?v={{ request.static_version }}">
<link rel="icon" href="data:,">
{% block head_extra %}{% endblock %}
</head>
<body class="cmd-center">
<header class="cmd-bar">
<div class="cmd-bar-left">
<a class="cmd-brand" href="/">arborist VIZ</a>
<span class="cmd-sep">·</span>
<span class="cmd-tag">Merkle Command Center</span>
</div>
<form class="cmd-hash-drop" id="hash-drop" autocomplete="off">
<input
type="text"
name="hash"
id="hash-input"
placeholder="drop a hash → document_root | leaf_hash | event_hash | interior"
spellcheck="false"
pattern="[0-9a-fA-F]{4,128}"
title="hex hash (4-128 chars)">
<button type="submit">resolve</button>
<span id="hash-resolve-status" class="cmd-hash-status"></span>
</form>
<div class="cmd-bar-right">
<span class="cmd-pill cmd-pill-shards"
title="arborist shard files mounted (one per arborist routing bucket)">
shards: {{ shards|length if shards else 0 }}
</span>
<span class="cmd-pill"
title="document_roots indexed across every shard">
roots: {{ "{:,}".format(shard_stats.docs) if shard_stats else 0 }}
</span>
<span class="cmd-pill" title="audit chain length summed across shards">
audit: {{ "{:,}".format(shard_stats.audit_events) if shard_stats else 0 }}
</span>
<span class="cmd-pill cmd-pill-{{ 'on' if reveal_private else 'off' }}"
title="privacy default-deny — §14">
private: {{ 'reveal' if reveal_private else 'deny' }}
</span>
<span class="cmd-pill" title="git commit">{{ request.git_commit }}</span>
</div>
</header>
<main class="cmd-main">
{% block body %}{% endblock %}
</main>
<footer class="cmd-foot">
<span>ticket #000069 — Arborist VIZ / Merkle Command Center</span>
<span class="cmd-sep">·</span>
<span>state projected, never asserted</span>
<span class="cmd-sep">·</span>
<span>soft signals never enter the proof path</span>
</footer>
<script type="module">
import { wireHashDrop } from "{{ request.static_url('arborist_viz:static/viz/hash-drop.js') }}?v={{ request.static_version }}";
wireHashDrop(document.getElementById("hash-drop"));
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,59 @@
{% extends "base.jinja2" %}
{% block body %}
<div class="dash"
data-dashboard-id="{{ dashboard_id }}"
data-sse-url="{{ request.route_path('sse_audit_live') }}">
<aside class="dash-side">
<h2 class="dash-h2">Dashboards</h2>
<ul class="dash-list" id="dash-list">
<li><a href="/dashboard/default">Audit Operations</a></li>
<li><a href="/dashboard/graveyard">Claim Graveyard</a></li>
<li><a href="/dashboard/proof">Merkle Root Proof</a></li>
</ul>
<h2 class="dash-h2">
Roots <span class="muted">({{ shard_stats.docs if shard_stats else 0 }})</span>
</h2>
<ul class="dash-list dash-roots" id="dash-roots">
<li class="muted">loading…</li>
</ul>
<h2 class="dash-h2">Live stream</h2>
<div class="dash-stream" id="sse-status">
<span class="dot dot-off"></span>
<span class="lbl">disconnected</span>
</div>
<pre class="dash-events" id="sse-events" aria-live="polite"></pre>
</aside>
<section class="dash-grid" id="dash-grid">
<!-- Widgets render here. Loaded via /api/dashboards/{dashboard_id}. -->
<article class="widget widget-empty" data-widget-id="loading">
<header class="widget-head">
<span class="widget-title">loading dashboard…</span>
</header>
<div class="widget-body">
<p>Fetching <code>/api/dashboards/{{ dashboard_id }}</code></p>
</div>
</article>
</section>
</div>
{% endblock %}
{% block scripts %}
<script type="module">
import { mountDashboard } from "{{ request.static_url('arborist_viz:static/viz/dashboard.js') }}?v={{ request.static_version }}";
import { connectSSE } from "{{ request.static_url('arborist_viz:static/viz/sse-client.js') }}?v={{ request.static_version }}";
const root = document.querySelector(".dash");
const id = root.dataset.dashboardId;
const sseUrl = root.dataset.sseUrl;
mountDashboard(root, id);
connectSSE(sseUrl, {
statusEl: document.getElementById("sse-status"),
eventsEl: document.getElementById("sse-events"),
});
</script>
{% endblock %}

View file

@ -0,0 +1,95 @@
{% extends "base.jinja2" %}
{% block body %}
<div class="proof"
data-root-hash="{{ root_hash }}"
data-leaf-index="{{ leaf_index or '' }}"
data-cache-key="{{ cache_key or '' }}">
<aside class="dash-side">
<h2 class="dash-h2">Root</h2>
<pre class="root-hash-block" id="root-hash">{{ root_hash | hex_chunk }}</pre>
<h2 class="dash-h2">Inclusion proof</h2>
<div id="proof-card" class="proof-card">
<p class="muted">select a leaf to compute its inclusion proof…</p>
</div>
<h2 class="dash-h2">Leaf content
<span class="muted" id="content-mode-label"></span>
</h2>
<div class="content-toggle" id="content-toggle" hidden>
<button type="button" data-mode="prose" class="active">prose</button>
<button type="button" data-mode="raw">raw</button>
<span class="muted" id="content-base-version"></span>
</div>
<div id="leaf-content" class="leaf-content muted">select a leaf to view its chunk text…</div>
<h2 class="dash-h2">Audit linkage</h2>
<div id="audit-link" class="muted">looking up…</div>
<h2 class="dash-h2">Live stream</h2>
<div class="dash-stream" id="sse-status">
<span class="dot dot-off"></span>
<span class="lbl">disconnected</span>
</div>
<pre class="dash-events" id="sse-events" aria-live="polite"></pre>
</aside>
<section class="proof-stage">
<div class="lattice-bar">
<span class="widget-type">[merkle-lattice-3d]</span>
<span id="lattice-status">loading…</span>
<button id="btn-pulse" disabled>pulse proof path</button>
<button id="btn-reset">reset</button>
<span class="lattice-legend muted">
<span class="legend-swatch swatch-leaf"></span>leaves
<span class="legend-swatch swatch-interior"></span>interior
<span class="legend-swatch swatch-root"></span>root
<span class="legend-swatch swatch-selected"></span>selected
</span>
</div>
<div id="lattice-canvas" class="lattice-canvas"></div>
<div id="node-inspect" class="node-inspect muted">click any node — leaves compute an inclusion proof, interior + root reveal their hash here.</div>
<div class="leaves-strip" id="leaves-strip">
<span class="muted">leaves load with the tree…</span>
</div>
<!-- Context-root variant: shown only when /api/root/{hash} returns
kind=context_root. The Merkle lattice + leaves strip still render
the tree; this panel adds the QA card + evidence manifest. Each
source is stamped with its 0-based context-tree leaf index so the
manifest, leaf chips, and inclusion proof all agree. -->
<article id="context-panel" class="widget" hidden>
<header class="widget-head">
<span class="widget-type">[context-root]</span>
<span class="widget-title">multi-source evidence</span>
</header>
<div class="widget-body">
<div id="qa-card" class="qa-card" hidden></div>
<ul id="sources-list" class="sources-list"></ul>
</div>
</article>
</section>
</div>
{% endblock %}
{% block scripts %}
<script type="module">
import { mountProofPage } from "{{ request.static_url('arborist_viz:static/viz/proof-page.js') }}?v={{ request.static_version }}";
import { connectSSE } from "{{ request.static_url('arborist_viz:static/viz/sse-client.js') }}?v={{ request.static_version }}";
const root = document.querySelector(".proof");
const rootHash = root.dataset.rootHash;
const leafIndex = root.dataset.leafIndex ? parseInt(root.dataset.leafIndex, 10) : null;
const cacheKey = root.dataset.cacheKey || null;
mountProofPage(rootHash, leafIndex, cacheKey);
connectSSE(`/sse/root/${encodeURIComponent(rootHash)}`, {
statusEl: document.getElementById("sse-status"),
eventsEl: document.getElementById("sse-events"),
});
</script>
{% endblock %}

View file

@ -0,0 +1,17 @@
<article class="widget widget-audit-timeline" data-widget-id="{{ widget.widget_id }}">
<header class="widget-head">
<span class="widget-type">[audit-timeline]</span>
<span class="widget-title">{{ widget.title }}</span>
</header>
<div class="widget-body">
<ol class="audit-chain" id="audit-chain-live">
<li class="muted">waiting for SSE events on /sse/audit/live…</li>
</ol>
</div>
<footer class="widget-foot">
<details>
<summary>query + source roots</summary>
<pre>{{ widget.query | tojson(indent=2) }}</pre>
</details>
</footer>
</article>

View file

@ -0,0 +1,22 @@
<article class="widget widget-claim-warrant" data-widget-id="{{ widget.widget_id }}">
<header class="widget-head">
<span class="widget-type">[claim-warrant]</span>
<span class="widget-title">{{ widget.title }}</span>
</header>
<div class="widget-body">
<ul class="warrant-ladder">
<li>UNGROUNDED</li>
<li>POINTER_LINKED</li>
<li>ANCHOR_WARRANTED</li>
<li>EVIDENCE_WARRANTED</li>
<li>FORMALLY_PROVED</li>
</ul>
<p class="muted">claim warrant levels — render real claims once shards land.</p>
</div>
<footer class="widget-foot">
<details>
<summary>query + source roots</summary>
<pre>{{ widget.query | tojson(indent=2) }}</pre>
</details>
</footer>
</article>

View file

@ -0,0 +1,18 @@
<article class="widget widget-root-explorer" data-widget-id="{{ widget.widget_id }}">
<header class="widget-head">
<span class="widget-type">[root-explorer]</span>
<span class="widget-title">{{ widget.title }}</span>
</header>
<div class="widget-body">
<table class="widget-table">
<thead><tr><th>root</th><th>leaves</th><th>created</th></tr></thead>
<tbody><tr><td colspan="3" class="muted">waiting on arborist shard wiring</td></tr></tbody>
</table>
</div>
<footer class="widget-foot">
<details>
<summary>query + source roots</summary>
<pre>{{ widget.query | tojson(indent=2) }}</pre>
</details>
</footer>
</article>

View file

@ -0,0 +1,21 @@
<article class="widget widget-run-dag" data-widget-id="{{ widget.widget_id }}">
<header class="widget-head">
<span class="widget-type">[run-dag]</span>
<span class="widget-title">{{ widget.title }}</span>
</header>
<div class="widget-body">
<ol class="dag-stages">
<li>Question</li><li>Preflight</li><li>Retrieval</li>
<li>EvidenceMap</li><li>AnswerDraft</li><li>ClaimExtraction</li>
<li>Verifier</li><li>FalsifierSearch</li><li>CacheWrite</li>
<li>AuditAppend</li><li>StateAdmission</li>
</ol>
<p class="muted">run pipeline stages from ticket §6.5 — replay once a run is loaded.</p>
</div>
<footer class="widget-foot">
<details>
<summary>query + source roots</summary>
<pre>{{ widget.query | tojson(indent=2) }}</pre>
</details>
</footer>
</article>

View file

View file

@ -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)

View file

@ -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"

View file

@ -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]]

View file

@ -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.
"""

276
arborist_viz/views/audit.py Normal file
View file

@ -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,
)

View file

@ -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."
),
}
)

View file

@ -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})

106
arborist_viz/views/home.py Normal file
View file

@ -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")

View file

@ -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],
})

244
arborist_viz/views/roots.py Normal file
View file

@ -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 "/"

219
arborist_viz/views/sse.py Normal file
View file

@ -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)),
)

109
development.ini Normal file
View file

@ -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

343
docs/style-guide.md Normal file
View file

@ -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 `<details>`
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="<full hash>"` for hover (desktop)
- a visible click-to-copy button
- a "show full" affordance (`<details>` 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 <color>`) 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.
- [ ] `<form>`, `<button>`, `<a>` used semantically; no `<div onclick>`.
- [ ] All icons either have `aria-label` or are decorative
(`aria-hidden="true"`).
- [ ] Page works with images blocked.
- [ ] Page works at 200% browser zoom without horizontal scroll on the
main grid.
- [ ] Page degrades to a single column at 720px width.
---
## 7. Anti-patterns (do not do)
- **Light-mode-only.** Operators run dark by default. A light theme may
come later; design dark first.
- **Decorative gradients on text.** Hashes need pixel-perfect glyphs.
- **Tooltip-only labels.** A pill that only reveals meaning on hover is
invisible on touch and to screen readers. Always pair with on-screen
text or `aria-label`.
- **Modals.** This is a command center; context is read-only most of the
time. Prefer side panels and inline detail sections over modal
interruption.
- **Confirmation prompts on read actions.** "Are you sure you want to
view this audit event?" — no. Confirm only on writes.
- **Custom scrollbars.** Native scrollbars indicate scrollable regions to
assistive tech; styled scrollbars often hide on mobile.
- **Icon-only buttons in the chrome bar.** Pair every icon with a word.
Recognition beats recall.
---
## 8. Source of truth
When this document and `viz.css` disagree, **`viz.css` wins** — the artifact
is ground truth. Open a PR to reconcile.

78
production.ini Normal file
View file

@ -0,0 +1,78 @@
###
# arborist-viz — Merkle Command Center (production)
###
[alembic]
script_location = arborist_viz/scripts/alembic
sqlalchemy.url = sqlite:///%(here)s/data/arborist_viz.sqlite
[app:main]
use = egg:arborist-viz
sqlalchemy.url = sqlite:///%(here)s/data/arborist_viz.sqlite
arborist.shards =
pyramid.reload_templates = false
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.hashalg = sha512
# OVERRIDE in deployed environment via env / secrets manager.
session.secret = CHANGE-ME-IN-PROD
session.timeout = 86400
session.max_age = 86400
session.reissue_time = 7200
session.samesite = strict
session.secure = True
privacy.reveal_private_leaves = false
sse.heartbeat_seconds = 15
sse.max_event_bytes = 65536
[server:main]
use = egg:waitress#main
listen = 127.0.0.1:6543
[loggers]
keys = root, arborist_viz, sqlalchemy
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_arborist_viz]
level = INFO
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

4
pytest.ini Normal file
View file

@ -0,0 +1,4 @@
[pytest]
testpaths = arborist_viz/tests
python_files = test_*.py
addopts = -ra --tb=short

2
requirements-dev.txt Normal file
View file

@ -0,0 +1,2 @@
-r requirements.txt
pyramid_debugtoolbar>=4.10

3
requirements-test.txt Normal file
View file

@ -0,0 +1,3 @@
-r requirements.txt
pytest>=8.0
WebTest>=3.0

8
requirements.txt Normal file
View file

@ -0,0 +1,8 @@
pyramid>=2.0
pyramid_jinja2>=2.10
pyramid_tm>=2.5
pyramid_retry>=2.1
SQLAlchemy>=2.0
alembic>=1.13
waitress>=3.0
zope.sqlalchemy>=3.1

85
setup.py Normal file
View file

@ -0,0 +1,85 @@
import os
from setuptools import setup, find_packages
def parse_requirements(filename):
req_path = os.path.join(os.path.dirname(__file__), filename)
if not os.path.exists(req_path):
return []
with open(req_path, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
out = []
for line in lines:
s = line.strip()
if not s or s.startswith("#"):
continue
# setuptools' extras_require doesn't understand pip's "-r other.txt"
# syntax — recurse manually instead.
if s.startswith("-r ") or s.startswith("--requirement "):
nested = s.split(None, 1)[1].strip()
out.extend(parse_requirements(nested))
continue
out.append(s)
return out
here = os.path.abspath(os.path.dirname(__file__))
with open(os.path.join(here, "README.rst"), "r", encoding="utf-8") as f:
long_description = f.read()
install_requires = parse_requirements("requirements.txt")
extras_require = {
"dev": parse_requirements("requirements-dev.txt"),
"test": parse_requirements("requirements-test.txt"),
}
setup(
name="arborist-viz",
version="0.0.1",
description=(
"Arborist VIZ / Merkle Command Center — a configurable proof-native "
"browser dashboard for inspecting arborist's content-addressed state."
),
long_description=long_description,
long_description_content_type="text/x-rst",
author="Russell Ballestrini",
author_email="russell@unturf.com",
url="https://git.unturf.com/engineering/arborist-viz",
keywords="merkle proof audit dashboard pyramid arborist provenance",
license="AGPL-3.0-only",
include_package_data=True,
packages=find_packages(exclude=["tests"]),
package_data={
"arborist_viz": [
"templates/*.jinja2",
"templates/widgets/*.jinja2",
"static/six/*",
"static/viz/*",
"static/css/*",
"scripts/alembic/*.py",
"scripts/alembic/*.ini",
"scripts/alembic/versions/*.py",
]
},
zip_safe=False,
install_requires=install_requires,
extras_require=extras_require,
entry_points={
"paste.app_factory": ["main = arborist_viz:main"],
"console_scripts": [
"arborist_viz_init_db = arborist_viz.scripts.init_db:main",
"arborist_viz_seed_demo = arborist_viz.scripts.seed_demo:main",
],
},
classifiers=[
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Framework :: Pyramid",
"License :: OSI Approved :: GNU Affero General Public License v3",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Internet :: WWW/HTTP :: WSGI :: Application",
],
)