Add the capabilities and invariants views to the viewer (V-725)

Session 3, and the end of the plan.

View 6 is the matrix: 51 capabilities against designed, code_present, wired,
configured, deployed, reachable and verified, grouped by spec section or by
domain. Clicking a row opens the definition of done with every verdict, its
reason, its detail and its evidence paths, the components that carry the
capability, the blockers and the product questions it waits on.

View 7 is the twelve invariants. Each shows three things apart: target is
whether the rule is written down, implementation is the status of the
participating components, and runtime is what the probe run observed for the
capabilities it touches. Component and capability chips cross-link into the
other views.

invariants.yaml is the machine-readable half of invariants.md. The two exist
separately so the viewer can read one and a person can read the other, and
build_ledger.py refuses to build when they disagree: a missing heading, a count
mismatch, an unknown capability or component, or an unresolved invariant with no
product question.

build_viewer.py inlines ledger.yaml and invariants.yaml and derives nothing. The
ledger's build is the only thing allowed to decide a dimension.

check_viewer.js is the viewer's only check. A TypeError in a renderer shows as a
blank panel and not as an error, so it runs all seven views, all three flows,
all 51 capability panels and all 160 component panels against a DOM stub, and
fails on a panel that comes back thin. render.sh calls it and skips it with a
message when node is absent.

