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:
commit
0cd96ab1d7
55 changed files with 5985 additions and 0 deletions
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal 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
28
CLAUDE.md
Normal 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
9
MANIFEST.in
Normal 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
134
Makefile
Normal 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
103
README.rst
Normal 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
153
arborist_viz/__init__.py
Normal 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()
|
||||
42
arborist_viz/models/__init__.py
Normal file
42
arborist_viz/models/__init__.py
Normal 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
62
arborist_viz/routes.py
Normal 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
376
arborist_viz/schemas.py
Normal 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"
|
||||
0
arborist_viz/scripts/__init__.py
Normal file
0
arborist_viz/scripts/__init__.py
Normal file
0
arborist_viz/scripts/alembic/__init__.py
Normal file
0
arborist_viz/scripts/alembic/__init__.py
Normal file
51
arborist_viz/scripts/alembic/env.py
Normal file
51
arborist_viz/scripts/alembic/env.py
Normal 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()
|
||||
24
arborist_viz/scripts/alembic/script.py.mako
Normal file
24
arborist_viz/scripts/alembic/script.py.mako
Normal 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"}
|
||||
0
arborist_viz/scripts/alembic/versions/__init__.py
Normal file
0
arborist_viz/scripts/alembic/versions/__init__.py
Normal file
37
arborist_viz/scripts/init_db.py
Normal file
37
arborist_viz/scripts/init_db.py
Normal 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()
|
||||
161
arborist_viz/scripts/seed_demo.py
Normal file
161
arborist_viz/scripts/seed_demo.py
Normal 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()
|
||||
612
arborist_viz/static/css/viz.css
Normal file
612
arborist_viz/static/css/viz.css
Normal 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;
|
||||
}
|
||||
35
arborist_viz/static/viz/audit-page.js
Normal file
35
arborist_viz/static/viz/audit-page.js
Normal 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;
|
||||
}
|
||||
140
arborist_viz/static/viz/claim-graph.js
Normal file
140
arborist_viz/static/viz/claim-graph.js
Normal 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";
|
||||
}
|
||||
}
|
||||
242
arborist_viz/static/viz/dashboard.js
Normal file
242
arborist_viz/static/viz/dashboard.js
Normal 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) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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)),
|
||||
);
|
||||
}
|
||||
39
arborist_viz/static/viz/hash-drop.js
Normal file
39
arborist_viz/static/viz/hash-drop.js
Normal 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;
|
||||
}
|
||||
32
arborist_viz/static/viz/hash.js
Normal file
32
arborist_viz/static/viz/hash.js
Normal 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)}`;
|
||||
}
|
||||
377
arborist_viz/static/viz/merkle-lattice.js
Normal file
377
arborist_viz/static/viz/merkle-lattice.js
Normal 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);
|
||||
}
|
||||
335
arborist_viz/static/viz/proof-page.js
Normal file
335
arborist_viz/static/viz/proof-page.js
Normal 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) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
})[c]);
|
||||
}
|
||||
82
arborist_viz/static/viz/sse-client.js
Normal file
82
arborist_viz/static/viz/sse-client.js
Normal 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);
|
||||
}
|
||||
}
|
||||
221
arborist_viz/static/viz/web-components.js
Normal file
221
arborist_viz/static/viz/web-components.js
Normal 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
65
arborist_viz/storage.py
Normal 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()
|
||||
53
arborist_viz/templates/audit.jinja2
Normal file
53
arborist_viz/templates/audit.jinja2
Normal 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 %}
|
||||
70
arborist_viz/templates/base.jinja2
Normal file
70
arborist_viz/templates/base.jinja2
Normal 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>
|
||||
59
arborist_viz/templates/dashboard.jinja2
Normal file
59
arborist_viz/templates/dashboard.jinja2
Normal 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 %}
|
||||
95
arborist_viz/templates/proof.jinja2
Normal file
95
arborist_viz/templates/proof.jinja2
Normal 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 %}
|
||||
17
arborist_viz/templates/widgets/audit_timeline.jinja2
Normal file
17
arborist_viz/templates/widgets/audit_timeline.jinja2
Normal 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>
|
||||
22
arborist_viz/templates/widgets/claim_warrant.jinja2
Normal file
22
arborist_viz/templates/widgets/claim_warrant.jinja2
Normal 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>
|
||||
18
arborist_viz/templates/widgets/root_explorer.jinja2
Normal file
18
arborist_viz/templates/widgets/root_explorer.jinja2
Normal 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>
|
||||
21
arborist_viz/templates/widgets/run_dag.jinja2
Normal file
21
arborist_viz/templates/widgets/run_dag.jinja2
Normal 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>
|
||||
0
arborist_viz/tests/__init__.py
Normal file
0
arborist_viz/tests/__init__.py
Normal file
77
arborist_viz/tests/conftest.py
Normal file
77
arborist_viz/tests/conftest.py
Normal 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)
|
||||
85
arborist_viz/tests/test_schemas.py
Normal file
85
arborist_viz/tests/test_schemas.py
Normal 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"
|
||||
237
arborist_viz/tests/test_smoke.py
Normal file
237
arborist_viz/tests/test_smoke.py
Normal 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]]
|
||||
3
arborist_viz/views/__init__.py
Normal file
3
arborist_viz/views/__init__.py
Normal 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
276
arborist_viz/views/audit.py
Normal 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,
|
||||
)
|
||||
81
arborist_viz/views/claims.py
Normal file
81
arborist_viz/views/claims.py
Normal 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."
|
||||
),
|
||||
}
|
||||
)
|
||||
244
arborist_viz/views/dashboards.py
Normal file
244
arborist_viz/views/dashboards.py
Normal 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
106
arborist_viz/views/home.py
Normal 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")
|
||||
51
arborist_viz/views/proofs.py
Normal file
51
arborist_viz/views/proofs.py
Normal 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
244
arborist_viz/views/roots.py
Normal 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
219
arborist_viz/views/sse.py
Normal 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
109
development.ini
Normal 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
343
docs/style-guide.md
Normal 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
78
production.ini
Normal 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
4
pytest.ini
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[pytest]
|
||||
testpaths = arborist_viz/tests
|
||||
python_files = test_*.py
|
||||
addopts = -ra --tb=short
|
||||
2
requirements-dev.txt
Normal file
2
requirements-dev.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
-r requirements.txt
|
||||
pyramid_debugtoolbar>=4.10
|
||||
3
requirements-test.txt
Normal file
3
requirements-test.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
-r requirements.txt
|
||||
pytest>=8.0
|
||||
WebTest>=3.0
|
||||
8
requirements.txt
Normal file
8
requirements.txt
Normal 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
85
setup.py
Normal 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",
|
||||
],
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue