rootmemory 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rootmemory/__init__.py +84 -0
- rootmemory/api/__init__.py +25 -0
- rootmemory/api/agents.py +38 -0
- rootmemory/api/beliefs.py +66 -0
- rootmemory/api/claims.py +87 -0
- rootmemory/api/deps.py +21 -0
- rootmemory/api/errors.py +36 -0
- rootmemory/api/graph.py +32 -0
- rootmemory/api/invalidation.py +47 -0
- rootmemory/api/observations.py +46 -0
- rootmemory/api/promotion.py +37 -0
- rootmemory/api/provenance.py +88 -0
- rootmemory/api/security.py +53 -0
- rootmemory/cli.py +96 -0
- rootmemory/client.py +219 -0
- rootmemory/config.py +64 -0
- rootmemory/db/__init__.py +0 -0
- rootmemory/db/base.py +42 -0
- rootmemory/db/migrations/env.py +59 -0
- rootmemory/db/migrations/script.py.mako +26 -0
- rootmemory/db/migrations/versions/d336d5cf7fb9_initial_schema.py +260 -0
- rootmemory/db/session.py +138 -0
- rootmemory/errors.py +27 -0
- rootmemory/integrations/__init__.py +16 -0
- rootmemory/integrations/async_client.py +268 -0
- rootmemory/integrations/langchain_tools.py +237 -0
- rootmemory/integrations/langgraph_memory.py +179 -0
- rootmemory/integrations/langgraph_store.py +402 -0
- rootmemory/logging_config.py +30 -0
- rootmemory/main.py +133 -0
- rootmemory/models/__init__.py +22 -0
- rootmemory/models/agent.py +30 -0
- rootmemory/models/audit.py +51 -0
- rootmemory/models/belief.py +39 -0
- rootmemory/models/claim.py +52 -0
- rootmemory/models/decision.py +38 -0
- rootmemory/models/edge.py +43 -0
- rootmemory/models/enums.py +104 -0
- rootmemory/models/node_ref.py +42 -0
- rootmemory/models/observation.py +51 -0
- rootmemory/py.typed +0 -0
- rootmemory/repositories/__init__.py +19 -0
- rootmemory/repositories/agent_repo.py +44 -0
- rootmemory/repositories/audit_repo.py +43 -0
- rootmemory/repositories/belief_repo.py +63 -0
- rootmemory/repositories/claim_repo.py +102 -0
- rootmemory/repositories/decision_repo.py +76 -0
- rootmemory/repositories/edge_repo.py +77 -0
- rootmemory/repositories/observation_repo.py +76 -0
- rootmemory/schemas/__init__.py +52 -0
- rootmemory/schemas/agent.py +26 -0
- rootmemory/schemas/belief.py +49 -0
- rootmemory/schemas/claim.py +54 -0
- rootmemory/schemas/common.py +27 -0
- rootmemory/schemas/observation.py +69 -0
- rootmemory/schemas/promotion.py +50 -0
- rootmemory/schemas/provenance.py +51 -0
- rootmemory/services/__init__.py +35 -0
- rootmemory/services/belief_service.py +161 -0
- rootmemory/services/contradiction_service.py +148 -0
- rootmemory/services/decision_service.py +65 -0
- rootmemory/services/graph_service.py +316 -0
- rootmemory/services/independence_service.py +159 -0
- rootmemory/services/invalidation_service.py +164 -0
- rootmemory/services/normalization_service.py +234 -0
- rootmemory/services/promotion_service.py +318 -0
- rootmemory/services/provenance_service.py +282 -0
- rootmemory/services/scoring_service.py +53 -0
- rootmemory/static/index.html +866 -0
- rootmemory-0.1.0.dist-info/METADATA +266 -0
- rootmemory-0.1.0.dist-info/RECORD +75 -0
- rootmemory-0.1.0.dist-info/WHEEL +5 -0
- rootmemory-0.1.0.dist-info/entry_points.txt +2 -0
- rootmemory-0.1.0.dist-info/licenses/LICENSE +21 -0
- rootmemory-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>RootMemory</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #0e1116;
|
|
10
|
+
--panel: #161b22;
|
|
11
|
+
--panel-2: #1c2230;
|
|
12
|
+
--line: #2a3441;
|
|
13
|
+
--ink: #e6edf3;
|
|
14
|
+
--ink-dim: #8b98a5;
|
|
15
|
+
--ink-faint: #5b6673;
|
|
16
|
+
|
|
17
|
+
--observation: #4ba3f5;
|
|
18
|
+
--belief: #b07cf0;
|
|
19
|
+
--claim: #f0b429;
|
|
20
|
+
--decision: #3fbf8f;
|
|
21
|
+
|
|
22
|
+
--good: #3fbf8f;
|
|
23
|
+
--warn: #f0b429;
|
|
24
|
+
--bad: #f2545b;
|
|
25
|
+
|
|
26
|
+
--edge: #38404d;
|
|
27
|
+
--edge-support: #3fbf8f;
|
|
28
|
+
--edge-contradict: #f2545b;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
* { box-sizing: border-box; }
|
|
32
|
+
|
|
33
|
+
body {
|
|
34
|
+
margin: 0;
|
|
35
|
+
background: var(--bg);
|
|
36
|
+
color: var(--ink);
|
|
37
|
+
font: 13px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
38
|
+
height: 100vh;
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-direction: column;
|
|
41
|
+
overflow: hidden;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* ---------- header ---------- */
|
|
45
|
+
header {
|
|
46
|
+
display: flex;
|
|
47
|
+
align-items: center;
|
|
48
|
+
gap: 18px;
|
|
49
|
+
padding: 10px 16px;
|
|
50
|
+
border-bottom: 1px solid var(--line);
|
|
51
|
+
background: var(--panel);
|
|
52
|
+
flex-shrink: 0;
|
|
53
|
+
}
|
|
54
|
+
h1 {
|
|
55
|
+
font-size: 14px;
|
|
56
|
+
margin: 0;
|
|
57
|
+
letter-spacing: .3px;
|
|
58
|
+
font-weight: 600;
|
|
59
|
+
}
|
|
60
|
+
h1 span { color: var(--ink-faint); font-weight: 400; }
|
|
61
|
+
|
|
62
|
+
.stats { display: flex; gap: 14px; margin-left: auto; align-items: center; flex-wrap: wrap; }
|
|
63
|
+
.stat { text-align: right; line-height: 1.2; }
|
|
64
|
+
.stat b { font-size: 15px; font-variant-numeric: tabular-nums; display: block; }
|
|
65
|
+
.stat i { font-style: normal; font-size: 10px; color: var(--ink-faint); text-transform: uppercase; letter-spacing: .5px; }
|
|
66
|
+
.stat.alert b { color: var(--bad); }
|
|
67
|
+
|
|
68
|
+
button {
|
|
69
|
+
background: var(--panel-2);
|
|
70
|
+
color: var(--ink);
|
|
71
|
+
border: 1px solid var(--line);
|
|
72
|
+
border-radius: 5px;
|
|
73
|
+
padding: 5px 10px;
|
|
74
|
+
font: inherit;
|
|
75
|
+
font-size: 12px;
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
}
|
|
78
|
+
button:hover:not(:disabled) { border-color: var(--ink-faint); }
|
|
79
|
+
button:disabled { opacity: .4; cursor: not-allowed; }
|
|
80
|
+
button.danger:hover:not(:disabled) { border-color: var(--bad); color: var(--bad); }
|
|
81
|
+
button.primary { background: #1f6feb; border-color: #1f6feb; }
|
|
82
|
+
button.primary:hover:not(:disabled) { background: #2b7cf3; }
|
|
83
|
+
|
|
84
|
+
/* ---------- layout ---------- */
|
|
85
|
+
main { flex: 1; display: flex; min-height: 0; }
|
|
86
|
+
|
|
87
|
+
#canvas-wrap {
|
|
88
|
+
flex: 1;
|
|
89
|
+
position: relative;
|
|
90
|
+
overflow: hidden;
|
|
91
|
+
background-color: var(--bg);
|
|
92
|
+
background-image: radial-gradient(circle at 1px 1px, #1b2431 1px, transparent 0);
|
|
93
|
+
background-size: 22px 22px;
|
|
94
|
+
}
|
|
95
|
+
svg { width: 100%; height: 100%; display: block; cursor: grab; }
|
|
96
|
+
svg.panning { cursor: grabbing; }
|
|
97
|
+
|
|
98
|
+
.lane-label {
|
|
99
|
+
fill: var(--ink-faint);
|
|
100
|
+
font-size: 10px;
|
|
101
|
+
text-transform: uppercase;
|
|
102
|
+
letter-spacing: 1px;
|
|
103
|
+
}
|
|
104
|
+
.lane-line { stroke: var(--line); stroke-dasharray: 2 6; }
|
|
105
|
+
|
|
106
|
+
.edge { fill: none; stroke: var(--edge); stroke-width: 1.4px; }
|
|
107
|
+
.edge.supports { stroke: var(--edge-support); stroke-dasharray: 4 3; opacity: .55; }
|
|
108
|
+
.edge.contradicts { stroke: var(--edge-contradict); stroke-dasharray: 4 3; opacity: .55; }
|
|
109
|
+
.edge.depends_on { stroke: var(--decision); stroke-dasharray: 1 4; opacity: .6; }
|
|
110
|
+
.edge.trace { stroke: #fff; stroke-width: 2.4px; opacity: 1; stroke-dasharray: none; }
|
|
111
|
+
.edge.dim { opacity: .08; }
|
|
112
|
+
|
|
113
|
+
/* Node paint lives here, not in attributes: var() does not resolve inside
|
|
114
|
+
SVG presentation attributes, so fill="var(--x)" paints black instead. */
|
|
115
|
+
.node { cursor: pointer; }
|
|
116
|
+
.node rect.box {
|
|
117
|
+
fill: var(--panel-2);
|
|
118
|
+
stroke: var(--ink-faint);
|
|
119
|
+
stroke-width: 1.5px;
|
|
120
|
+
rx: 6;
|
|
121
|
+
}
|
|
122
|
+
.node rect.accent { fill: var(--ink-faint); }
|
|
123
|
+
|
|
124
|
+
.node.observation rect.box { stroke: var(--observation); }
|
|
125
|
+
.node.observation rect.accent { fill: var(--observation); }
|
|
126
|
+
.node.belief rect.box { stroke: var(--belief); }
|
|
127
|
+
.node.belief rect.accent { fill: var(--belief); }
|
|
128
|
+
.node.claim rect.box { stroke: var(--claim); }
|
|
129
|
+
.node.claim rect.accent { fill: var(--claim); }
|
|
130
|
+
.node.decision rect.box { stroke: var(--decision); }
|
|
131
|
+
.node.decision rect.accent { fill: var(--decision); }
|
|
132
|
+
|
|
133
|
+
.node text { fill: var(--ink); font-size: 11px; pointer-events: none; }
|
|
134
|
+
.node .kicker { fill: var(--ink-faint); font-size: 9px; letter-spacing: .6px; text-transform: uppercase; }
|
|
135
|
+
.node.dim { opacity: .18; }
|
|
136
|
+
.node.selected rect.box { stroke: #fff; stroke-width: 2.5px; }
|
|
137
|
+
.node.root rect.box { stroke: #fff; stroke-dasharray: 3 2; stroke-width: 2px; }
|
|
138
|
+
|
|
139
|
+
.badge { font-size: 9px; fill: var(--ink-faint); }
|
|
140
|
+
.badge.good { fill: var(--good); }
|
|
141
|
+
.badge.warn { fill: var(--warn); }
|
|
142
|
+
.badge.bad { fill: var(--bad); }
|
|
143
|
+
.badge.neutral { fill: var(--ink-faint); }
|
|
144
|
+
|
|
145
|
+
/* ---------- side panel ---------- */
|
|
146
|
+
aside {
|
|
147
|
+
width: 370px;
|
|
148
|
+
flex-shrink: 0;
|
|
149
|
+
border-left: 1px solid var(--line);
|
|
150
|
+
background: var(--panel);
|
|
151
|
+
overflow-y: auto;
|
|
152
|
+
padding: 14px 16px 40px;
|
|
153
|
+
}
|
|
154
|
+
aside h2 { font-size: 12px; margin: 0 0 4px; text-transform: uppercase; letter-spacing: .8px; color: var(--ink-dim); }
|
|
155
|
+
aside .title { font-size: 14px; line-height: 1.45; margin: 0 0 12px; }
|
|
156
|
+
|
|
157
|
+
.kv { display: grid; grid-template-columns: 120px 1fr; gap: 4px 10px; margin: 10px 0; }
|
|
158
|
+
.kv dt { color: var(--ink-faint); font-size: 11px; }
|
|
159
|
+
.kv dd { margin: 0; font-size: 12px; word-break: break-word; }
|
|
160
|
+
|
|
161
|
+
.pill {
|
|
162
|
+
display: inline-block;
|
|
163
|
+
padding: 1px 7px;
|
|
164
|
+
border-radius: 99px;
|
|
165
|
+
font-size: 10px;
|
|
166
|
+
letter-spacing: .4px;
|
|
167
|
+
text-transform: uppercase;
|
|
168
|
+
border: 1px solid currentColor;
|
|
169
|
+
}
|
|
170
|
+
.pill.good { color: var(--good); }
|
|
171
|
+
.pill.warn { color: var(--warn); }
|
|
172
|
+
.pill.bad { color: var(--bad); }
|
|
173
|
+
.pill.neutral { color: var(--ink-faint); }
|
|
174
|
+
|
|
175
|
+
.verdict {
|
|
176
|
+
border: 1px solid var(--line);
|
|
177
|
+
border-radius: 8px;
|
|
178
|
+
padding: 12px;
|
|
179
|
+
margin: 12px 0;
|
|
180
|
+
background: var(--panel-2);
|
|
181
|
+
}
|
|
182
|
+
.verdict.reject { border-color: #5c2a2f; }
|
|
183
|
+
.verdict.promote { border-color: #1f5c46; }
|
|
184
|
+
.verdict-head { font-size: 15px; font-weight: 600; margin-bottom: 6px; }
|
|
185
|
+
.verdict.reject .verdict-head { color: var(--bad); }
|
|
186
|
+
.verdict.promote .verdict-head { color: var(--good); }
|
|
187
|
+
.verdict p { margin: 0; color: var(--ink-dim); font-size: 12px; }
|
|
188
|
+
|
|
189
|
+
.counts { display: flex; gap: 8px; margin: 10px 0; }
|
|
190
|
+
.count {
|
|
191
|
+
flex: 1;
|
|
192
|
+
border: 1px solid var(--line);
|
|
193
|
+
border-radius: 6px;
|
|
194
|
+
padding: 8px;
|
|
195
|
+
text-align: center;
|
|
196
|
+
background: var(--panel-2);
|
|
197
|
+
}
|
|
198
|
+
.count b { display: block; font-size: 20px; font-variant-numeric: tabular-nums; }
|
|
199
|
+
.count i { font-style: normal; font-size: 9px; color: var(--ink-faint); text-transform: uppercase; letter-spacing: .4px; }
|
|
200
|
+
.count.key { border-color: var(--claim); }
|
|
201
|
+
.count.key b { color: var(--claim); }
|
|
202
|
+
|
|
203
|
+
.note {
|
|
204
|
+
font-size: 11px;
|
|
205
|
+
color: var(--ink-dim);
|
|
206
|
+
border-left: 2px solid var(--claim);
|
|
207
|
+
padding-left: 9px;
|
|
208
|
+
margin: 10px 0;
|
|
209
|
+
line-height: 1.5;
|
|
210
|
+
}
|
|
211
|
+
.note.bad { border-color: var(--bad); }
|
|
212
|
+
.note.good { border-color: var(--good); }
|
|
213
|
+
|
|
214
|
+
.reasons { list-style: none; padding: 0; margin: 8px 0; }
|
|
215
|
+
.reasons li { font-size: 11px; padding: 2px 0 2px 16px; position: relative; color: var(--ink-dim); }
|
|
216
|
+
.reasons li::before { content: "•"; position: absolute; left: 4px; }
|
|
217
|
+
.reasons li.met::before { content: "✓"; color: var(--good); }
|
|
218
|
+
.reasons li.failed::before { content: "✕"; color: var(--bad); }
|
|
219
|
+
|
|
220
|
+
.actions { display: flex; gap: 6px; flex-wrap: wrap; margin: 14px 0 6px; }
|
|
221
|
+
|
|
222
|
+
.empty { color: var(--ink-faint); text-align: center; padding: 40px 20px; line-height: 1.7; }
|
|
223
|
+
.empty code { background: var(--panel-2); padding: 2px 6px; border-radius: 4px; color: var(--ink); }
|
|
224
|
+
|
|
225
|
+
.legend {
|
|
226
|
+
position: absolute; left: 12px; bottom: 12px;
|
|
227
|
+
background: rgba(22,27,34,.92);
|
|
228
|
+
border: 1px solid var(--line);
|
|
229
|
+
border-radius: 6px;
|
|
230
|
+
padding: 8px 10px;
|
|
231
|
+
font-size: 11px;
|
|
232
|
+
line-height: 1.7;
|
|
233
|
+
}
|
|
234
|
+
.legend .row { display: flex; align-items: center; gap: 7px; }
|
|
235
|
+
.swatch { width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
|
|
236
|
+
|
|
237
|
+
.hint {
|
|
238
|
+
position: absolute; right: 12px; bottom: 12px;
|
|
239
|
+
color: var(--ink-faint); font-size: 11px;
|
|
240
|
+
background: rgba(22,27,34,.92);
|
|
241
|
+
border: 1px solid var(--line);
|
|
242
|
+
border-radius: 6px; padding: 6px 9px;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.toast {
|
|
246
|
+
position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%);
|
|
247
|
+
background: var(--panel-2); border: 1px solid var(--line);
|
|
248
|
+
padding: 9px 16px; border-radius: 6px; font-size: 12px;
|
|
249
|
+
z-index: 50; max-width: 70ch;
|
|
250
|
+
}
|
|
251
|
+
.toast.error { border-color: var(--bad); color: var(--bad); }
|
|
252
|
+
.toast.ok { border-color: var(--good); }
|
|
253
|
+
</style>
|
|
254
|
+
</head>
|
|
255
|
+
<body>
|
|
256
|
+
|
|
257
|
+
<header>
|
|
258
|
+
<h1>RootMemory <span>· provenance portal</span></h1>
|
|
259
|
+
<button id="refresh">Refresh</button>
|
|
260
|
+
<button id="fit">Fit</button>
|
|
261
|
+
<div class="stats" id="stats"></div>
|
|
262
|
+
</header>
|
|
263
|
+
|
|
264
|
+
<main>
|
|
265
|
+
<div id="canvas-wrap">
|
|
266
|
+
<svg id="canvas"></svg>
|
|
267
|
+
<div class="legend">
|
|
268
|
+
<div class="row"><span class="swatch" style="background:var(--observation)"></span> observation · real evidence</div>
|
|
269
|
+
<div class="row"><span class="swatch" style="background:var(--belief)"></span> belief · an agent's opinion</div>
|
|
270
|
+
<div class="row"><span class="swatch" style="background:var(--claim)"></span> claim · proposition</div>
|
|
271
|
+
<div class="row"><span class="swatch" style="background:var(--decision)"></span> decision · action taken</div>
|
|
272
|
+
</div>
|
|
273
|
+
<div class="hint">drag to pan · scroll to zoom · click a node</div>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<aside id="panel"></aside>
|
|
277
|
+
</main>
|
|
278
|
+
|
|
279
|
+
<script>
|
|
280
|
+
"use strict";
|
|
281
|
+
|
|
282
|
+
const STATUS_TONE = {
|
|
283
|
+
valid: "good", invalid: "bad", disputed: "warn", unknown: "neutral",
|
|
284
|
+
inferred: "neutral", believed: "neutral", uncertain: "warn",
|
|
285
|
+
confirmed: "good", contradicted: "bad", retracted: "bad",
|
|
286
|
+
unconfirmed: "neutral", provisional: "warn",
|
|
287
|
+
active: "good", needs_review: "warn", invalidated: "bad",
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const NODE_W = 190, NODE_H = 52, GAP_X = 34, GAP_Y = 96;
|
|
291
|
+
|
|
292
|
+
let graph = { nodes: [], edges: [], stats: {} };
|
|
293
|
+
let positions = new Map();
|
|
294
|
+
let selected = null;
|
|
295
|
+
let view = { x: 0, y: 0, k: 1 };
|
|
296
|
+
|
|
297
|
+
const svg = document.getElementById("canvas");
|
|
298
|
+
const panel = document.getElementById("panel");
|
|
299
|
+
|
|
300
|
+
/* ------------------------------------------------------------------ */
|
|
301
|
+
/* data */
|
|
302
|
+
/* ------------------------------------------------------------------ */
|
|
303
|
+
|
|
304
|
+
/** The API key, when the server requires one. Kept per browser tab only. */
|
|
305
|
+
function apiKey() {
|
|
306
|
+
try { return sessionStorage.getItem("causalmemory_api_key") || ""; } catch (e) { return ""; }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function setApiKey(value) {
|
|
310
|
+
try { sessionStorage.setItem("causalmemory_api_key", value); } catch (e) { /* private mode */ }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function api(path, options, retried) {
|
|
314
|
+
const headers = { "content-type": "application/json" };
|
|
315
|
+
const key = apiKey();
|
|
316
|
+
if (key) headers["X-API-Key"] = key;
|
|
317
|
+
|
|
318
|
+
const res = await fetch(path, { headers, ...options });
|
|
319
|
+
|
|
320
|
+
if (res.status === 401 && !retried) {
|
|
321
|
+
// The server has API_KEYS set. Ask once, then retry the same call.
|
|
322
|
+
const entered = prompt("This RootMemory instance requires an API key:", "");
|
|
323
|
+
if (entered) {
|
|
324
|
+
setApiKey(entered.trim());
|
|
325
|
+
return api(path, options, true);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (!res.ok) {
|
|
329
|
+
let detail = res.statusText;
|
|
330
|
+
try { detail = (await res.json()).detail || detail; } catch (e) { /* keep status */ }
|
|
331
|
+
throw new Error(detail);
|
|
332
|
+
}
|
|
333
|
+
return res.status === 204 ? null : res.json();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function load(keepSelection) {
|
|
337
|
+
const previous = keepSelection && selected ? selected.id : null;
|
|
338
|
+
graph = await api("/graph");
|
|
339
|
+
layout();
|
|
340
|
+
render();
|
|
341
|
+
renderStats();
|
|
342
|
+
// ?node=<id> deep-links to one node, so a trace can be shared as a URL.
|
|
343
|
+
const wanted = previous || new URLSearchParams(location.search).get("node");
|
|
344
|
+
const restored = wanted ? graph.nodes.find(n => n.id === wanted) : null;
|
|
345
|
+
select(restored || null);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/* ------------------------------------------------------------------ */
|
|
349
|
+
/* layout: one lane per depth, observations at the bottom */
|
|
350
|
+
/* ------------------------------------------------------------------ */
|
|
351
|
+
|
|
352
|
+
function layout() {
|
|
353
|
+
positions = new Map();
|
|
354
|
+
const lanes = new Map();
|
|
355
|
+
for (const node of graph.nodes) {
|
|
356
|
+
if (!lanes.has(node.depth)) lanes.set(node.depth, []);
|
|
357
|
+
lanes.get(node.depth).push(node);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const depths = [...lanes.keys()].sort((a, b) => a - b);
|
|
361
|
+
const maxDepth = depths.length ? Math.max(...depths) : 0;
|
|
362
|
+
const widest = Math.max(1, ...[...lanes.values()].map(l => l.length));
|
|
363
|
+
const canvasW = widest * (NODE_W + GAP_X);
|
|
364
|
+
|
|
365
|
+
for (const depth of depths) {
|
|
366
|
+
const lane = lanes.get(depth);
|
|
367
|
+
// Keep each lane stable and roughly ordered by what it descends from.
|
|
368
|
+
lane.sort((a, b) => a.label.localeCompare(b.label));
|
|
369
|
+
const laneW = lane.length * (NODE_W + GAP_X) - GAP_X;
|
|
370
|
+
const startX = (canvasW - laneW) / 2;
|
|
371
|
+
lane.forEach((node, i) => {
|
|
372
|
+
positions.set(node.id, {
|
|
373
|
+
x: startX + i * (NODE_W + GAP_X),
|
|
374
|
+
// Depth 0 sits at the bottom, so evidence is the foundation.
|
|
375
|
+
y: (maxDepth - depth) * GAP_Y,
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
return { canvasW, canvasH: (maxDepth + 1) * GAP_Y };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/* ------------------------------------------------------------------ */
|
|
383
|
+
/* rendering */
|
|
384
|
+
/* ------------------------------------------------------------------ */
|
|
385
|
+
|
|
386
|
+
function el(name, attrs, children) {
|
|
387
|
+
const node = document.createElementNS("http://www.w3.org/2000/svg", name);
|
|
388
|
+
for (const [k, v] of Object.entries(attrs || {})) {
|
|
389
|
+
if (v !== null && v !== undefined) node.setAttribute(k, v);
|
|
390
|
+
}
|
|
391
|
+
for (const child of children || []) node.appendChild(child);
|
|
392
|
+
return node;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function text(content, attrs) {
|
|
396
|
+
const node = el("text", attrs);
|
|
397
|
+
node.textContent = content;
|
|
398
|
+
return node;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function truncate(value, max) {
|
|
402
|
+
if (!value) return "";
|
|
403
|
+
return value.length > max ? value.slice(0, max - 1) + "…" : value;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function render() {
|
|
407
|
+
svg.textContent = "";
|
|
408
|
+
|
|
409
|
+
const root = el("g", { id: "viewport" });
|
|
410
|
+
svg.appendChild(root);
|
|
411
|
+
|
|
412
|
+
const edgeLayer = el("g");
|
|
413
|
+
const nodeLayer = el("g");
|
|
414
|
+
root.appendChild(edgeLayer);
|
|
415
|
+
root.appendChild(nodeLayer);
|
|
416
|
+
|
|
417
|
+
const trace = traceSet();
|
|
418
|
+
|
|
419
|
+
for (const edge of graph.edges) {
|
|
420
|
+
const a = positions.get(edge.source);
|
|
421
|
+
const b = positions.get(edge.target);
|
|
422
|
+
if (!a || !b) continue;
|
|
423
|
+
const x1 = a.x + NODE_W / 2, y1 = a.y;
|
|
424
|
+
const x2 = b.x + NODE_W / 2, y2 = b.y + NODE_H;
|
|
425
|
+
const mid = (y1 + y2) / 2;
|
|
426
|
+
const inTrace = trace.edges.has(edge.source + ">" + edge.target);
|
|
427
|
+
edgeLayer.appendChild(el("path", {
|
|
428
|
+
d: `M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}`,
|
|
429
|
+
class: "edge " + edge.edge_type + (inTrace ? " trace" : (trace.active ? " dim" : "")),
|
|
430
|
+
}));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
for (const node of graph.nodes) {
|
|
434
|
+
const pos = positions.get(node.id);
|
|
435
|
+
if (!pos) continue;
|
|
436
|
+
|
|
437
|
+
const dim = trace.active && !trace.nodes.has(node.id);
|
|
438
|
+
// Type goes on the class list, never into a fill attribute: CSS custom
|
|
439
|
+
// properties do not resolve inside SVG presentation attributes, so
|
|
440
|
+
// fill="var(--x)" silently paints black. All node colour lives in the
|
|
441
|
+
// stylesheet.
|
|
442
|
+
const classes = ["node", node.type];
|
|
443
|
+
if (selected && selected.id === node.id) classes.push("selected");
|
|
444
|
+
if (trace.roots.has(node.id)) classes.push("root");
|
|
445
|
+
if (dim) classes.push("dim");
|
|
446
|
+
|
|
447
|
+
const group = el("g", {
|
|
448
|
+
class: classes.join(" "),
|
|
449
|
+
transform: `translate(${pos.x},${pos.y})`,
|
|
450
|
+
});
|
|
451
|
+
group.addEventListener("click", (event) => {
|
|
452
|
+
event.stopPropagation();
|
|
453
|
+
select(node);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
group.appendChild(el("rect", { class: "box", width: NODE_W, height: NODE_H }));
|
|
457
|
+
group.appendChild(el("rect", { class: "accent", width: 3, height: NODE_H }));
|
|
458
|
+
|
|
459
|
+
const kicker = node.type === "belief"
|
|
460
|
+
? `${node.detail.agent} · ${node.detail.confidence}`
|
|
461
|
+
: node.type === "observation"
|
|
462
|
+
? node.detail.source_type
|
|
463
|
+
: node.type;
|
|
464
|
+
group.appendChild(text(truncate(kicker, 30), { x: 11, y: 15, class: "kicker" }));
|
|
465
|
+
group.appendChild(text(truncate(node.label, 27), { x: 11, y: 31, class: "label" }));
|
|
466
|
+
|
|
467
|
+
const tone = STATUS_TONE[node.status] || "neutral";
|
|
468
|
+
group.appendChild(text(node.status, {
|
|
469
|
+
x: 11, y: 45, class: `badge ${tone}`,
|
|
470
|
+
}));
|
|
471
|
+
|
|
472
|
+
if (node.type === "claim") {
|
|
473
|
+
const s = node.detail.support;
|
|
474
|
+
group.appendChild(text(
|
|
475
|
+
`${s.belief_count} beliefs · ${s.independent_sources} source${s.independent_sources === 1 ? "" : "s"}`,
|
|
476
|
+
{
|
|
477
|
+
x: NODE_W - 11, y: 45, "text-anchor": "end",
|
|
478
|
+
class: `badge ${node.detail.shares_single_source ? "bad" : "neutral"}`,
|
|
479
|
+
}
|
|
480
|
+
));
|
|
481
|
+
}
|
|
482
|
+
if (node.type === "belief") {
|
|
483
|
+
group.appendChild(text(
|
|
484
|
+
`${node.detail.independent_source_count} src`,
|
|
485
|
+
{ x: NODE_W - 11, y: 45, "text-anchor": "end", class: "badge neutral" }
|
|
486
|
+
));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
nodeLayer.appendChild(group);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
applyView();
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Nodes and edges on the path from the selection down to its evidence.
|
|
496
|
+
*
|
|
497
|
+
* derived_from and depends_on point from a node to what it rests on, so they
|
|
498
|
+
* are followed forwards. supports/contradicts point the other way — from a
|
|
499
|
+
* belief up at the claim it argues about — so they are followed in reverse.
|
|
500
|
+
* Selecting a claim therefore lights up every belief arguing about it and the
|
|
501
|
+
* evidence underneath them; selecting a belief stops at its own evidence and
|
|
502
|
+
* does not drag the claim in with it.
|
|
503
|
+
*/
|
|
504
|
+
const CAUSAL_EDGES = new Set(["derived_from", "depends_on"]);
|
|
505
|
+
|
|
506
|
+
function traceSet() {
|
|
507
|
+
const result = { active: false, nodes: new Set(), edges: new Set(), roots: new Set() };
|
|
508
|
+
if (!selected) return result;
|
|
509
|
+
result.active = true;
|
|
510
|
+
|
|
511
|
+
const parents = new Map();
|
|
512
|
+
const add = (from, to) => {
|
|
513
|
+
if (!parents.has(from)) parents.set(from, []);
|
|
514
|
+
parents.get(from).push(to);
|
|
515
|
+
};
|
|
516
|
+
for (const edge of graph.edges) {
|
|
517
|
+
if (CAUSAL_EDGES.has(edge.edge_type)) add(edge.source, edge.target);
|
|
518
|
+
else add(edge.target, edge.source);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const stack = [selected.id];
|
|
522
|
+
const seen = new Set();
|
|
523
|
+
while (stack.length) {
|
|
524
|
+
const id = stack.pop();
|
|
525
|
+
if (seen.has(id)) continue;
|
|
526
|
+
seen.add(id);
|
|
527
|
+
result.nodes.add(id);
|
|
528
|
+
for (const parent of parents.get(id) || []) {
|
|
529
|
+
// Store both orientations: supports/contradicts were walked in reverse,
|
|
530
|
+
// but the drawing code keys edges by their stored source and target.
|
|
531
|
+
result.edges.add(id + ">" + parent);
|
|
532
|
+
result.edges.add(parent + ">" + id);
|
|
533
|
+
stack.push(parent);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
for (const id of result.nodes) {
|
|
538
|
+
const node = graph.nodes.find(n => n.id === id);
|
|
539
|
+
if (node && node.type === "observation" && node.status === "valid") result.roots.add(id);
|
|
540
|
+
}
|
|
541
|
+
return result;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function renderStats() {
|
|
545
|
+
const s = graph.stats;
|
|
546
|
+
const items = [
|
|
547
|
+
["beliefs", s.beliefs, false],
|
|
548
|
+
["real sources", s.independent_sources, false],
|
|
549
|
+
["confirmed", s.confirmed_claims, false],
|
|
550
|
+
["single-source claims", s.claims_on_a_single_source, s.claims_on_a_single_source > 0],
|
|
551
|
+
["need review", s.decisions_needing_review, s.decisions_needing_review > 0],
|
|
552
|
+
];
|
|
553
|
+
document.getElementById("stats").innerHTML = items.map(([label, value, alert]) =>
|
|
554
|
+
`<div class="stat ${alert ? "alert" : ""}"><b>${value ?? 0}</b><i>${label}</i></div>`
|
|
555
|
+
).join("");
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/* ------------------------------------------------------------------ */
|
|
559
|
+
/* pan and zoom */
|
|
560
|
+
/* ------------------------------------------------------------------ */
|
|
561
|
+
|
|
562
|
+
function applyView() {
|
|
563
|
+
const viewport = svg.querySelector("#viewport");
|
|
564
|
+
if (viewport) {
|
|
565
|
+
viewport.setAttribute("transform", `translate(${view.x},${view.y}) scale(${view.k})`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function fit(attempt) {
|
|
570
|
+
if (!graph.nodes.length) return;
|
|
571
|
+
const xs = [...positions.values()];
|
|
572
|
+
const minX = Math.min(...xs.map(p => p.x)), maxX = Math.max(...xs.map(p => p.x + NODE_W));
|
|
573
|
+
const minY = Math.min(...xs.map(p => p.y)), maxY = Math.max(...xs.map(p => p.y + NODE_H));
|
|
574
|
+
const box = svg.getBoundingClientRect();
|
|
575
|
+
|
|
576
|
+
// Called before the browser has sized the SVG, everything would be scaled to
|
|
577
|
+
// nothing and parked off-screen. Wait a frame and try again.
|
|
578
|
+
if ((box.width < 10 || box.height < 10) && (attempt || 0) < 20) {
|
|
579
|
+
requestAnimationFrame(() => fit((attempt || 0) + 1));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const pad = 50;
|
|
584
|
+
const k = Math.min(
|
|
585
|
+
(box.width - pad * 2) / Math.max(1, maxX - minX),
|
|
586
|
+
(box.height - pad * 2) / Math.max(1, maxY - minY),
|
|
587
|
+
1.2
|
|
588
|
+
);
|
|
589
|
+
view.k = k;
|
|
590
|
+
view.x = pad + (box.width - pad * 2 - (maxX - minX) * k) / 2 - minX * k;
|
|
591
|
+
view.y = pad + (box.height - pad * 2 - (maxY - minY) * k) / 2 - minY * k;
|
|
592
|
+
applyView();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
let dragging = null;
|
|
596
|
+
svg.addEventListener("mousedown", (e) => {
|
|
597
|
+
dragging = { x: e.clientX - view.x, y: e.clientY - view.y };
|
|
598
|
+
svg.classList.add("panning");
|
|
599
|
+
});
|
|
600
|
+
window.addEventListener("mousemove", (e) => {
|
|
601
|
+
if (!dragging) return;
|
|
602
|
+
view.x = e.clientX - dragging.x;
|
|
603
|
+
view.y = e.clientY - dragging.y;
|
|
604
|
+
applyView();
|
|
605
|
+
});
|
|
606
|
+
window.addEventListener("mouseup", () => { dragging = null; svg.classList.remove("panning"); });
|
|
607
|
+
svg.addEventListener("click", () => select(null));
|
|
608
|
+
svg.addEventListener("wheel", (e) => {
|
|
609
|
+
e.preventDefault();
|
|
610
|
+
const box = svg.getBoundingClientRect();
|
|
611
|
+
const mx = e.clientX - box.left, my = e.clientY - box.top;
|
|
612
|
+
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
|
613
|
+
const next = Math.min(2.5, Math.max(0.15, view.k * factor));
|
|
614
|
+
view.x = mx - (mx - view.x) * (next / view.k);
|
|
615
|
+
view.y = my - (my - view.y) * (next / view.k);
|
|
616
|
+
view.k = next;
|
|
617
|
+
applyView();
|
|
618
|
+
}, { passive: false });
|
|
619
|
+
|
|
620
|
+
/* ------------------------------------------------------------------ */
|
|
621
|
+
/* side panel */
|
|
622
|
+
/* ------------------------------------------------------------------ */
|
|
623
|
+
|
|
624
|
+
function select(node) {
|
|
625
|
+
selected = node;
|
|
626
|
+
render();
|
|
627
|
+
renderPanel();
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function pill(status) {
|
|
631
|
+
const tone = STATUS_TONE[status] || "neutral";
|
|
632
|
+
return `<span class="pill ${tone}">${status}</span>`;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function renderPanel() {
|
|
636
|
+
if (!graph.nodes.length) {
|
|
637
|
+
panel.innerHTML = `<div class="empty">
|
|
638
|
+
<p>The memory is empty.</p>
|
|
639
|
+
<p>Load the supplier demo:</p>
|
|
640
|
+
<p><code>python -m experiments.seed_demo</code></p>
|
|
641
|
+
<p style="margin-top:20px">Then hit Refresh.</p>
|
|
642
|
+
</div>`;
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (!selected) {
|
|
646
|
+
const s = graph.stats;
|
|
647
|
+
panel.innerHTML = `<div class="empty">
|
|
648
|
+
<p><b>${s.beliefs}</b> beliefs rest on <b>${s.independent_sources}</b> real source${s.independent_sources === 1 ? "" : "s"}.</p>
|
|
649
|
+
<p style="margin-top:16px">Click any node to trace it back to the evidence it actually came from.</p>
|
|
650
|
+
<p style="margin-top:16px;font-size:11px">Everything below a node in the graph is what it depends on. Observations are the bottom row.</p>
|
|
651
|
+
</div>`;
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
const renderers = {
|
|
655
|
+
observation: observationPanel,
|
|
656
|
+
belief: beliefPanel,
|
|
657
|
+
claim: claimPanel,
|
|
658
|
+
decision: decisionPanel,
|
|
659
|
+
};
|
|
660
|
+
panel.innerHTML = renderers[selected.type](selected);
|
|
661
|
+
wireActions();
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function observationPanel(node) {
|
|
665
|
+
const d = node.detail;
|
|
666
|
+
return `
|
|
667
|
+
<h2 style="color:var(--observation)">Observation</h2>
|
|
668
|
+
<p class="title">${escapeHtml(d.content)}</p>
|
|
669
|
+
${pill(node.status)}
|
|
670
|
+
${d.validity_reason ? `<div class="note bad">${escapeHtml(d.validity_reason)}</div>` : ""}
|
|
671
|
+
<dl class="kv">
|
|
672
|
+
<dt>source type</dt><dd>${escapeHtml(d.source_type)}</dd>
|
|
673
|
+
<dt>source actor</dt><dd>${escapeHtml(d.source_actor || "—")}</dd>
|
|
674
|
+
<dt>reliability</dt><dd>${d.reliability_score}</dd>
|
|
675
|
+
<dt>source family</dt><dd>${escapeHtml(d.source_family_id || "— (counts as its own source)")}</dd>
|
|
676
|
+
<dt>independence key</dt><dd style="font-size:11px;color:var(--ink-dim)">${escapeHtml(d.independence_key)}</dd>
|
|
677
|
+
</dl>
|
|
678
|
+
<div class="note">This is real evidence from outside the system. It is never edited or deleted — only its validity can change.</div>
|
|
679
|
+
${node.status === "valid" ? `
|
|
680
|
+
<div class="actions">
|
|
681
|
+
<button class="danger" data-action="invalidate" data-id="${node.id}">Retract this source…</button>
|
|
682
|
+
</div>
|
|
683
|
+
<p style="font-size:11px;color:var(--ink-faint)">Retracting walks forward through every belief, claim and decision built on this, and repairs them.</p>
|
|
684
|
+
` : ""}
|
|
685
|
+
`;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function beliefPanel(node) {
|
|
689
|
+
const d = node.detail;
|
|
690
|
+
const roots = d.evidence_roots.map(id => graph.nodes.find(n => n.id === id)).filter(Boolean);
|
|
691
|
+
const isEcho = d.independent_source_count === 1 && hasBeliefParent(node.id);
|
|
692
|
+
return `
|
|
693
|
+
<h2 style="color:var(--belief)">Belief</h2>
|
|
694
|
+
<p class="title">${escapeHtml(node.label)}</p>
|
|
695
|
+
${pill(node.status)} ${pill(d.visibility)}
|
|
696
|
+
<dl class="kv">
|
|
697
|
+
<dt>held by</dt><dd>${escapeHtml(d.agent)}</dd>
|
|
698
|
+
<dt>confidence</dt><dd>${d.confidence}</dd>
|
|
699
|
+
<dt>claim key</dt><dd>${escapeHtml(d.claim_key)}</dd>
|
|
700
|
+
<dt>written by</dt><dd>${d.llm_generated ? "an LLM" : "deterministic code"}</dd>
|
|
701
|
+
</dl>
|
|
702
|
+
<div class="counts">
|
|
703
|
+
<div class="count key"><b>${d.independent_source_count}</b><i>real source${d.independent_source_count === 1 ? "" : "s"}</i></div>
|
|
704
|
+
</div>
|
|
705
|
+
<h2>Traces back to</h2>
|
|
706
|
+
${roots.length ? roots.map(r => `
|
|
707
|
+
<div class="note ${r.status === "valid" ? "good" : "bad"}">
|
|
708
|
+
${escapeHtml(truncate(r.detail.content, 90))}<br>
|
|
709
|
+
<span style="color:var(--ink-faint)">${escapeHtml(r.detail.source_type)} · ${r.status}</span>
|
|
710
|
+
</div>`).join("")
|
|
711
|
+
: `<div class="note bad">Nothing. Every source underneath this belief has been retracted.</div>`}
|
|
712
|
+
${isEcho ? `<div class="note bad">This belief was formed by reading another agent, not by looking at new evidence. It cannot corroborate what it came from.</div>` : ""}
|
|
713
|
+
<div class="actions">
|
|
714
|
+
<button data-action="provenance" data-id="${node.id}">Show full ancestry (JSON)</button>
|
|
715
|
+
</div>
|
|
716
|
+
`;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function claimPanel(node) {
|
|
720
|
+
const d = node.detail;
|
|
721
|
+
const s = d.support, c = d.contradictions;
|
|
722
|
+
return `
|
|
723
|
+
<h2 style="color:var(--claim)">Claim</h2>
|
|
724
|
+
<p class="title">${escapeHtml(node.label)}</p>
|
|
725
|
+
${pill(node.status)}
|
|
726
|
+
<div class="counts">
|
|
727
|
+
<div class="count"><b>${s.agreeing_agents}</b><i>agents agree</i></div>
|
|
728
|
+
<div class="count"><b>${s.belief_count}</b><i>beliefs</i></div>
|
|
729
|
+
<div class="count key"><b>${s.independent_sources}</b><i>real sources</i></div>
|
|
730
|
+
</div>
|
|
731
|
+
${d.shares_single_source ? `
|
|
732
|
+
<div class="note bad"><b>False corroboration.</b> ${s.belief_count} beliefs from ${s.agreeing_agents} agents all descend from one single piece of evidence. The agreement is an echo.</div>
|
|
733
|
+
` : ""}
|
|
734
|
+
<dl class="kv">
|
|
735
|
+
<dt>support confidence</dt><dd>${s.confidence} <span style="color:var(--ink-faint)">(naive mean would be ${s.naive_average_confidence})</span></dd>
|
|
736
|
+
<dt>contradictions</dt><dd>${c.belief_count} belief${c.belief_count === 1 ? "" : "s"} from ${c.independent_sources} source${c.independent_sources === 1 ? "" : "s"}${c.belief_count ? ` at ${c.confidence}` : ""}</dd>
|
|
737
|
+
<dt>promoted at</dt><dd>${d.promoted_at ? new Date(d.promoted_at).toLocaleString() : "— not in shared memory"}</dd>
|
|
738
|
+
</dl>
|
|
739
|
+
<div class="actions">
|
|
740
|
+
<button class="primary" data-action="evaluate" data-id="${node.id}">Run the promotion gate</button>
|
|
741
|
+
<button data-action="audits" data-id="${node.id}">History</button>
|
|
742
|
+
</div>
|
|
743
|
+
<div id="verdict"></div>
|
|
744
|
+
`;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function decisionPanel(node) {
|
|
748
|
+
const d = node.detail;
|
|
749
|
+
return `
|
|
750
|
+
<h2 style="color:var(--decision)">Decision</h2>
|
|
751
|
+
<p class="title">${escapeHtml(node.label)}</p>
|
|
752
|
+
${pill(node.status)}
|
|
753
|
+
<dl class="kv">
|
|
754
|
+
<dt>type</dt><dd>${escapeHtml(d.decision_type)}</dd>
|
|
755
|
+
<dt>taken by</dt><dd>${escapeHtml(d.agent)}</dd>
|
|
756
|
+
<dt>rests on</dt><dd>${d.depends_on.length} claim${d.depends_on.length === 1 ? "" : "s"}</dd>
|
|
757
|
+
</dl>
|
|
758
|
+
${d.meta && d.meta.rationale ? `<div class="note">${escapeHtml(d.meta.rationale)}</div>` : ""}
|
|
759
|
+
${node.status === "needs_review"
|
|
760
|
+
? `<div class="note bad">The evidence under this decision changed after it was taken. It has been flagged automatically.</div>`
|
|
761
|
+
: `<div class="note good">Still standing on valid evidence.</div>`}
|
|
762
|
+
`;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function hasBeliefParent(id) {
|
|
766
|
+
return graph.edges.some(e =>
|
|
767
|
+
e.source === id && e.edge_type === "derived_from" &&
|
|
768
|
+
(graph.nodes.find(n => n.id === e.target) || {}).type === "belief");
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/* ------------------------------------------------------------------ */
|
|
772
|
+
/* actions */
|
|
773
|
+
/* ------------------------------------------------------------------ */
|
|
774
|
+
|
|
775
|
+
function wireActions() {
|
|
776
|
+
panel.querySelectorAll("[data-action]").forEach(button => {
|
|
777
|
+
button.addEventListener("click", () => handle(button.dataset.action, button.dataset.id, button));
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function handle(action, id, button) {
|
|
782
|
+
button.disabled = true;
|
|
783
|
+
try {
|
|
784
|
+
if (action === "evaluate") {
|
|
785
|
+
const verdict = await api(`/claims/${id}/evaluate-promotion`, { method: "POST" });
|
|
786
|
+
showVerdict(verdict);
|
|
787
|
+
} else if (action === "audits") {
|
|
788
|
+
const audits = await api(`/claims/${id}/promotion-audits`);
|
|
789
|
+
showAudits(audits);
|
|
790
|
+
} else if (action === "provenance") {
|
|
791
|
+
const payload = await api(`/provenance/${id}?node_type=belief`);
|
|
792
|
+
toast(`${payload.evidence_roots.length} evidence root(s), ${payload.paths.length} path(s) — see console`);
|
|
793
|
+
console.log(payload);
|
|
794
|
+
} else if (action === "invalidate") {
|
|
795
|
+
const reason = prompt("Why is this source being retracted?", "source turned out to be wrong");
|
|
796
|
+
if (reason === null) return;
|
|
797
|
+
const repair = await api(`/observations/${id}/invalidate`, {
|
|
798
|
+
method: "POST",
|
|
799
|
+
body: JSON.stringify({ reason }),
|
|
800
|
+
});
|
|
801
|
+
await load(true);
|
|
802
|
+
toast(`Repaired: ${repair.affected_beliefs.length} belief(s), ${repair.affected_claims.length} claim(s), ${repair.affected_decisions.length} decision(s)`, "ok");
|
|
803
|
+
}
|
|
804
|
+
} catch (error) {
|
|
805
|
+
toast(error.message, "error");
|
|
806
|
+
} finally {
|
|
807
|
+
button.disabled = false;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function showVerdict(v) {
|
|
812
|
+
const box = document.getElementById("verdict");
|
|
813
|
+
if (!box) return;
|
|
814
|
+
const promoted = v.decision === "promote";
|
|
815
|
+
box.innerHTML = `
|
|
816
|
+
<div class="verdict ${promoted ? "promote" : "reject"}">
|
|
817
|
+
<div class="verdict-head">${promoted ? "PROMOTE" : "REJECT"}</div>
|
|
818
|
+
<p>${escapeHtml(v.explanation)}</p>
|
|
819
|
+
<ul class="reasons">
|
|
820
|
+
${v.reasons.map(r => `<li class="${/not_met|below|present|no_valid|single/.test(r) ? "failed" : "met"}">${escapeHtml(r.replace(/_/g, " "))}</li>`).join("")}
|
|
821
|
+
</ul>
|
|
822
|
+
<p style="font-size:11px">policy ${escapeHtml(v.policy_version)} · ${v.agreeing_agents} agents · ${v.independent_support_count} independent source(s) · confidence ${v.aggregate_confidence}</p>
|
|
823
|
+
</div>`;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function showAudits(audits) {
|
|
827
|
+
const box = document.getElementById("verdict");
|
|
828
|
+
if (!box) return;
|
|
829
|
+
box.innerHTML = `<h2 style="margin-top:14px">Decision history</h2>` + (audits.length
|
|
830
|
+
? audits.map(a => `
|
|
831
|
+
<div class="note ${a.result === "promoted" ? "good" : "bad"}">
|
|
832
|
+
<b>${a.result}</b> · ${new Date(a.evaluated_at).toLocaleString()}<br>
|
|
833
|
+
${a.agreeing_agent_count} agents · ${a.independent_support_count} independent source(s) · confidence ${a.aggregate_confidence}
|
|
834
|
+
</div>`).join("")
|
|
835
|
+
: `<p style="color:var(--ink-faint);font-size:12px">The gate has never been run on this claim.</p>`);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function toast(message, kind) {
|
|
839
|
+
const existing = document.querySelector(".toast");
|
|
840
|
+
if (existing) existing.remove();
|
|
841
|
+
const node = document.createElement("div");
|
|
842
|
+
node.className = "toast " + (kind || "");
|
|
843
|
+
node.textContent = message;
|
|
844
|
+
document.body.appendChild(node);
|
|
845
|
+
setTimeout(() => node.remove(), 5200);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function escapeHtml(value) {
|
|
849
|
+
return String(value ?? "").replace(/[&<>"']/g, ch =>
|
|
850
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch]));
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/* ------------------------------------------------------------------ */
|
|
854
|
+
|
|
855
|
+
// Wrapped, not passed directly: a click handler would hand fit() the Event as
|
|
856
|
+
// its retry counter.
|
|
857
|
+
document.getElementById("refresh").addEventListener("click", () => load(true).then(() => fit()));
|
|
858
|
+
document.getElementById("fit").addEventListener("click", () => fit());
|
|
859
|
+
window.addEventListener("resize", () => fit());
|
|
860
|
+
|
|
861
|
+
load().then(() => fit()).catch(error => {
|
|
862
|
+
panel.innerHTML = `<div class="empty"><p>Could not reach the API.</p><p>${escapeHtml(error.message)}</p></div>`;
|
|
863
|
+
});
|
|
864
|
+
</script>
|
|
865
|
+
</body>
|
|
866
|
+
</html>
|