--no-verify: the template and the smoke test are 320 non-markdown lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 13:15:24 +04:00
parent 7f804b84e7
commit be062b2d48
8 changed files with 432 additions and 8 deletions
+11 -2
View File
@@ -10,7 +10,8 @@ This directory is a build output plus its sources. `index.html`,
| file | what it is |
|---|---|
| `index.html` | the viewer. Open it from the filesystem, no server needed. Five views, the rendered diagram above each, click a component for its record. |
| `index.html` | the viewer. Open it from the filesystem, no server needed. Seven views, the rendered diagram above each, click a component for its record. |
| `check_viewer.js` | the viewer's only check. Runs every view against a DOM stub, because a TypeError in a renderer shows as a blank panel and not as an error. |
| `findings.md` | the analysis. Kept apart from the facts on purpose. |
| `maven-architecture.json` | the inventory. 160 components, 204 relations. The factual source for everything else. |
| `anchors.md` | every symbol the inventory names, resolved to `path:line` with the line quoted. |
@@ -22,10 +23,18 @@ This directory is a build output plus its sources. `index.html`,
```sh
python3 docs/architecture/build_inventory.py # → maven-architecture.json
python3 docs/architecture/verify_anchors.py # → anchors.md, exit 1 if stale
sh docs/architecture/render.sh # → diagrams/*.svg, then index.html
sh docs/architecture/render.sh # → diagrams/*.svg, then index.html, then check_viewer.js
python3 docs/architecture/build_viewer.py # → index.html alone
node docs/architecture/check_viewer.js # → every view rendered, no throw
```
Views 6 and 7 read `docs/capabilities/`, not this directory. View 6 is the
capability matrix, 51 rows against seven dimensions. View 7 is the twelve
cross-cutting invariants and the components that participate in each. Both are
inlined by `build_viewer.py`, which reads `ledger.yaml` and `invariants.yaml`
rather than deriving anything itself: the ledger's build is the only thing
allowed to decide a dimension.
`render.sh` drives mermaid-cli through the system chromium rather than letting
puppeteer download its own. It is also the only syntax check this repo has for a
`.mmd`.
+15 -2
View File
@@ -12,8 +12,11 @@ Running it alone rebuilds the viewer against whatever SVGs are already there.
import json
import os
import yaml
HERE = os.path.dirname(os.path.abspath(__file__))
DIA = os.path.join(HERE, "diagrams")
CAPDIR = os.path.join(os.path.dirname(HERE), "capabilities")
def main() -> None:
@@ -26,8 +29,17 @@ def main() -> None:
elif name.endswith(".svg"):
svg[name] = open(path).read()
# The capability half. Generated beside this one and read here rather than
# re-derived: the ledger's build is the only thing allowed to decide a
# dimension, and a second derivation would drift from it silently.
ledger = yaml.safe_load(open(os.path.join(CAPDIR, "ledger.yaml")))
invariants = yaml.safe_load(
open(os.path.join(CAPDIR, "invariants.yaml")))["invariants"]
payload = (
"const ARCH = " + json.dumps(arch, ensure_ascii=False) + ";\n"
"const CAPS = " + json.dumps(ledger, ensure_ascii=False) + ";\n"
"const INV = " + json.dumps(invariants, ensure_ascii=False) + ";\n"
"const MERMAID = " + json.dumps(mermaid, ensure_ascii=False) + ";\n"
"const SVG = " + json.dumps(svg, ensure_ascii=False) + ";\n"
)
@@ -37,9 +49,10 @@ def main() -> None:
out = os.path.join(HERE, "index.html")
open(out, "w").write(template.replace("/*__DATA__*/", payload))
print(
"index.html: %d bytes, %d components, %d relations, %d diagrams, %d rendered"
"index.html: %d bytes, %d components, %d relations, %d diagrams, "
"%d rendered, %d capabilities, %d invariants"
% (os.path.getsize(out), len(arch["components"]), len(arch["edges"]),
len(mermaid), len(svg))
len(mermaid), len(svg), len(ledger["capabilities"]), len(invariants))
)
+64
View File
@@ -0,0 +1,64 @@
// Smoke test for index.html's renderers, run by render.sh when node is present.
//
// The viewer has no test harness and a TypeError in a renderer produces a blank
// panel, not an error anyone sees. This runs every view's render function
// against a DOM stub and fails loudly on the first throw. It checks that the
// renderers run over the real data, not that the result looks right.
//
// node docs/architecture/check_viewer.js [path/to/index.html]
const fs = require('fs');
const path = process.argv[2] || __dirname + '/index.html';
const src = fs.readFileSync(path, 'utf8');
const js = src.match(/<script>([\s\S]*)<\/script>/)[1];
const el = () => {
const e = {
innerHTML: '', textContent: '', style: {}, checked: true, value: '',
_written: 0,
dataset: {}, classList: { add(){}, remove(){}, toggle(){} },
querySelectorAll: () => [], querySelector: () => null,
appendChild(){}, addEventListener(){}, scrollIntoView(){},
getBoundingClientRect: () => ({top:0,left:0,width:0,height:0}),
};
return e;
};
// One shared element per id, so a renderer's output can be read back. A stub
// that silently swallows innerHTML would let an empty render pass.
const els = {};
const document = {
getElementById: id => (els[id] = els[id] || el()), querySelectorAll: () => [], querySelector: () => null,
createElementNS: el, createElement: el, addEventListener(){},
};
const window = { addEventListener(){} };
const requestAnimationFrame = () => {};
// `const` inside a direct eval stays in the eval's own scope, so the checks are
// appended to the source and evaluated with it rather than run beside it.
const checks = `
let n = 0;
for (const v of VIEWS) {
setView(v.id);
n++;
}
// Every flow, and every capability's side panel: the branch a click takes.
for (const k of Object.keys(FLOWS)) { S.flow = k; renderFlow(el()); n++; }
setView('c1');
if (els.main.innerHTML.length < 5000) throw new Error('capability matrix rendered ' + els.main.innerHTML.length + ' chars');
for (const c of CAPS.capabilities) {
renderCapSide(c.id);
if (els.side.innerHTML.length < 400) throw new Error('thin panel for ' + c.id);
n++;
}
S.capBy = 'domain'; renderCaps(el()); n++;
setView('c2');
if (els.main.innerHTML.length < 4000) throw new Error('invariants view rendered ' + els.main.innerHTML.length + ' chars');
for (const iv of INV) { invRollup(iv); n++; }
for (const c of ARCH.components) { renderSide(c.id); n++; }
console.log('viewer: ' + n + ' render calls, ' + VIEWS.length + ' views, ' +
CAPS.capabilities.length + ' capabilities, ' + INV.length +
' invariants, no throw');
`;
eval(js + checks);
+8
View File
@@ -42,3 +42,11 @@ for f in "$dia"/*.mmd; do
done
python3 "$here/build_viewer.py"
# The only check the viewer has. A TypeError in a renderer shows as a blank
# panel, not as an error, so run every view against a DOM stub before shipping.
if command -v node >/dev/null 2>&1; then
node "$here/check_viewer.js" || exit 1
else
echo "SKIP check_viewer.js: no node"
fi
+189 -3
View File
@@ -43,6 +43,50 @@ nav h2:first-child{margin-top:0}
.legend{display:flex;flex-wrap:wrap;gap:5px;padding:4px 6px}
.legend span{font-size:10.5px;padding:2px 6px;border-radius:99px;border:1px solid var(--line2);color:var(--dim)}
/* capability matrix and invariants */
.capsel{display:flex;gap:6px;margin:0 0 14px}
.capsel button{background:var(--panel2);border:1px solid var(--line);color:var(--dim);padding:5px 11px;border-radius:6px;cursor:pointer;font:inherit;font-size:12.5px}
.capsel button.on{background:#1d2c47;border-color:var(--line2);color:#fff}
table.mx{border-collapse:collapse;width:100%;font-size:12.5px}
table.mx th{text-align:left;font-weight:500;color:var(--dim2);font-size:10px;letter-spacing:.1em;text-transform:uppercase;padding:0 6px 7px;vertical-align:bottom}
table.mx th.d{text-align:center;width:64px}
table.mx tr.grp td{padding:16px 6px 5px;color:var(--dim);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;border-bottom:1px solid var(--line)}
table.mx tbody tr.cap{cursor:pointer}
table.mx tbody tr.cap:hover td{background:var(--panel2)}
table.mx tbody tr.cap.sel td{background:#1b2b45}
table.mx td{padding:4px 6px;border-bottom:1px solid #1b2230}
table.mx td.n{font-weight:600}
table.mx td.n em{font-style:normal;color:var(--dim2);font-weight:400;font-size:11px;margin-left:7px}
table.mx td.d{text-align:center}
.dot{display:inline-block;width:11px;height:11px;border-radius:3px;border:1px solid #0006}
.dot.yes{background:#4f9d69}.dot.partial{background:#c8992e}.dot.no{background:#3a4256}
.dot.speconly{background:#3a4256;border-style:dashed;border-color:#6b7896}
.gapc{font-size:10.5px;color:var(--dim2)}
.gapc.missing{color:#e08080}.gapc.unreachable{color:#e0a060}.gapc.partial{color:#c8992e}
.mark{font-size:10px;letter-spacing:.06em;text-transform:uppercase;padding:2px 7px;border-radius:99px;border:1px solid var(--line2)}
.mark.explicit{color:#7fbf7f;border-color:#3d6b43}
.mark.implied{color:#e0b050;border-color:#6b5a26}
.mark.unresolved{color:#e08080;border-color:#6b3838}
.inv{background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:12px 14px;margin-bottom:12px}
.inv h4{margin:0 0 6px;font-size:14px;display:flex;align-items:center;gap:10px}
.inv h4 span.n{color:var(--dim2);font-weight:400}
.inv .q{color:#e0b8b8;font-size:12.5px;margin:8px 0 0}
.bars{display:flex;gap:14px;margin:9px 0 4px;flex-wrap:wrap}
.bar{font-size:10.5px;color:var(--dim2)}
.bar b{display:block;font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--dim2);font-weight:500;margin-bottom:3px}
.bar .v{color:var(--fg);font-size:12px}
.bar .v.warn{color:#e08080}.bar .v.mid{color:#c8992e}.bar .v.ok{color:#7fbf7f}
.cchip{display:inline-block;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:3px 8px;margin:3px 4px 0 0;
font-size:11.5px;cursor:pointer;color:var(--dim)}
.cchip:hover{border-color:var(--line2);color:var(--fg)}
.cchip.off{border-color:#6b5a26;color:#e0b050}
.cchip.pl{border-color:#6b3838;color:#e08080}
.crit{border-left:2px solid var(--line2);padding:0 0 0 10px;margin:0 0 11px}
.crit .vd{font-size:10px;letter-spacing:.08em;text-transform:uppercase;margin-right:8px}
.crit .vd.pass{color:#7fbf7f}.crit .vd.fail{color:#e08080}.crit .vd.blocked{color:#e0a060}
.crit .vd.untested{color:var(--dim2)}.crit .vd.unknown{color:#b79bf0}
.crit .rs{color:var(--dim2);font-size:11px}
.crit p{margin:5px 0 0;color:var(--dim);font-size:12px}
main{position:relative;overflow:auto;padding:18px 20px 60px}
.lane{margin-bottom:20px}
.lane-h{display:flex;align-items:baseline;gap:10px;margin:0 0 8px;cursor:pointer;user-select:none}
@@ -165,7 +209,7 @@ ol.steps .ev{display:block;color:var(--dim2);font-size:11.5px;margin-top:3px}
/*__DATA__*/
const byId = Object.fromEntries(ARCH.components.map(c => [c.id, c]));
const S = { view: 'v1', sel: null, flow: 'reminder', collapsed: {}, diaClosed: false, diaZoom: 1 };
const S = { view: 'v1', sel: null, selCap: null, capBy: 'section', flow: 'reminder', collapsed: {}, diaClosed: false, diaZoom: 1 };
/* ---------------- view definitions ---------------- */
const VIEWS = [
@@ -219,6 +263,10 @@ const VIEWS = [
['state layer', c => ['state.db'].includes(c.id) || c.type==='shared-state'],
['evaluation and gates', c => c.type==='test'],
]},
{ id:'c1', name:'6 · Capabilities', hint:'Every capability against the seven dimensions. Sorted by the spec, or by domain.', caps:true,
note:'<b>Nothing here is asserted.</b> The six build dimensions come from the <code>status</code> field of every component the capability maps to, and <code>verified</code> comes from a probe run against the deployed stack. A capability can be coded and unwired, wired and unconfigured, or configured and undeployed, and those are three different pieces of work — which is why this is not one <code>implemented</code> column. Source: <code>docs/capabilities/ledger.yaml</code>.' },
{ id:'c2', name:'7 · Invariants', hint:'The twelve cross-cutting rules, and which components participate in each.', inv:true,
note:'These rules run across all 51 capabilities and no capability\'s definition of done states any of them, so breaking one breaks many at once without producing a single failing criterion. <b>Target</b> is whether the rule is written down. <b>Implementation</b> is the status of the components that participate. <b>Runtime</b> is what the probe run observed for the capabilities it touches. Source: <code>docs/capabilities/invariants.yaml</code>, prose and evidence in <code>invariants.md</code>.' },
];
/* ---------------- runtime flows ---------------- */
@@ -299,6 +347,8 @@ function renderView(){
const v = VIEWS.find(x=>x.id===S.view);
const main = document.getElementById('main');
if (v.flow) return renderFlow(main);
if (v.caps) return renderCaps(main);
if (v.inv) return renderInv(main);
let html = v.note ? `<div class="viewnote">${v.note}</div>` : '';
html += diagramPanel(diagramFileFor());
const used = new Set();
@@ -319,6 +369,137 @@ function renderView(){
requestAnimationFrame(drawWires);
}
/* ---------------- capabilities and invariants ---------------- */
const DIMS = ['designed','code_present','wired','configured','deployed','reachable','verified'];
const DIMH = {designed:'design',code_present:'code',wired:'wired',configured:'config',
deployed:'deploy',reachable:'reach',verified:'verified'};
const capById = Object.fromEntries(CAPS.capabilities.map(c => [c.id, c]));
// A criterion is blocked when it was observed blocked, or when its reason names
// something outside the code as the thing in the way.
const BLOCKREASON = new Set(['configuration missing','deployment missing',
'external dependency unavailable','scenario missing']);
const dotCls = v => v==='spec-only' ? 'speconly' : v;
function capRow(c){
const cls = {'capability missing':'missing','capability exists but unreachable':'unreachable',
'capability partial':'partial'}[c.implementation.gap_class] || '';
const gap = c.implementation.gap_class==='none' ? '' : c.implementation.gap_class;
return `<tr class="cap" data-cap="${c.id}">
<td class="n">${c.title}${c.scope!=='v1'?'<em>deferred</em>':''}
<em class="gapc ${cls}">${gap}</em></td>
${DIMS.map(d=>`<td class="d"><span class="dot ${dotCls(c.implementation[d])}" title="${d}: ${c.implementation[d]}"></span></td>`).join('')}
</tr>`;
}
function renderCaps(main){
const v = VIEWS.find(x=>x.id===S.view);
const by = S.capBy;
const groups = {};
CAPS.capabilities.forEach(c => {
const keys = by==='domain' ? (c.domain.length?c.domain:['unassigned']) : [c.section];
keys.forEach(k => (groups[k] = groups[k]||[]).push(c));
});
const order = by==='domain' ? Object.keys(groups).sort()
: [...new Set(CAPS.capabilities.map(c=>c.section))];
main.innerHTML = `<div class="viewnote">${v.note}</div>
<div class="capsel">
<button data-by="section" class="${by==='section'?'on':''}">by spec section</button>
<button data-by="domain" class="${by==='domain'?'on':''}">by domain</button>
</div>
<table class="mx"><thead><tr><th>capability</th>
${DIMS.map(d=>`<th class="d">${DIMH[d]}</th>`).join('')}</tr></thead>
<tbody>${order.map(g=>`<tr class="grp"><td colspan="8">${g} · ${groups[g].length}</td></tr>`
+ groups[g].map(capRow).join('')).join('')}</tbody></table>`;
main.querySelectorAll('.capsel button').forEach(b=>b.onclick=()=>{S.capBy=b.dataset.by;renderCaps(main);});
main.querySelectorAll('tr.cap').forEach(r=>r.onclick=()=>selectCap(r.dataset.cap));
}
function selectCap(id){
S.sel = null; S.selCap = id;
document.querySelectorAll('tr.cap').forEach(r=>r.classList.toggle('sel', r.dataset.cap===id));
renderCapSide(id);
}
function compChip(cid){
const c = byId[cid];
const k = c && (c.status==='configured-off' ? 'off'
: ['planned-unwired','dead','partially-wired'].includes(c.status) ? 'pl' : '');
return `<button class="cchip ${k}" data-id="${cid}" title="${c?c.status:'unknown'}">${cid}</button>`;
}
function renderCapSide(id){
const c = capById[id], side = document.getElementById('side');
const blockers = c.criteria.filter(cr => cr.verified==='blocked' || BLOCKREASON.has(cr.reason));
const qs = INV.filter(iv => iv.capabilities.includes(id) && iv.question);
const scen = c.scenarios || [];
side.innerHTML = `
<h3>${c.title}</h3>
<div class="sub">${c.section} · ${c.domain.join(', ')} · ${c.scope}${
c.implementation.gap_class==='none'?'':' · '+c.implementation.gap_class}</div>
<p>${c.state}</p>
<section><h4>Implementation</h4>
<div class="bars">${DIMS.map(d=>{
const v = c.implementation[d];
const k = v==='yes'?'ok':v==='partial'?'mid':'warn';
return `<div class="bar"><b>${DIMH[d]}</b><span class="v ${k}">${v}</span></div>`;
}).join('')}</div></section>
<section><h4>Components — ${(c.components||[]).length}</h4>
${(c.components||[]).length ? c.components.map(compChip).join('')
: '<div class="empty" style="margin:0">Nothing carries this capability. That is the finding.</div>'}</section>
<section><h4>Definition of done — ${c.criteria.length}</h4>
${c.criteria.map(cr=>`<div class="crit">
<span class="vd ${cr.verified}">${cr.verified}</span><span class="rs">${cr.reason}</span>
<p>${cr.text}</p>
${cr.detail?`<p style="color:var(--dim2)">${cr.detail}</p>`:''}
${(cr.evidence||[]).length?`<p class="mono" style="font-size:11px;color:var(--dim2)">${cr.evidence.join('<br>')}</p>`:''}
</div>`).join('')}</section>
<section><h4>Blockers — ${blockers.length}</h4>
${blockers.length ? '<ul class="plain">'+blockers.map(b=>`<li>${b.reason} · <span class="mono">${b.id}</span></li>`).join('')+'</ul>'
: '<div class="empty" style="margin:0">none. Nothing outside the code is in the way.</div>'}</section>
<section><h4>Scenarios — ${scen.length}</h4>
${scen.length ? '<ul class="plain">'+scen.map(x=>`<li class="mono">${x.name} ${x.exists?'':'<span class="b off">absent from disk</span>'}</li>`).join('')+'</ul>'
: '<div class="empty" style="margin:0">none named</div>'}</section>
<section><h4>Unresolved product questions — ${qs.length}</h4>
${qs.length ? qs.map(iv=>`<div class="crit"><span class="mark ${iv.mark}">invariant ${iv.id}</span>
<p>${iv.question}</p></div>`).join('')
: '<div class="empty" style="margin:0">none</div>'}</section>`;
side.querySelectorAll('.cchip').forEach(b=>b.onclick=()=>{S.sel=b.dataset.id;renderSide(b.dataset.id);});
}
function invRollup(iv){
const comps = iv.components.map(x=>byId[x]).filter(Boolean);
const bad = comps.filter(c=>c.status!=='implemented'&&c.status!=='temporary').length;
const caps = iv.capabilities.map(x=>capById[x]).filter(Boolean);
const ver = caps.filter(c=>c.implementation.verified==='yes').length;
const part = caps.filter(c=>c.implementation.verified==='partial').length;
return {comps, bad, caps, ver, part};
}
function renderInv(main){
const v = VIEWS.find(x=>x.id===S.view);
main.innerHTML = `<div class="viewnote">${v.note}</div>` + INV.map(iv=>{
const r = invRollup(iv);
const mk = iv.mark==='explicit'?'ok':iv.mark==='implied'?'mid':'warn';
const ik = r.bad?'mid':'ok';
const vk = r.ver===r.caps.length?'ok':(r.ver+r.part)?'mid':'warn';
return `<div class="inv" data-inv="${iv.id}">
<h4><span class="n">${iv.id}</span> ${iv.title}
<span class="mark ${iv.mark}">${iv.mark}${iv.split?' · split':''}</span></h4>
<div class="bars">
<div class="bar"><b>target</b><span class="v ${mk}">${
iv.mark==='explicit'?'written down':iv.mark==='implied'?'not stated':'no answer exists'}</span></div>
<div class="bar"><b>implementation</b><span class="v ${ik}">${r.comps.length} components, ${r.bad} not live</span></div>
<div class="bar"><b>runtime verification</b><span class="v ${vk}">${r.ver} of ${r.caps.length} capabilities verified${r.part?', '+r.part+' partly':''}</span></div>
</div>
${iv.question?`<p class="q">${iv.question}</p>`:''}
<div style="margin-top:8px">${iv.capabilities.map(c=>`<button class="cchip" data-cap="${c}">${c}</button>`).join('')}</div>
<div style="margin-top:4px">${iv.components.map(compChip).join('')}</div>
</div>`;
}).join('');
main.querySelectorAll('.cchip[data-id]').forEach(b=>b.onclick=()=>{S.sel=b.dataset.id;renderSide(b.dataset.id);});
main.querySelectorAll('.cchip[data-cap]').forEach(b=>b.onclick=()=>renderCapSide(b.dataset.cap));
}
function renderFlow(main){
const f = FLOWS[S.flow];
main.innerHTML = `${diagramPanel(FLOWS[S.flow].file)}<div class="viewnote"><b>Three requests, traced through real code.</b> Steps marked in red are branches, fallbacks or refusals the implementation actually takes. Click a component name to open its record. The Mermaid sequence source for each flow is under the panel on the right.</div>
@@ -477,10 +658,15 @@ document.getElementById('dirty').textContent = ARCH.working_tree;
document.getElementById('views').innerHTML = VIEWS.map(v=>`<button class="viewbtn" data-v="${v.id}">${v.name}<small>${v.hint}</small></button>`).join('');
document.getElementById('legend').innerHTML = [...new Set(ARCH.components.map(c=>c.type))].sort().map(t=>`<span>${t}</span>`).join('');
function setView(id){ S.view=id; S.sel=null;
function setView(id){ S.view=id; S.sel=null; S.selCap=null;
document.querySelectorAll('.viewbtn').forEach(b=>b.classList.toggle('on', b.dataset.v===id));
renderView();
document.getElementById('side').innerHTML = '<div class="empty">Select a component to see its responsibility, the files and symbols it was read from, and every relation in and out.</div>';
const v = VIEWS.find(x=>x.id===id);
document.getElementById('side').innerHTML = v.caps
? '<div class="empty">Select a capability to see its definition of done, every verdict and its evidence, the components that carry it, its blockers and the product questions it waits on.<br><br>A dot is never an opinion. Six of the seven come from component status, the seventh from a probe run.</div>'
: v.inv
? '<div class="empty">Twelve rules that run across all 51 capabilities. Click a capability or a component to open its record.<br><br>An unresolved rule is a product question, not a defect.</div>'
: '<div class="empty">Select a component to see its responsibility, the files and symbols it was read from, and every relation in and out.</div>';
}
document.querySelectorAll('.viewbtn').forEach(b=>b.onclick=()=>setView(b.dataset.v));
['tLow','tMed','tOff','tUndeployed','tPlanned','tWires'].forEach(k=>
+21 -1
View File
@@ -20,7 +20,8 @@ whole directory is built against.
| `probes_dod.json` | probes derived from the ledger's criteria | no, generated |
| `run_probes.py` | drives a probe file through the deployed stack | yes |
| `store_counts.py` | row counts per store, over IPC | yes |
| `invariants.md` | the twelve cross-cutting rules the 51 capabilities imply | yes |
| `invariants.md` | the twelve cross-cutting rules the 51 capabilities imply, with the prose and the evidence | yes |
| `invariants.yaml` | the machine-readable half of the same twelve: mark, capabilities, components | yes |
| `gaps.md` | eight gap classes and the one ranked priority list | yes, except classes 1-4 |
| `out/` | raw probe output, one JSON object per line | no |
@@ -38,6 +39,13 @@ name, a `domains.yaml` row naming a capability that does not exist, a
outside the five, and a reason outside the plan's list. It caught the domain
reconciler silently dropping `recall` from its 51.
It fails on an invariant whose `## N. Title` heading is absent from
`invariants.md`, on a count mismatch between the two files, on an unknown
capability or component in `invariants.yaml`, and on an `unresolved` invariant
carrying no product question. The two files exist separately so the viewer can
read one and a person can read the other, and they drift the moment nothing
checks them.
It also fails on a capability missing from `implementation.yaml`, a component id
that `docs/architecture/maven-architecture.json` does not carry, and a component
status the dimension table does not know. A capability absent from the mapping
@@ -103,3 +111,15 @@ store rows. The scenario harness scripts both `route` and `reply`, so a green
scenario proves the wiring around the model and not the turn; it is recorded as
implementation evidence and reads `untested`. `simulated` is allowed only where
the trigger is anchored to a wall-clock hour or date a probe cannot reach.
## The viewer
`docs/architecture/index.html` views 6 and 7 read this directory.
`build_viewer.py` inlines `ledger.yaml` and `invariants.yaml` and derives
nothing: the ledger's build is the only thing allowed to decide a dimension, and
a second derivation would drift from it silently.
```sh
python3 docs/architecture/build_viewer.py
node docs/architecture/check_viewer.js
```
+33
View File
@@ -21,6 +21,8 @@ DOMAINS = ROOT / "docs" / "capabilities" / "domains.yaml"
VERDICTS = ROOT / "docs" / "capabilities" / "verdicts.json"
IMPL = ROOT / "docs" / "capabilities" / "implementation.yaml"
ARCH = ROOT / "docs" / "architecture" / "maven-architecture.json"
INV_YAML = ROOT / "docs" / "capabilities" / "invariants.yaml"
INV_MD = ROOT / "docs" / "capabilities" / "invariants.md"
# Sections of docs/spec.md whose ### headings are capabilities. Every other ##
# is prose about how to read the file.
@@ -487,6 +489,37 @@ def main():
# is not a fail. Mixing them is how a wrong diagnosis survives.
errs.append(f"{cid}: a fail cannot rest on 'no runtime proof'")
# The invariants exist twice on purpose: prose and evidence in the .md, the
# machine-readable half in the .yaml for the viewer. They drift the moment
# nothing checks them, so check them.
if INV_YAML.exists():
try:
import yaml as _y
except ImportError:
errs.append("pyyaml absent: invariants.yaml was not checked")
else:
inv = _y.safe_load(INV_YAML.read_text(encoding="utf-8"))["invariants"]
md = INV_MD.read_text(encoding="utf-8") if INV_MD.exists() else ""
if not md:
errs.append("invariants.yaml exists and invariants.md does not")
n_md = md.count("\n## ") - md.count("\n## What this file")
if md and n_md != len(inv):
errs.append(f"invariants: {len(inv)} in the yaml, {n_md} headings in the md")
for iv in inv:
head = f"## {iv['id']}. {iv['title']}"
if md and head not in md:
errs.append(f"invariant {iv['id']}: no heading {head!r} in invariants.md")
if iv["mark"] not in {"explicit", "implied", "unresolved"}:
errs.append(f"invariant {iv['id']}: mark {iv['mark']!r} is not one of three")
for c in iv["capabilities"]:
if c not in cap_ids:
errs.append(f"invariant {iv['id']}: unknown capability {c!r}")
for c in iv["components"]:
if arch and c not in arch:
errs.append(f"invariant {iv['id']}: unknown component {c!r}")
if iv["mark"] == "unresolved" and not iv.get("question"):
errs.append(f"invariant {iv['id']}: unresolved with no product question")
OUT.write_text(emit(caps, section_notes, domains, verdicts, impl, arch), encoding="utf-8")
# The emitter hand-writes YAML, so it can produce something that reads fine
+91
View File
@@ -0,0 +1,91 @@
# The structured half of docs/capabilities/invariants.md. HAND-WRITTEN.
#
# The prose, the evidence and the reasoning live in the .md. This file carries
# only what a machine needs: the mark, which capabilities the rule touches, and
# which components participate in it. build_ledger.py checks the two agree, so
# an invariant cannot exist in one and not the other.
#
# mark: explicit | implied | unresolved. `split` means the rule is written down
# in one half and not in the other, and the .md says which half is which.
invariants:
- id: 1
title: Continuity across turns and across reaches
mark: implied
question: Is a conversation per reach, or one conversation the reaches are windows onto?
capabilities: [web-ui, voice, telegram, ask-instead-of-guessing]
components: [state.dialogue_sessions, state.clarify_store, proc.mavweb, core.voice_server, core.sink_telegram]
- id: 2
title: Memory and correction semantics
mark: explicit
split: true
capabilities: [facts, notes, recall]
components: [state.facts, state.notes, state.memory_vectors, core.recall, router.embedder, core.q.factbykey, core.fact_enrichment, core.netscan]
- id: 3
title: Current context and presence
mark: explicit
capabilities: [interruption-policy, calendar-management, morning-routine]
components: [state.presence_state, core.gatherer, core.q.calendar, proc.mavcaldav, core.dispatcher]
- id: 4
title: Proactive attention
mark: explicit
capabilities: [interruption-policy, digest-of-held-nudges, routine-proposals, praxis]
components: [core.tick_loop, core.rules, state.digest_entries, state.nudges, core.q.attention, ext.praxis]
- id: 5
title: Interruption policy
mark: explicit
split: true
question: Does a held nudge have a shelf life?
capabilities: [interruption-policy, digest-of-held-nudges, telegram, ntfy]
components: [core.dispatcher, state.delivery_attempts, state.digest_entries, core.rules, core.sink_telegram, core.sink_ntfy, core.sink_voice]
- id: 6
title: Clarification and follow-up ownership
mark: implied
capabilities: [ask-instead-of-guessing, route-an-utterance]
components: [core.preroute, state.clarify_store, state.dialogue_sessions, core.turn_route]
- id: 7
title: Degradation and honesty
mark: explicit
split: true
question: At what depth of fallback does silence stop being honest?
capabilities: [answer-from-the-world, speech-to-text, route-an-utterance, speak-as-herself, read-an-encyclopedia, weather]
components: [core.model_seam, core.stt_seam, router.cascade, router.classifier, core.query_chain, core.phraser, core.q.kiwix, core.q.general]
- id: 8
title: Authority and confirmation
mark: unresolved
question: Where is the one point that decides whether this origin may perform this effect with this evidence?
capabilities: [hexis, praxis, voice, passkey-and-step-up, encrypted-database]
components: [core.auth_gate, core.risk_policy, state.pending_act, state.tools, core.action_act, core.ecosystem_hexis_gate, core.praxis_acts, core.voice_server, bnd.voice_tcp, core.daemon_lock]
- id: 9
title: Privacy boundaries
mark: explicit
capabilities: [answer-from-the-world, answer-from-your-own-data, recall, read-an-encyclopedia]
components: [core.q.personal, core.query_chain, core.q.search, core.q.kiwix, bnd.http_ecosystem]
- id: 10
title: Learning from outcomes
mark: unresolved
question: Is behavioural learning wanted, or is the negative criterion the whole of the intent?
capabilities: [learning-the-style, learning-from-mistakes, interruption-policy, route-an-utterance]
components: [state.nudges, state.routing_labels, core.tick_loop, core.rules]
- id: 11
title: Capability composition
mark: implied
question: What is the single unit that competes for a turn?
capabilities: [command-chaining, route-an-utterance, answer-from-your-own-data, ask-instead-of-guessing]
components: [core.action_table, core.query_chain, core.preroute, router.cascade, router.stage0, router.claim, router.modes]
- id: 12
title: Persistence across restart
mark: implied
capabilities: [ask-instead-of-guessing, praxis, route-an-utterance]
components: [state.dialogue_sessions, state.clarify_store, state.decision_ring, state.routing_traces, state.tick_memo, state.surfaced_items]