proof dispatch: every hash kind through /proof/{hash}, no kind-named URLs

Two fixes to fox feedback ("rarely like stuff collapsed · why does it
need to be a different screen than the other node types?"):

1. <details open> on answer + metadata panels in sessions_tree.jinja2.
   Expanded by default — fox rarely wants stuff collapsed; clicks to
   collapse cost nothing and discoverability of the field is what
   matters on first read.

2. /proof/{hash} is now THE dispatcher — drop ANY hash here and the
   page resolves the right view:

     - corpus document_root / leaf / merkle interior  → proof.jinja2
       (existing 3D Merkle lattice + inclusion proof + leaf-list)
     - providence cache_key with session references → cache_key.jinja2
       (every session node that hit this cache entry)
     - session node_hash / subtree_hash → sessions_tree.jinja2
       (the session's conversation tree)
     - session_root (= seq=0 node's subtree_hash) → sessions_tree.jinja2

   Aligns with ticket #000069 §13 discipline: "never mint new
   kind-named URLs." /sessions/{sid} and /cache/{cache_key} stay as
   working aliases — but the resolver, the metadata-panel links, and
   the hash-drop bar all route through /proof/{hash} now.

Sessions are still tree-shaped (parent/child) where corpus roots are
flat leaf lists, so the IN-PAGE view differs. That's a topology fact,
not a URL choice — sessions and documents live in the same hash
universe under one route. A future six.js tree widget could share
the proof.jinja2 3D stage; not in this commit.

Resolver:
- /api/resolve/{hash} → "url": "/proof/{hash}" for cache_keys and
  session node hashes (was /cache/<key> and /sessions/<sid>).

Templates:
- sessions_tree node-card cache_key + node_hash + subtree_hash are
  now clickable → each opens /proof/<hash>.
- <details> on answer + hash panels default-open.

Smoke verified all four dispatch paths:
  /proof/<cache_key>    → cache_key.jinja2
  /proof/<session_root> → sessions_tree.jinja2
  /proof/<node_hash>    → sessions_tree.jinja2
  /proof/<unknown>      → proof.jinja2 (corpus fallback)
This commit is contained in:
russell@unturf.com 2026-06-01 18:08:00 -04:00
parent f78eab6fa4
commit cb6cab0886
No known key found for this signature in database
3 changed files with 125 additions and 14 deletions

View file

@ -21,21 +21,21 @@
{% endif %}
{% if node.answer_text %}
<details class="cmd-node-answer">
<details class="cmd-node-answer" open>
<summary>answer ({{ node.answer_text|length }} chars)</summary>
<pre class="cmd-answer-pre">{{ node.answer_text }}</pre>
</details>
{% endif %}
<details class="cmd-node-meta">
<details class="cmd-node-meta" open>
<summary>hashes / metadata</summary>
<dl class="cmd-meta-dl">
<dt>cache_key</dt>
<dd>{% if node.cache_key %}<a href="/cache/{{ node.cache_key }}"><code class="cmd-hash">{{ node.cache_key }}</code></a>{% else %}<em>(none — canonical_projection)</em>{% endif %}</dd>
<dd>{% if node.cache_key %}<a href="/proof/{{ node.cache_key }}"><code class="cmd-hash">{{ node.cache_key }}</code></a>{% else %}<em>(none — canonical_projection)</em>{% endif %}</dd>
<dt>node_hash</dt>
<dd><code class="cmd-hash">{{ node.node_hash }}</code></dd>
<dd><a href="/proof/{{ node.node_hash }}"><code class="cmd-hash">{{ node.node_hash }}</code></a></dd>
<dt>subtree_hash</dt>
<dd><code class="cmd-hash">{{ node.subtree_hash }}</code></dd>
<dd><a href="/proof/{{ node.subtree_hash }}"><code class="cmd-hash">{{ node.subtree_hash }}</code></a></dd>
<dt>created</dt>
<dd>{{ node.created_at }}</dd>
{% if node.parent_bates %}

View file

@ -54,22 +54,133 @@ def dashboard_page(request):
}
@view_config(route_name="proof_page", renderer="proof.jinja2")
@view_config(route_name="proof_page")
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"]
"""Single-root visualizer. Dispatches by hash kind:
- corpus document_root / leaf / merkle interior proof.jinja2
(the 3D Merkle lattice + inclusion proof + leaf-list view)
- session node_hash / subtree_hash sessions_tree.jinja2
(the conversation-tree view, scrolled to the matching node)
- providence cache_key cache_key.jinja2
(every session node referencing this cache hit)
All hashes drop through ONE URL `/proof/{hash}` per the
ticket's "never mint new kind-named URLs" discipline.
"""
from pyramid.renderers import render_to_response
root_hash = request.matchdict["root_hash"].strip().lower()
leaf_param = request.GET.get("leaf")
cache_key = request.GET.get("cache_key")
return {
# Sessions dispatch — check before falling through to the corpus
# proof renderer. A session_root or session node_hash here means
# the hash IS the answer; the corpus proof page can't help.
sessions = request.sessions
if sessions is not None and sessions.is_open:
# session cache_key? show all referencing nodes.
ck_hits = sessions.find_by_cache_key(root_hash)
if ck_hits:
ctx = _common_ctx(request)
ctx["title"] = "Arborist VIZ — cache_key {0}".format(
root_hash[:12]
)
ctx["cache_key"] = root_hash
ctx["hits"] = ck_hits
ctx["sessions_stats"] = _sessions_stats_for_ctx(request)
return render_to_response(
"cache_key.jinja2", ctx, request=request
)
# session node hash (node_hash or subtree_hash)?
node = _find_session_node_by_hash(sessions, root_hash)
if node is not None:
return _render_session_in_proof(
request, node.sid, focus_bates=node.bates,
)
# session_root? (= subtree_hash of a session's root node)
sid_for_root = _find_sid_by_session_root(sessions, root_hash)
if sid_for_root is not None:
return _render_session_in_proof(request, sid_for_root)
# Default: corpus root. Hand off to proof.jinja2.
ctx = {
"title": "Arborist VIZ — root {0}".format(root_hash[:12]),
"root_hash": root_hash,
"leaf_index": leaf_param,
"cache_key": cache_key,
**_common_ctx(request),
}
return render_to_response("proof.jinja2", ctx, request=request)
def _sessions_stats_for_ctx(request) -> dict:
s = request.sessions
if s is None or not s.is_open:
return {"n_sessions": 0, "n_nodes": 0,
"n_audit_events": 0, "open": False}
c = s.counts()
return {
"n_sessions": c.n_sessions, "n_nodes": c.n_nodes,
"n_audit_events": c.n_audit_events,
"open": True, "path": c.shard_path,
}
def _find_session_node_by_hash(sessions, h: str):
"""Lookup by node_hash OR subtree_hash. Returns first match or None."""
try:
r = sessions._conn.execute( # type: ignore[attr-defined]
"SELECT * FROM nodes WHERE node_hash=? OR subtree_hash=? "
"LIMIT 1",
(h, h),
).fetchone()
except Exception:
return None
if not r:
return None
from arborist.read import _row_to_session_node
return _row_to_session_node(r)
def _find_sid_by_session_root(sessions, h: str):
"""A session_root is the synthetic root node's subtree_hash — so
look for a seq=0 node with that subtree_hash."""
try:
r = sessions._conn.execute( # type: ignore[attr-defined]
"SELECT sid FROM nodes WHERE seq=0 AND subtree_hash=? LIMIT 1",
(h,),
).fetchone()
except Exception:
return None
return r["sid"] if r else None
def _render_session_in_proof(request, sid, focus_bates=None):
"""Render the session tree inside the unified /proof/{hash} dispatch.
Re-uses sessions_tree.jinja2 so the visualization is consistent
whether you arrived via /proof/{session_root}, /proof/{node_hash},
or directly via /sessions/{sid}."""
from pyramid.renderers import render_to_response
sessions = request.sessions
meta = sessions.get_session(sid)
if meta is None:
return _common_ctx(request)
nodes = sessions.all_nodes_in_session(sid)
children: dict[str, list] = {}
for n in nodes:
if n.parent_bates:
children.setdefault(n.parent_bates, []).append(n)
branches = sessions.branches_in_session(sid)
ctx = _common_ctx(request)
ctx["title"] = "Arborist VIZ — session {0}".format(sid)
ctx["session"] = meta
ctx["nodes"] = nodes
ctx["children"] = children
ctx["branches"] = set(branches)
ctx["focus_bates"] = focus_bates
ctx["sessions_stats"] = _sessions_stats_for_ctx(request)
return render_to_response("sessions_tree.jinja2", ctx, request=request)
@view_config(route_name="audit_page", renderer="audit.jinja2")

View file

@ -228,7 +228,7 @@ def resolve_hash(request):
"bates_list": [n.bates for n in hits],
"audit_modes": sorted({n.audit_mode for n in hits}),
"question": first.question,
"url": "/cache/{0}".format(h),
"url": "/proof/{0}".format(h),
})
# node_hash / subtree_hash search: scan one row.
# (No index on these; rare path — direct query.)
@ -254,7 +254,7 @@ def resolve_hash(request):
"sid": row["sid"],
"audit_mode": row["audit_mode"],
"question": row["question"],
"url": "/sessions/{0}".format(row["sid"]),
"url": "/proof/{0}".format(h),
})
return _json({"kind": "unknown", "hash": h, "url": "/"})