Joshua Sundance Bailey commited on
Commit
a973dff
Β·
1 Parent(s): 1c5cd80

feat: open-world trust-loop UX (chat-first review, edge review, layout reshape)

Browse files

Chat-first magic build; radial + chat-driven Accept/Reject review; edge-claim fix (edges now reviewable + amber); inspector panel dropped + full-width layout; fcose auto-arrange on build; confidence-tier voice; stable full-source context. 44 files, gate-verified upstream (mypy + full pytest green).

Files changed (44) hide show
  1. cytoscapecanvas/frontend/Index.svelte +326 -12
  2. cytoscapecanvas/frontend/renderer.test.ts +411 -8
  3. cytoscapecanvas/frontend/renderer.ts +400 -35
  4. cytoscapecanvas/frontend/types.ts +3 -1
  5. fixtures/seed_local_ai.json +729 -0
  6. scripts/playwright_cxtmenu.py +97 -4
  7. src/loosecanvas/agent_harness.py +133 -32
  8. src/loosecanvas/agent_tools.py +171 -9
  9. src/loosecanvas/contracts.py +2 -1
  10. src/loosecanvas/exporter.py +78 -4
  11. src/loosecanvas/extractors/fixture_adapter.py +11 -2
  12. src/loosecanvas/extractors/text_graph_adapter.py +27 -8
  13. src/loosecanvas/graph_repository.py +15 -1
  14. src/loosecanvas/main.py +553 -169
  15. src/loosecanvas/reducer.py +62 -5
  16. src/loosecanvas/scene_curator.py +83 -5
  17. src/loosecanvas/turn_logic.py +604 -60
  18. src/loosecanvas/validator.py +18 -3
  19. tests/test_agent_harness.py +24 -1
  20. tests/test_agent_tools.py +29 -0
  21. tests/test_b2_cxtmenu_review.py +144 -0
  22. tests/test_contracts.py +3 -2
  23. tests/test_edge_claim_review.py +238 -0
  24. tests/test_exporter.py +322 -3
  25. tests/test_find_connection.py +189 -0
  26. tests/test_fixture_adapter.py +31 -0
  27. tests/test_graph_repository.py +50 -0
  28. tests/test_magic_build_opener.py +172 -0
  29. tests/test_p1_03_claim_review_consumer.py +35 -0
  30. tests/test_renderer_contract_parity.py +7 -4
  31. tests/test_review_agent_tool.py +271 -0
  32. tests/test_review_amber_clear.py +214 -0
  33. tests/test_scene_curator.py +82 -0
  34. tests/test_search_no_match_reset.py +57 -0
  35. tests/test_seed_resilience.py +132 -0
  36. tests/test_t1_03_polish.py +19 -4
  37. tests/test_t2_01_removal.py +1 -1
  38. tests/test_t2_02_claim_review.py +10 -2
  39. tests/test_t2_03_edit_edges.py +192 -6
  40. tests/test_t2_04_node_edits.py +367 -0
  41. tests/test_text_graph_adapter.py +74 -0
  42. tests/test_time_travel_output_arity.py +95 -0
  43. tests/test_tool_narration_chat.py +183 -0
  44. tests/test_tooltip_stale_review.py +135 -0
cytoscapecanvas/frontend/Index.svelte CHANGED
@@ -1,10 +1,14 @@
1
  <script module lang="ts">
2
  import cytoscapeLib from "cytoscape";
3
  import cxtmenu from "cytoscape-cxtmenu";
4
- // Register the radial context-menu extension exactly once. Module scope runs
5
- // a single time for the whole component (not per instance / remount), so this
6
- // avoids Cytoscape's "extension already registered" warning.
 
 
 
7
  cytoscapeLib.use(cxtmenu);
 
8
  </script>
9
 
10
  <script lang="ts">
@@ -16,7 +20,12 @@
16
  import {
17
  applyCommands,
18
  resetCanvas,
19
- spotlightTurnNodes,
 
 
 
 
 
20
  wireSelectionEvents,
21
  wireRenameEvents,
22
  wireTrustTooltip,
@@ -55,6 +64,47 @@
55
  let focusedNodeId = $state<string | null>(null);
56
  // FE-01: non-fatal patch-apply warning surfaced as a dismissible toast.
57
  let patchError = $state<string | null>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
  function fitGraph(): void {
60
  if (!cy || cy.elements().length === 0) return;
@@ -65,6 +115,115 @@
65
  cy.fit(cy.elements(), 28);
66
  }
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  onMount(() => {
69
  cy = cytoscape({
70
  container,
@@ -115,6 +274,18 @@
115
 
116
  wireTrustTooltip(cy);
117
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  // T3-03: Keyboard navigation handler (thin wiring; logic lives in keyboardNav.ts).
119
  // Arrow keys: move focus ring to the nearest visible non-fogged node.
120
  // Enter: dispatch a select event for the focused node.
@@ -122,6 +293,44 @@
122
  function handleKeydown(e: KeyboardEvent): void {
123
  if (!cy) return;
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  if (e.key === "Escape") {
126
  cy.$(":selected").unselect();
127
  cy.elements().removeClass("dimmed");
@@ -191,6 +400,11 @@
191
  return () => {
192
  container.removeEventListener("keydown", handleKeydown);
193
  ro.disconnect();
 
 
 
 
 
194
  cy?.destroy();
195
  cy = null;
196
  };
@@ -226,13 +440,33 @@
226
  resetCanvas(cy);
227
  currentSceneId = value.scene_id;
228
  pendingFit = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  }
230
  const result = applyCommands(cy, value.operations);
231
  lastAppliedPatchId = value.patch_id;
232
- // C1: briefly spotlight the nodes this turn touched (not on a load
233
- // snapshot, where everything is "new" and a spotlight is meaningless).
 
 
234
  if (!value.is_snapshot) {
235
- spotlightTurnNodes(cy, value.operations);
 
 
 
236
  }
237
  // FE-01: per-op failures are non-fatal β€” the rest of the patch still
238
  // applied. Surface a dismissible toast and notify the host.
@@ -285,10 +519,50 @@
285
  {/if}
286
 
287
  <div class="lc-canvas-wrapper">
288
- <!-- T3-03: Zoom-to-fit toolbar button; pure view operation, no backend call -->
289
- <button class="lc-fit-btn" type="button" aria-label="Zoom to fit all nodes" onclick={() => fitGraph()}>
290
- ⊑ Fit
291
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  <div bind:this={container} class="lc-cytoscape-canvas"></div>
293
  </div>
294
 
@@ -313,11 +587,15 @@
313
  .lc-canvas-wrapper {
314
  position: relative;
315
  }
316
- .lc-fit-btn {
317
  position: absolute;
318
  top: 8px;
319
  right: 8px;
320
  z-index: 10;
 
 
 
 
321
  padding: 4px 8px;
322
  background: rgba(17, 19, 26, 0.85);
323
  color: #cbd5e0;
@@ -329,6 +607,42 @@
329
  .lc-fit-btn:hover {
330
  background: #2d3748;
331
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  .lc-cytoscape-canvas {
333
  width: 100%;
334
  /* CRITICAL: fixed px height, NEVER vh. The app is embedded in an HF Spaces
 
1
  <script module lang="ts">
2
  import cytoscapeLib from "cytoscape";
3
  import cxtmenu from "cytoscape-cxtmenu";
4
+ // @ts-expect-error cytoscape-fcose ships no types (same as cxtmenu)
5
+ import fcose from "cytoscape-fcose";
6
+ // Register extensions exactly once. Module scope runs a single time for the
7
+ // whole component (not per instance / remount), so this avoids Cytoscape's
8
+ // "extension already registered" warning. cxtmenu = radial menu; fcose =
9
+ // force-directed auto-layout (the "Arrange" button).
10
  cytoscapeLib.use(cxtmenu);
11
+ cytoscapeLib.use(fcose);
12
  </script>
13
 
14
  <script lang="ts">
 
20
  import {
21
  applyCommands,
22
  resetCanvas,
23
+ dimBurst,
24
+ collectTurnNodeIds,
25
+ startReviewPulse,
26
+ stopReviewPulse,
27
+ wireHoverFade,
28
+ wireEdgeHover,
29
  wireSelectionEvents,
30
  wireRenameEvents,
31
  wireTrustTooltip,
 
64
  let focusedNodeId = $state<string | null>(null);
65
  // FE-01: non-fatal patch-apply warning surfaced as a dismissible toast.
66
  let patchError = $state<string | null>(null);
67
+ // Review-queue badge: count of model-inferred proposals still awaiting the
68
+ // user's decision. Derived from the canvas after every patch so it counts down
69
+ // as proposals are accepted/rejected β€” framing review as the act that makes
70
+ // knowledge real. Pure frontend (no backend arity), so it never touches turn_outputs.
71
+ let pendingCount = $state(0);
72
+ // Task 4: ids of nodes added/touched by the most-recent non-snapshot turn.
73
+ // Used by settleNewNodes() to pin existing nodes and only lay out the new ones.
74
+ // Reset to empty on a snapshot (full rebuild) so "Settle new" falls back to
75
+ // a full layout on the first turn after a reload.
76
+ let lastTurnNodeIds = $state<Set<string>>(new Set());
77
+
78
+ // Auto-arrange debounce: after a BUILD/LOAD snapshot lands, wait 450 ms for
79
+ // streaming to settle, then run fcose once over all visible nodes. Reset on
80
+ // each new snapshot patch so bursts coalesce into a single layout run.
81
+ // Cleared on teardown so the timer never fires after the component is gone.
82
+ let autoArrangeTimer: ReturnType<typeof setTimeout> | null = null;
83
+
84
+ function updatePendingCount(): void {
85
+ if (cy) pendingCount = cy.$(".review-pending.origin-model-inferred").length;
86
+ }
87
+
88
+ /** j/k review-queue cycling: focus the next/prev pending proposal and select it
89
+ * (which surfaces its Accept/Reject in the inspector). Returns false if none. */
90
+ function cyclePending(dir: 1 | -1): boolean {
91
+ if (!cy) return false;
92
+ const pending = cy
93
+ .$(".review-pending.origin-model-inferred")
94
+ .filter((el) => el.isNode() && el.style("display") !== "none");
95
+ const ids = pending.map((n) => n.id());
96
+ if (ids.length === 0) return false;
97
+ const cur = focusedNodeId ? ids.indexOf(focusedNodeId) : -1;
98
+ const next = ids[(cur + (dir === 1 ? 1 : ids.length - 1)) % ids.length];
99
+ if (focusedNodeId) cy.getElementById(focusedNodeId).removeClass("focus-ring");
100
+ focusedNodeId = next;
101
+ const node = cy.getElementById(next);
102
+ node.addClass("focus-ring");
103
+ cy.animate({ center: { eles: node }, zoom: Math.max(cy.zoom(), 1.2) }, { duration: 300 });
104
+ cy.$(":selected").unselect();
105
+ node.select();
106
+ return true;
107
+ }
108
 
109
  function fitGraph(): void {
110
  if (!cy || cy.elements().length === 0) return;
 
115
  cy.fit(cy.elements(), 28);
116
  }
117
 
118
+ // Layout algorithms offered in the toolbar. fcose is the force-directed default
119
+ // (registered in the module script); concentric/grid/breadthfirst are native
120
+ // Cytoscape layouts (no extra dep). The frontend owns positions (plan/05), so a
121
+ // user-triggered re-layout is allowed.
122
+ type LayoutName = "fcose" | "concentric" | "grid" | "breadthfirst";
123
+
124
+ // Auto-arrange β€” the "✦ Arrange" button (fcose) + the layout <select>. Animates
125
+ // to a tidy arrangement and re-fits. Lays out only visible (non-fogged) elements
126
+ // so fogged nodes keep their place. No-op on an empty canvas.
127
+ function runLayout(name: LayoutName): void {
128
+ if (!cy || cy.elements().length === 0) return;
129
+ cy.elements().removeClass("dimmed");
130
+ const eles = cy.elements().filter((el) => !el.hasClass("fogged"));
131
+ // Per-algorithm options. fcose keeps the existing tuning; the native layouts
132
+ // share the same animate/fit/padding feel so switching is seamless.
133
+ const optsByName: Record<LayoutName, Record<string, unknown>> = {
134
+ fcose: {
135
+ name: "fcose",
136
+ animate: true,
137
+ animationDuration: 650,
138
+ fit: true,
139
+ padding: 36,
140
+ nodeSeparation: 95,
141
+ randomize: false,
142
+ quality: "default",
143
+ },
144
+ concentric: {
145
+ name: "concentric",
146
+ animate: true,
147
+ animationDuration: 650,
148
+ fit: true,
149
+ padding: 36,
150
+ minNodeSpacing: 40,
151
+ },
152
+ grid: {
153
+ name: "grid",
154
+ animate: true,
155
+ animationDuration: 650,
156
+ fit: true,
157
+ padding: 36,
158
+ avoidOverlap: true,
159
+ },
160
+ breadthfirst: {
161
+ name: "breadthfirst",
162
+ animate: true,
163
+ animationDuration: 650,
164
+ fit: true,
165
+ padding: 36,
166
+ spacingFactor: 1.2,
167
+ directed: false,
168
+ },
169
+ };
170
+ const opts = optsByName[name] as unknown as import("cytoscape").LayoutOptions;
171
+ eles.layout(opts).run();
172
+ }
173
+
174
+ /**
175
+ * Task 4: Partial fcose layout β€” settle ONLY newly-added nodes while keeping
176
+ * existing nodes pinned at their current positions via fcose `fixedNodeConstraint`.
177
+ * If the last-turn set is empty (no turn applied yet) or covers all canvas nodes
178
+ * (a full rebuild), falls back to a full runLayout("fcose") instead.
179
+ */
180
+ function settleNewNodes(): void {
181
+ if (!cy || cy.elements().length === 0) return;
182
+ const allNodeIds = cy.nodes().map((n) => n.id());
183
+ // Fall back to full layout when there are no last-turn nodes (fresh snapshot
184
+ // with no turn yet applied) or when every node is a new node.
185
+ if (lastTurnNodeIds.size === 0 || lastTurnNodeIds.size >= allNodeIds.length) {
186
+ runLayout("fcose");
187
+ return;
188
+ }
189
+ // Build fixedNodeConstraint for every node NOT in lastTurnNodeIds so they
190
+ // keep their positions. Only visible (non-fogged) nodes join the layout.
191
+ interface FixedConstraint { nodeId: string; position: { x: number; y: number } }
192
+ const fixedNodeConstraint: FixedConstraint[] = [];
193
+ cy.nodes().forEach((n) => {
194
+ if (!lastTurnNodeIds.has(n.id()) && !n.hasClass("fogged")) {
195
+ const pos = n.position();
196
+ fixedNodeConstraint.push({ nodeId: n.id(), position: { x: pos.x, y: pos.y } });
197
+ }
198
+ });
199
+ const eles = cy.elements().filter((el) => !el.hasClass("fogged"));
200
+ const opts = {
201
+ name: "fcose",
202
+ animate: true,
203
+ animationDuration: 650,
204
+ fit: true,
205
+ padding: 36,
206
+ nodeSeparation: 95,
207
+ randomize: false,
208
+ quality: "default",
209
+ fixedNodeConstraint,
210
+ } as unknown as import("cytoscape").LayoutOptions;
211
+ eles.layout(opts).run();
212
+ }
213
+
214
+ // Pure-view zoom around the viewport center (the οΌ‹ / - toolbar buttons). No
215
+ // backend call; animates a smooth zoom so the gesture reads as deliberate.
216
+ function zoomBy(factor: number): void {
217
+ if (!cy) return;
218
+ cy.animate(
219
+ {
220
+ zoom: cy.zoom() * factor,
221
+ renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 },
222
+ },
223
+ { duration: 180 },
224
+ );
225
+ }
226
+
227
  onMount(() => {
228
  cy = cytoscape({
229
  container,
 
274
 
275
  wireTrustTooltip(cy);
276
 
277
+ // Hover-fade: fade everything outside the hovered node's neighbourhood so
278
+ // local structure pops (uses its own class; clears on mouseout).
279
+ wireHoverFade(cy);
280
+
281
+ // Edge hover-emphasis: brighten + thicken the hovered edge (class toggle only).
282
+ wireEdgeHover(cy);
283
+
284
+ // Pulse the amber "awaiting review" border on model-inferred pending nodes
285
+ // so a proposal that needs the user's decision draws the eye. Stopped in the
286
+ // teardown below before cy.destroy() so the interval never hits a dead core.
287
+ startReviewPulse(cy);
288
+
289
  // T3-03: Keyboard navigation handler (thin wiring; logic lives in keyboardNav.ts).
290
  // Arrow keys: move focus ring to the nearest visible non-fogged node.
291
  // Enter: dispatch a select event for the focused node.
 
293
  function handleKeydown(e: KeyboardEvent): void {
294
  if (!cy) return;
295
 
296
+ // Single-key review shortcuts (the high-frequency path; far cheaper than a
297
+ // command palette). Guarded against firing while typing in a field; the
298
+ // handler is bound to the canvas container, so it only fires when the
299
+ // canvas has focus anyway. A = accept, R = reject the selected (or focused)
300
+ // node's proposal; F = zoom-to-fit. A/R reuse the same "review" select
301
+ // channel the radial menu dispatches, so the backend routing is unchanged.
302
+ const target = e.target as HTMLElement | null;
303
+ const typing =
304
+ target?.tagName === "INPUT" ||
305
+ target?.tagName === "TEXTAREA" ||
306
+ target?.isContentEditable === true;
307
+ if (!typing) {
308
+ const k = e.key.toLowerCase();
309
+ if (k === "f") {
310
+ e.preventDefault();
311
+ fitGraph();
312
+ return;
313
+ }
314
+ if (k === "a" || k === "r") {
315
+ const sel = cy.$("node:selected").first();
316
+ const id = sel.nonempty() ? sel.id() : focusedNodeId;
317
+ if (id) {
318
+ e.preventDefault();
319
+ gradio.dispatch("select", {
320
+ index: null,
321
+ value: { kind: "review", id, action: k === "a" ? "accepted" : "rejected" },
322
+ selected: false,
323
+ });
324
+ }
325
+ return;
326
+ }
327
+ if (k === "j" || k === "k") {
328
+ // Cycle the review queue: focus next/prev pending proposal + select it.
329
+ if (cyclePending(k === "j" ? 1 : -1)) e.preventDefault();
330
+ return;
331
+ }
332
+ }
333
+
334
  if (e.key === "Escape") {
335
  cy.$(":selected").unselect();
336
  cy.elements().removeClass("dimmed");
 
400
  return () => {
401
  container.removeEventListener("keydown", handleKeydown);
402
  ro.disconnect();
403
+ if (autoArrangeTimer !== null) {
404
+ clearTimeout(autoArrangeTimer);
405
+ autoArrangeTimer = null;
406
+ }
407
+ if (cy) stopReviewPulse(cy);
408
  cy?.destroy();
409
  cy = null;
410
  };
 
440
  resetCanvas(cy);
441
  currentSceneId = value.scene_id;
442
  pendingFit = true;
443
+ // A snapshot replaces the whole scene β€” clear the last-turn set so
444
+ // "Settle new" falls back to a full layout on the next turn.
445
+ lastTurnNodeIds = new Set();
446
+ // Auto-arrange: schedule fcose 450 ms after the most-recent snapshot
447
+ // patch so streaming bursts coalesce into a single layout run. The
448
+ // timer is reset on each new snapshot patch and cleared on teardown.
449
+ // Guard: only run when at least one visible (non-fogged) node exists.
450
+ if (autoArrangeTimer !== null) clearTimeout(autoArrangeTimer);
451
+ autoArrangeTimer = setTimeout(() => {
452
+ autoArrangeTimer = null;
453
+ if (!cy) return;
454
+ const visibleNodes = cy.nodes().filter((n) => !n.hasClass("fogged"));
455
+ if (visibleNodes.length === 0) return;
456
+ runLayout("fcose");
457
+ }, 450);
458
  }
459
  const result = applyCommands(cy, value.operations);
460
  lastAppliedPatchId = value.patch_id;
461
+ updatePendingCount(); // refresh the review-queue badge after every patch
462
+ // C1+C3: post-turn dim-burst β€” glow what the agent just touched and
463
+ // briefly fade the rest, so "what changed" is unmistakable. Not on a
464
+ // load snapshot, where everything is "new" and a burst is meaningless.
465
  if (!value.is_snapshot) {
466
+ const turnIds = collectTurnNodeIds(value.operations);
467
+ dimBurst(cy, turnIds);
468
+ // Track the set for Task 4 partial layout ("Settle new" button).
469
+ lastTurnNodeIds = new Set(turnIds);
470
  }
471
  // FE-01: per-op failures are non-fatal β€” the rest of the patch still
472
  // applied. Surface a dismissible toast and notify the host.
 
519
  {/if}
520
 
521
  <div class="lc-canvas-wrapper">
522
+ <!-- Pure view operations; no backend call. Arrange = fcose auto-layout;
523
+ the select switches force/concentric/grid/tree; the zoom buttons zoom
524
+ around the viewport center; Fit fits all nodes. -->
525
+ <div class="lc-canvas-toolbar">
526
+ <button class="lc-fit-btn" type="button" aria-label="Auto-arrange the graph layout" onclick={() => runLayout("fcose")}>
527
+ ✦ Arrange
528
+ </button>
529
+ <button class="lc-fit-btn" type="button" aria-label="Settle newly-added nodes while pinning existing ones" onclick={() => settleNewNodes()}>
530
+ βŠ• Settle new
531
+ </button>
532
+ <select
533
+ class="lc-fit-btn lc-layout-select"
534
+ aria-label="Choose graph layout algorithm"
535
+ onchange={(e) => runLayout(e.currentTarget.value as LayoutName)}
536
+ >
537
+ <option value="fcose">Force</option>
538
+ <option value="concentric">Concentric</option>
539
+ <option value="grid">Grid</option>
540
+ <option value="breadthfirst">Tree</option>
541
+ </select>
542
+ <button class="lc-fit-btn lc-zoom-btn" type="button" aria-label="Zoom in" onclick={() => zoomBy(1.2)}>
543
+ οΌ‹
544
+ </button>
545
+ <button class="lc-fit-btn lc-zoom-btn" type="button" aria-label="Zoom out" onclick={() => zoomBy(0.8)}>
546
+ -
547
+ </button>
548
+ <button class="lc-fit-btn" type="button" aria-label="Zoom to fit all nodes" onclick={() => fitGraph()}>
549
+ ⊑ Fit
550
+ </button>
551
+ </div>
552
+ <!-- Review-queue badge: frames review as the act that makes knowledge real.
553
+ Counts down as proposals are accepted/rejected; press j/k to cycle them. -->
554
+ <div
555
+ class="lc-review-badge"
556
+ class:lc-review-clear={pendingCount === 0}
557
+ aria-live="polite"
558
+ title="Model proposals awaiting your review β€” press j/k to cycle them"
559
+ >
560
+ {#if pendingCount > 0}
561
+ ⬑ {pendingCount} awaiting review
562
+ {:else}
563
+ βœ“ all reviewed
564
+ {/if}
565
+ </div>
566
  <div bind:this={container} class="lc-cytoscape-canvas"></div>
567
  </div>
568
 
 
587
  .lc-canvas-wrapper {
588
  position: relative;
589
  }
590
+ .lc-canvas-toolbar {
591
  position: absolute;
592
  top: 8px;
593
  right: 8px;
594
  z-index: 10;
595
+ display: flex;
596
+ gap: 6px;
597
+ }
598
+ .lc-fit-btn {
599
  padding: 4px 8px;
600
  background: rgba(17, 19, 26, 0.85);
601
  color: #cbd5e0;
 
607
  .lc-fit-btn:hover {
608
  background: #2d3748;
609
  }
610
+ /* Dark pill <select> matching the buttons. Fixed sizing only (never vh). */
611
+ .lc-layout-select {
612
+ appearance: none;
613
+ -webkit-appearance: none;
614
+ height: 26px;
615
+ padding-right: 18px;
616
+ }
617
+ .lc-layout-select option {
618
+ background: #11131a;
619
+ color: #cbd5e0;
620
+ }
621
+ /* Square zoom pills with a slightly larger, centered glyph. */
622
+ .lc-zoom-btn {
623
+ width: 28px;
624
+ text-align: center;
625
+ font-size: 14px;
626
+ line-height: 1;
627
+ }
628
+ .lc-review-badge {
629
+ position: absolute;
630
+ top: 8px;
631
+ left: 8px;
632
+ z-index: 10;
633
+ padding: 4px 10px;
634
+ background: rgba(17, 19, 26, 0.85);
635
+ color: #f59e0b;
636
+ border: 1px solid #f59e0b;
637
+ border-radius: 999px;
638
+ font-size: 12px;
639
+ font-weight: 600;
640
+ pointer-events: none;
641
+ }
642
+ .lc-review-clear {
643
+ color: #34d399;
644
+ border-color: #34d399;
645
+ }
646
  .lc-cytoscape-canvas {
647
  width: 100%;
648
  /* CRITICAL: fixed px height, NEVER vh. The app is embedded in an HF Spaces
cytoscapecanvas/frontend/renderer.test.ts CHANGED
@@ -9,16 +9,20 @@
9
  // which still throws and rejects the whole patch.
10
 
11
  import { describe, it, expect, vi } from "vitest";
12
- import type { Core, NodeSingular } from "cytoscape";
13
 
14
  import {
15
  applyCommands,
16
  collectTurnNodeIds,
17
  MAX_PATCH_OPS,
18
  cytoscapeStyle,
 
 
19
  wireCxtMenu,
 
20
  wireSelectionEvents,
21
  promptRename,
 
22
  } from "./renderer";
23
  import type { CxtMenuCommand, CxtMenuOptions, CxtMenuDispatch } from "./renderer";
24
  import type { RendererCommand } from "./types";
@@ -324,6 +328,107 @@ describe("cytoscapeStyle β€” node shape encoding (node-shape-encoding)", () => {
324
  });
325
  });
326
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  describe("cytoscapeStyle β€” edge arrow mapping (edge-arrow-style-mapping)", () => {
328
  it("maps each edge kind to a VALID Cytoscape arrow shape", () => {
329
  for (const kind of ["implies", "is_a", "part_of", "contains", "related_to", "relates_to", "contradicts"]) {
@@ -361,6 +466,7 @@ interface OnCall {
361
  interface CxtScratch {
362
  suppressSelect: boolean;
363
  pendingDeleteEdgeId: string | null;
 
364
  }
365
 
366
  interface CxtCyStub {
@@ -372,6 +478,8 @@ interface CxtCyStub {
372
  getScratch: () => CxtScratch | undefined;
373
  getFitCalls: () => number;
374
  getDimCleared: () => number;
 
 
375
  }
376
 
377
  function makeCxtCy(): CxtCyStub {
@@ -389,9 +497,16 @@ function makeCxtCy(): CxtCyStub {
389
  },
390
  length: 0,
391
  };
 
392
  const elHandle = {
393
- addClass: () => elHandle,
394
- removeClass: () => elHandle,
 
 
 
 
 
 
395
  length: 1,
396
  };
397
  const cyObj = {
@@ -431,6 +546,7 @@ function makeCxtCy(): CxtCyStub {
431
  getScratch: () => scratchStore,
432
  getFitCalls: () => fitCalls,
433
  getDimCleared: () => dimCleared,
 
434
  };
435
  }
436
 
@@ -584,6 +700,88 @@ describe("wireCxtMenu β€” Focus + Rename commands (C2)", () => {
584
  });
585
  });
586
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
587
  describe("wireCxtMenu β€” background (core) menu (C5)", () => {
588
  it("registers a second cxtmenu on the 'core' selector with Fit + Clusters", () => {
589
  const { cy, getCoreOptions } = makeCxtCy();
@@ -596,6 +794,27 @@ describe("wireCxtMenu β€” background (core) menu (C5)", () => {
596
  expect(contents.some((t) => t.includes("cluster"))).toBe(true);
597
  });
598
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599
  it("Fit resizes, clears the Focus dim, and fits", () => {
600
  const { cy, getCoreOptions, getFitCalls, getDimCleared } = makeCxtCy();
601
  wireCxtMenu(cy, () => {});
@@ -636,9 +855,11 @@ describe("wireCxtMenu β€” edge menu, two-step delete confirm (C5b)", () => {
636
  expect(edgeOpts, "no edge menu registered").not.toBeNull();
637
  expect(edgeOpts!.selector).toBe("edge");
638
  const cmds = edgeOpts!.commands(edgeStub("e1"));
639
- expect(cmds).toHaveLength(1);
640
- expect(cmds[0].content.toLowerCase()).toContain("delete");
641
- cmds[0].select(edgeStub("e1"));
 
 
642
  expect(getScratch()!.pendingDeleteEdgeId).toBe("e1"); // armed
643
  expect(dispatched).toHaveLength(0); // but NOT deleted
644
  });
@@ -648,7 +869,9 @@ describe("wireCxtMenu β€” edge menu, two-step delete confirm (C5b)", () => {
648
  const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
649
  wireCxtMenu(cy, (p) => dispatched.push(p));
650
  const edgeOpts = getEdgeOptions()!;
651
- edgeOpts.commands(edgeStub("e1"))[0].select(edgeStub("e1")); // arm
 
 
652
  const armed = edgeOpts.commands(edgeStub("e1")); // second right-click on the same edge
653
  const contents = armed.map((c) => c.content.toLowerCase());
654
  expect(contents.some((t) => t.includes("confirm"))).toBe(true);
@@ -665,7 +888,9 @@ describe("wireCxtMenu β€” edge menu, two-step delete confirm (C5b)", () => {
665
  const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
666
  wireCxtMenu(cy, (p) => dispatched.push(p));
667
  const edgeOpts = getEdgeOptions()!;
668
- edgeOpts.commands(edgeStub("e1"))[0].select(edgeStub("e1")); // arm
 
 
669
  const cancel = edgeOpts.commands(edgeStub("e1")).find((c) => c.content.toLowerCase().includes("cancel"))!;
670
  cancel.select(edgeStub("e1"));
671
  expect(getScratch()!.pendingDeleteEdgeId).toBeNull();
@@ -774,3 +999,181 @@ describe("collectTurnNodeIds", () => {
774
  expect(collectTurnNodeIds(ops)).toEqual([]);
775
  });
776
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  // which still throws and rejects the whole patch.
10
 
11
  import { describe, it, expect, vi } from "vitest";
12
+ import type { Core, NodeSingular, EdgeSingular } from "cytoscape";
13
 
14
  import {
15
  applyCommands,
16
  collectTurnNodeIds,
17
  MAX_PATCH_OPS,
18
  cytoscapeStyle,
19
+ startReviewPulse,
20
+ stopReviewPulse,
21
  wireCxtMenu,
22
+ wireEdgeHover,
23
  wireSelectionEvents,
24
  promptRename,
25
+ promptEdgeLabel,
26
  } from "./renderer";
27
  import type { CxtMenuCommand, CxtMenuOptions, CxtMenuDispatch } from "./renderer";
28
  import type { RendererCommand } from "./types";
 
328
  });
329
  });
330
 
331
+ describe("cytoscapeStyle β€” awaiting-review affordance (review-pending lynchpin)", () => {
332
+ // Regression: the awaiting-review signal was an 18% Cytoscape pie rendered
333
+ // dead-centre UNDER the centred node label β€” invisible. The fix is an amber
334
+ // dashed border (+ a pulse-peak step driven by startReviewPulse).
335
+ it("uses an amber dashed border, not a centred pie", () => {
336
+ const rule = ruleFor(".review-pending.origin-model-inferred");
337
+ expect(rule, "missing .review-pending.origin-model-inferred rule").toBeDefined();
338
+ expect(rule!.style["border-color"]).toBe("#f59e0b");
339
+ expect(rule!.style["border-style"]).toBe("dashed");
340
+ expect(Number(rule!.style["border-width"])).toBeGreaterThanOrEqual(4);
341
+ // The invisible pie must be gone.
342
+ expect(rule!.style["pie-size"]).toBeUndefined();
343
+ // The pulse is a class toggle smoothed by a border transition.
344
+ expect(String(rule!.style["transition-property"])).toContain("border");
345
+ });
346
+
347
+ it("defines a brighter, thicker pulse-peak step", () => {
348
+ const base = ruleFor(".review-pending.origin-model-inferred");
349
+ const peak = ruleFor(".review-pending.origin-model-inferred.pulse-peak");
350
+ expect(peak, "missing pulse-peak rule").toBeDefined();
351
+ expect(Number(peak!.style["border-width"])).toBeGreaterThan(Number(base!.style["border-width"]));
352
+ });
353
+ });
354
+
355
+ describe("startReviewPulse / stopReviewPulse (awaiting-review breathing)", () => {
356
+ function makePulseCy(pendingLength = 1) {
357
+ const classesOnPending = new Set<string>();
358
+ let len = pendingLength;
359
+ const scratchStore = new Map<string, unknown>();
360
+ const collection = {
361
+ get length() {
362
+ return len;
363
+ },
364
+ addClass: (c: string) => {
365
+ classesOnPending.add(c);
366
+ },
367
+ removeClass: (c: string) => {
368
+ classesOnPending.delete(c);
369
+ },
370
+ };
371
+ const cy = {
372
+ // Mirrors Cytoscape's overloaded scratch(ns) read / scratch(ns, val) write.
373
+ scratch: (ns: string, val?: unknown) => {
374
+ if (val !== undefined) {
375
+ scratchStore.set(ns, val);
376
+ return undefined;
377
+ }
378
+ return scratchStore.get(ns);
379
+ },
380
+ $: () => collection,
381
+ } as unknown as Core;
382
+ return { cy, classesOnPending, setPendingLength: (n: number) => (len = n) };
383
+ }
384
+
385
+ it("toggles .pulse-peak on pending nodes over time and is idempotent", () => {
386
+ vi.useFakeTimers();
387
+ try {
388
+ const s = makePulseCy(2);
389
+ startReviewPulse(s.cy, 800);
390
+ startReviewPulse(s.cy, 800); // 2nd call must NOT start a second timer
391
+ expect(s.classesOnPending.has("pulse-peak")).toBe(false);
392
+ vi.advanceTimersByTime(800);
393
+ expect(s.classesOnPending.has("pulse-peak")).toBe(true); // peak
394
+ vi.advanceTimersByTime(800);
395
+ expect(s.classesOnPending.has("pulse-peak")).toBe(false); // trough
396
+ stopReviewPulse(s.cy);
397
+ vi.advanceTimersByTime(2400);
398
+ expect(s.classesOnPending.has("pulse-peak")).toBe(false); // no toggles after stop
399
+ } finally {
400
+ vi.useRealTimers();
401
+ }
402
+ });
403
+
404
+ it("is a quiet no-op tick when nothing is awaiting review", () => {
405
+ vi.useFakeTimers();
406
+ try {
407
+ const s = makePulseCy(0);
408
+ startReviewPulse(s.cy, 800);
409
+ vi.advanceTimersByTime(1600);
410
+ expect(s.classesOnPending.has("pulse-peak")).toBe(false);
411
+ stopReviewPulse(s.cy);
412
+ } finally {
413
+ vi.useRealTimers();
414
+ }
415
+ });
416
+ });
417
+
418
+ describe("cytoscapeStyle β€” delight affordances (hover-fade + dim-burst)", () => {
419
+ it("defines hover-fade and burst-dim as strong fade opacities", () => {
420
+ expect(Number(ruleFor(".hover-faded")!.style.opacity)).toBeLessThan(0.2);
421
+ expect(Number(ruleFor(".burst-dim")!.style.opacity)).toBeLessThan(0.2);
422
+ });
423
+
424
+ it("burst-glow uses a visible overlay halo (the 'what just changed' glow)", () => {
425
+ const glow = ruleFor(".burst-glow");
426
+ expect(glow, "missing .burst-glow rule").toBeDefined();
427
+ expect(glow!.style["overlay-color"]).toBeTruthy();
428
+ expect(Number(glow!.style["overlay-opacity"])).toBeGreaterThan(0);
429
+ });
430
+ });
431
+
432
  describe("cytoscapeStyle β€” edge arrow mapping (edge-arrow-style-mapping)", () => {
433
  it("maps each edge kind to a VALID Cytoscape arrow shape", () => {
434
  for (const kind of ["implies", "is_a", "part_of", "contains", "related_to", "relates_to", "contradicts"]) {
 
466
  interface CxtScratch {
467
  suppressSelect: boolean;
468
  pendingDeleteEdgeId: string | null;
469
+ pendingDeleteNodeId: string | null;
470
  }
471
 
472
  interface CxtCyStub {
 
478
  getScratch: () => CxtScratch | undefined;
479
  getFitCalls: () => number;
480
  getDimCleared: () => number;
481
+ /** Classes added/removed via getElementById(...).addClass/removeClass (pending-delete arming). */
482
+ getElClasses: () => Set<string>;
483
  }
484
 
485
  function makeCxtCy(): CxtCyStub {
 
497
  },
498
  length: 0,
499
  };
500
+ const elClasses = new Set<string>();
501
  const elHandle = {
502
+ addClass: (c: string) => {
503
+ elClasses.add(c);
504
+ return elHandle;
505
+ },
506
+ removeClass: (c: string) => {
507
+ elClasses.delete(c);
508
+ return elHandle;
509
+ },
510
  length: 1,
511
  };
512
  const cyObj = {
 
546
  getScratch: () => scratchStore,
547
  getFitCalls: () => fitCalls,
548
  getDimCleared: () => dimCleared,
549
+ getElClasses: () => elClasses,
550
  };
551
  }
552
 
 
700
  });
701
  });
702
 
703
+ describe("wireCxtMenu β€” node editing commands (Summary + Delete arm/confirm)", () => {
704
+ it("a normal (non-armed) node menu includes Summary and Delete commands", () => {
705
+ const { cy, getCxtOptions } = makeCxtCy();
706
+ wireCxtMenu(cy, () => {});
707
+ const cmds = getCxtOptions()!.commands(nodeStub({ id: "n1" }));
708
+ const contents = cmds.map((c) => c.content.toLowerCase());
709
+ expect(
710
+ contents.some((t) => /summary/i.test(t)),
711
+ "no Summary command"
712
+ ).toBe(true);
713
+ expect(
714
+ contents.some((t) => /delete/i.test(t)),
715
+ "no Delete command"
716
+ ).toBe(true);
717
+ });
718
+
719
+ it("selecting Delete ARMS the node (sets pendingDeleteNodeId + adds pending-delete class), no dispatch", () => {
720
+ const { cy, getCxtOptions, getScratch, getElClasses } = makeCxtCy();
721
+ const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
722
+ wireCxtMenu(cy, (p) => dispatched.push(p));
723
+ const del = getCxtOptions()!
724
+ .commands(nodeStub({ id: "n3" }))
725
+ .find((c) => /delete/i.test(c.content))!;
726
+ del.select(nodeStub({ id: "n3" }));
727
+ expect(getScratch()!.pendingDeleteNodeId).toBe("n3");
728
+ expect(getElClasses().has("pending-delete")).toBe(true);
729
+ expect(dispatched).toHaveLength(0); // armed only β€” not deleted
730
+ });
731
+
732
+ it("when armed (pendingDeleteNodeId === id) returns exactly Confirm + Cancel; Confirm dispatches delete_node", () => {
733
+ const { cy, getCxtOptions, getScratch } = makeCxtCy();
734
+ const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
735
+ wireCxtMenu(cy, (p) => dispatched.push(p));
736
+ const opts = getCxtOptions()!;
737
+ opts
738
+ .commands(nodeStub({ id: "n3" }))
739
+ .find((c) => /delete/i.test(c.content))!
740
+ .select(nodeStub({ id: "n3" })); // arm
741
+ const armed = opts.commands(nodeStub({ id: "n3" })); // second right-click on the same node
742
+ expect(armed).toHaveLength(2);
743
+ const contents = armed.map((c) => c.content.toLowerCase());
744
+ expect(contents.some((t) => t.includes("confirm"))).toBe(true);
745
+ expect(contents.some((t) => t.includes("cancel"))).toBe(true);
746
+ const confirm = armed.find((c) => c.content.toLowerCase().includes("confirm"))!;
747
+ confirm.select(nodeStub({ id: "n3" }));
748
+ expect(dispatched[0].value).toMatchObject({ kind: "delete_node", id: "n3" });
749
+ expect(getScratch()!.pendingDeleteNodeId).toBeNull(); // cleared after confirm
750
+ });
751
+
752
+ it("when armed, Cancel clears the armed state without dispatching", () => {
753
+ const { cy, getCxtOptions, getScratch } = makeCxtCy();
754
+ const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
755
+ wireCxtMenu(cy, (p) => dispatched.push(p));
756
+ const opts = getCxtOptions()!;
757
+ opts
758
+ .commands(nodeStub({ id: "n3" }))
759
+ .find((c) => /delete/i.test(c.content))!
760
+ .select(nodeStub({ id: "n3" })); // arm
761
+ const cancel = opts.commands(nodeStub({ id: "n3" })).find((c) => c.content.toLowerCase().includes("cancel"))!;
762
+ cancel.select(nodeStub({ id: "n3" }));
763
+ expect(getScratch()!.pendingDeleteNodeId).toBeNull();
764
+ expect(dispatched).toHaveLength(0);
765
+ });
766
+
767
+ it("the Summary command opens an inline input and dispatches kind:'edit_summary' on Enter", () => {
768
+ const { cy, getCxtOptions } = makeCxtCy();
769
+ const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
770
+ wireCxtMenu(cy, (p) => dispatched.push(p));
771
+ const summary = getCxtOptions()!
772
+ .commands(nodeStub({ id: "n5" }))
773
+ .find((c) => /summary/i.test(c.content))!;
774
+ summary.select(nodeStub({ id: "n5" }));
775
+ const input = document.querySelector("input");
776
+ expect(input, "Summary did not open an inline input").not.toBeNull();
777
+ input!.value = "A new summary";
778
+ input!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
779
+ const evt = dispatched.find((d) => d.value.kind === "edit_summary");
780
+ expect(evt, "no edit_summary dispatch").toBeDefined();
781
+ expect(evt!.value).toMatchObject({ kind: "edit_summary", id: "n5", text: "A new summary" });
782
+ });
783
+ });
784
+
785
  describe("wireCxtMenu β€” background (core) menu (C5)", () => {
786
  it("registers a second cxtmenu on the 'core' selector with Fit + Clusters", () => {
787
  const { cy, getCoreOptions } = makeCxtCy();
 
794
  expect(contents.some((t) => t.includes("cluster"))).toBe(true);
795
  });
796
 
797
+ it("includes an 'Add node' command that opens a centered input and dispatches kind:'add_node'", () => {
798
+ const { cy, getCoreOptions } = makeCxtCy();
799
+ const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
800
+ wireCxtMenu(cy, (p) => dispatched.push(p));
801
+ const core = getCoreOptions()!;
802
+ const contents = core.commands(nodeStub()).map((c) => c.content.toLowerCase());
803
+ expect(
804
+ contents.some((t) => /add node/i.test(t)),
805
+ "no Add node command"
806
+ ).toBe(true);
807
+ const addNode = core.commands(nodeStub()).find((c) => /add node/i.test(c.content))!;
808
+ addNode.select(nodeStub());
809
+ const input = document.querySelector("input");
810
+ expect(input, "Add node did not open an inline input").not.toBeNull();
811
+ input!.value = "Fresh Node";
812
+ input!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
813
+ const evt = dispatched.find((d) => d.value.kind === "add_node");
814
+ expect(evt, "no add_node dispatch").toBeDefined();
815
+ expect(evt!.value).toMatchObject({ kind: "add_node", label: "Fresh Node" });
816
+ });
817
+
818
  it("Fit resizes, clears the Focus dim, and fits", () => {
819
  const { cy, getCoreOptions, getFitCalls, getDimCleared } = makeCxtCy();
820
  wireCxtMenu(cy, () => {});
 
855
  expect(edgeOpts, "no edge menu registered").not.toBeNull();
856
  expect(edgeOpts!.selector).toBe("edge");
857
  const cmds = edgeOpts!.commands(edgeStub("e1"));
858
+ // expanded to Accept + Reject + Edit label + Delete (4 commands now)
859
+ expect(cmds.length).toBeGreaterThanOrEqual(4);
860
+ const delCmd = cmds.find((c) => /delete/i.test(c.content));
861
+ expect(delCmd, "no Delete command in edge menu").toBeDefined();
862
+ delCmd!.select(edgeStub("e1"));
863
  expect(getScratch()!.pendingDeleteEdgeId).toBe("e1"); // armed
864
  expect(dispatched).toHaveLength(0); // but NOT deleted
865
  });
 
869
  const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
870
  wireCxtMenu(cy, (p) => dispatched.push(p));
871
  const edgeOpts = getEdgeOptions()!;
872
+ // Arm via the Delete command (find it by content, not by index)
873
+ const armCmd = edgeOpts.commands(edgeStub("e1")).find((c) => /delete/i.test(c.content))!;
874
+ armCmd.select(edgeStub("e1")); // arm
875
  const armed = edgeOpts.commands(edgeStub("e1")); // second right-click on the same edge
876
  const contents = armed.map((c) => c.content.toLowerCase());
877
  expect(contents.some((t) => t.includes("confirm"))).toBe(true);
 
888
  const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
889
  wireCxtMenu(cy, (p) => dispatched.push(p));
890
  const edgeOpts = getEdgeOptions()!;
891
+ // Arm via the Delete command (find it by content, not by index)
892
+ const armCmd = edgeOpts.commands(edgeStub("e1")).find((c) => /delete/i.test(c.content))!;
893
+ armCmd.select(edgeStub("e1")); // arm
894
  const cancel = edgeOpts.commands(edgeStub("e1")).find((c) => c.content.toLowerCase().includes("cancel"))!;
895
  cancel.select(edgeStub("e1"));
896
  expect(getScratch()!.pendingDeleteEdgeId).toBeNull();
 
999
  expect(collectTurnNodeIds(ops)).toEqual([]);
1000
  });
1001
  });
1002
+
1003
+ // ── node.pending-delete style (armed-node "right-click again to confirm") ─────
1004
+
1005
+ describe("cytoscapeStyle β€” node.pending-delete (armed delete affordance)", () => {
1006
+ it("mirrors the edge armed-delete style: red dashed border", () => {
1007
+ const rule = ruleFor("node.pending-delete");
1008
+ expect(rule, "missing node.pending-delete rule").toBeDefined();
1009
+ // Red (LC_EDGE_CONTRADICTS) so it reads as the same "armed for deletion" language.
1010
+ expect(rule!.style["border-color"]).toBe("#ef4444");
1011
+ expect(rule!.style["border-style"]).toBe("dashed");
1012
+ expect(Number(rule!.style["border-width"])).toBeGreaterThanOrEqual(3);
1013
+ });
1014
+ });
1015
+
1016
+ // ── wireEdgeHover (edge hover-emphasis) ───────────────────────────────────────
1017
+
1018
+ describe("wireEdgeHover (edge hover emphasis)", () => {
1019
+ it("binds mouseover/mouseout on edges that toggle the edge-hover class", () => {
1020
+ const onCalls: OnCall[] = [];
1021
+ const added: string[] = [];
1022
+ const removed: string[] = [];
1023
+ const edgeTarget = {
1024
+ addClass: (c: string) => added.push(c),
1025
+ removeClass: (c: string) => removed.push(c),
1026
+ };
1027
+ const cy = {
1028
+ on(events: string, a?: unknown, b?: unknown) {
1029
+ if (typeof a === "function") onCalls.push({ events, handler: a as (...args: unknown[]) => void });
1030
+ else onCalls.push({ events, selector: a as string, handler: b as (...args: unknown[]) => void });
1031
+ },
1032
+ } as unknown as Core;
1033
+ wireEdgeHover(cy);
1034
+ const over = onCalls.find((c) => c.events.includes("mouseover") && c.selector === "edge");
1035
+ const out = onCalls.find((c) => c.events.includes("mouseout") && c.selector === "edge");
1036
+ expect(over, "no mouseover edge handler").toBeDefined();
1037
+ expect(out, "no mouseout edge handler").toBeDefined();
1038
+ over!.handler({ target: edgeTarget });
1039
+ expect(added).toContain("edge-hover");
1040
+ out!.handler({ target: edgeTarget });
1041
+ expect(removed).toContain("edge-hover");
1042
+ });
1043
+
1044
+ it("defines a brighter/thicker .edge-hover style rule", () => {
1045
+ // Accept either ".edge-hover" or "edge.edge-hover" selector form.
1046
+ const rule = ruleFor("edge.edge-hover") ?? ruleFor(".edge-hover");
1047
+ expect(rule, "missing edge-hover style rule").toBeDefined();
1048
+ expect(Number(rule!.style.width)).toBeGreaterThanOrEqual(4);
1049
+ expect(rule!.style["line-color"], "edge-hover should brighten the line").toBeTruthy();
1050
+ });
1051
+ });
1052
+
1053
+ // ── Task 5: edge-menu expanded commands + promptEdgeLabel (Task 2/3/5) ─────────
1054
+ //
1055
+ // These tests cover the NEW edge-menu Accept/Reject/Edit-label commands and the
1056
+ // promptEdgeLabel helper. They share the makeCxtCy + edgeStub infrastructure above.
1057
+
1058
+ describe("wireCxtMenu β€” edge menu expanded: Accept / Reject / Edit-label (Task 2/5)", () => {
1059
+ function edgeStubFull(id = "e1", fogged = false): NodeSingular {
1060
+ return {
1061
+ id: () => id,
1062
+ isNode: () => false,
1063
+ hasClass: (c: string) => (c === "fogged" ? fogged : false),
1064
+ data: (k: string) => (k === "label" ? "existing label" : undefined),
1065
+ renderedMidpoint: () => ({ x: 200, y: 150 }),
1066
+ } as unknown as NodeSingular;
1067
+ }
1068
+
1069
+ it("(1) Accept dispatches {kind:'review_edge', id, action:'accepted'}", () => {
1070
+ const { cy, getEdgeOptions } = makeCxtCy();
1071
+ const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
1072
+ wireCxtMenu(cy, (p) => dispatched.push(p));
1073
+ const cmds = getEdgeOptions()!.commands(edgeStubFull("e2"));
1074
+ const accept = cmds.find((c) => /accept/i.test(c.content));
1075
+ expect(accept, "no Accept command on edge menu").toBeDefined();
1076
+ accept!.select(edgeStubFull("e2"));
1077
+ expect(dispatched).toHaveLength(1);
1078
+ expect(dispatched[0].value).toMatchObject({ kind: "review_edge", id: "e2", action: "accepted" });
1079
+ });
1080
+
1081
+ it("(2) Reject dispatches {kind:'review_edge', id, action:'rejected'}", () => {
1082
+ const { cy, getEdgeOptions } = makeCxtCy();
1083
+ const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
1084
+ wireCxtMenu(cy, (p) => dispatched.push(p));
1085
+ const cmds = getEdgeOptions()!.commands(edgeStubFull("e3"));
1086
+ const reject = cmds.find((c) => /reject/i.test(c.content));
1087
+ expect(reject, "no Reject command on edge menu").toBeDefined();
1088
+ reject!.select(edgeStubFull("e3"));
1089
+ expect(dispatched[0].value).toMatchObject({ kind: "review_edge", id: "e3", action: "rejected" });
1090
+ });
1091
+
1092
+ it("(3) a fogged edge yields no actionable menu (empty [])", () => {
1093
+ const { cy, getEdgeOptions } = makeCxtCy();
1094
+ wireCxtMenu(cy, () => {});
1095
+ const cmds = getEdgeOptions()!.commands(edgeStubFull("e4", true));
1096
+ expect(cmds).toEqual([]);
1097
+ });
1098
+
1099
+ it("(4) Edit-label opens an inline input and dispatches {kind:'relabel_edge', id, label} on Enter", () => {
1100
+ const { cy, getEdgeOptions } = makeCxtCy();
1101
+ const dispatched: Parameters<CxtMenuDispatch>[0][] = [];
1102
+ wireCxtMenu(cy, (p) => dispatched.push(p));
1103
+ const cmds = getEdgeOptions()!.commands(edgeStubFull("e5"));
1104
+ const editLabel = cmds.find(
1105
+ (c) => /edit.?label/i.test(c.content) || /relabel/i.test(c.content) || /label/i.test(c.content)
1106
+ );
1107
+ expect(editLabel, "no Edit-label command on edge menu").toBeDefined();
1108
+ editLabel!.select(edgeStubFull("e5"));
1109
+ const input = document.querySelector("input");
1110
+ expect(input, "Edit-label did not open an inline input").not.toBeNull();
1111
+ input!.value = "New Edge Label";
1112
+ input!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
1113
+ const evt = dispatched.find((d) => d.value.kind === "relabel_edge");
1114
+ expect(evt, "no relabel_edge dispatch").toBeDefined();
1115
+ expect(evt!.value).toMatchObject({ kind: "relabel_edge", id: "e5", label: "New Edge Label" });
1116
+ });
1117
+
1118
+ it("(5) a non-fogged edge menu exposes >= 4 commands (Accept, Reject, Edit label, Delete)", () => {
1119
+ const { cy, getEdgeOptions } = makeCxtCy();
1120
+ wireCxtMenu(cy, () => {});
1121
+ const cmds = getEdgeOptions()!.commands(edgeStubFull("e6"));
1122
+ expect(cmds.length).toBeGreaterThanOrEqual(4);
1123
+ const contents = cmds.map((c) => c.content.toLowerCase());
1124
+ expect(
1125
+ contents.some((t) => /accept/i.test(t)),
1126
+ "missing Accept"
1127
+ ).toBe(true);
1128
+ expect(
1129
+ contents.some((t) => /reject/i.test(t)),
1130
+ "missing Reject"
1131
+ ).toBe(true);
1132
+ expect(
1133
+ contents.some((t) => /delete/i.test(t)),
1134
+ "missing Delete"
1135
+ ).toBe(true);
1136
+ });
1137
+ });
1138
+
1139
+ describe("promptEdgeLabel (inline label input for edges, Task 3)", () => {
1140
+ function edgeStubForPrompt(id = "e1", label = "current label"): EdgeSingular {
1141
+ return {
1142
+ id: () => id,
1143
+ data: (k: string) => (k === "label" ? label : undefined),
1144
+ renderedMidpoint: () => ({ x: 200, y: 150 }),
1145
+ } as unknown as EdgeSingular;
1146
+ }
1147
+
1148
+ it("opens an inline input pre-filled with the edge's current label and commits on Enter", () => {
1149
+ const container = document.createElement("div");
1150
+ document.body.appendChild(container);
1151
+ const cy = { container: () => container } as unknown as Core;
1152
+ let committed: string | null = null;
1153
+ promptEdgeLabel(cy, edgeStubForPrompt("e1", "old label"), (label) => {
1154
+ committed = label;
1155
+ });
1156
+ const input = document.querySelector("input");
1157
+ expect(input, "no input opened").not.toBeNull();
1158
+ expect(input!.value).toBe("old label");
1159
+ input!.value = "new label";
1160
+ input!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
1161
+ expect(committed).toBe("new label");
1162
+ expect(document.querySelector("input"), "input not removed after commit").toBeNull();
1163
+ });
1164
+
1165
+ it("cancels on Escape without committing", () => {
1166
+ const container = document.createElement("div");
1167
+ document.body.appendChild(container);
1168
+ const cy = { container: () => container } as unknown as Core;
1169
+ let committed: string | null = null;
1170
+ promptEdgeLabel(cy, edgeStubForPrompt("e1", "old"), (label) => {
1171
+ committed = label;
1172
+ });
1173
+ const input = document.querySelector("input")!;
1174
+ input.value = "whatever";
1175
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
1176
+ expect(committed).toBeNull();
1177
+ expect(document.querySelector("input")).toBeNull();
1178
+ });
1179
+ });
cytoscapecanvas/frontend/renderer.ts CHANGED
@@ -25,6 +25,8 @@ const LC_HIGHLIGHTED = "#ff9800"; // amber orange
25
  const LC_INFERRED_EDGE = "#9f7aea"; // purple (legible on dark, less garish than #a0a)
26
  const LC_EDGE_CONTRADICTS = "#ef4444"; // red β€” "contradicts" relations (high-signal trust edge)
27
  const LC_EDGE_SUPPORTS = "#34d399"; // green β€” "supports" relations (matches user-asserted accent)
 
 
28
 
29
  // Fixed cluster-colour hue pool (cluster-louvain-colors). One hue per `cluster-N`
30
  // class emitted by the backend (renderer_adapter cluster deltas). Deliberately
@@ -66,6 +68,12 @@ interface RendererScratch {
66
  connectSource: string | null;
67
  /** Edge id armed for deletion (radial menu first step); null = none pending. */
68
  pendingDeleteEdgeId: string | null;
 
 
 
 
 
 
69
  }
70
 
71
  const SCRATCH_NS = "_lc";
@@ -81,6 +89,9 @@ function getScratch(cy: Core): RendererScratch {
81
  connectMode: false,
82
  connectSource: null,
83
  pendingDeleteEdgeId: null,
 
 
 
84
  };
85
  cy.scratch(SCRATCH_NS, state);
86
  }
@@ -248,6 +259,30 @@ export const cytoscapeStyle: StylesheetJson = [
248
  opacity: 0.4,
249
  },
250
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  // T3-03: keyboard focus ring β€” distinct outset border, composes with .selected/.highlighted.
252
  {
253
  selector: ".focus-ring",
@@ -276,6 +311,29 @@ export const cytoscapeStyle: StylesheetJson = [
276
  "line-style": "dashed",
277
  },
278
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  // ── Trust origin β†’ shape + border style (node-shape-encoding) ─────────────
280
  // Shape reinforces the border-style origin signal (redundant encoding aids
281
  // colour-blind / low-contrast reading). Shapes are convex with a large interior
@@ -304,7 +362,10 @@ export const cytoscapeStyle: StylesheetJson = [
304
  shape: "hexagon",
305
  "border-style": "dashed",
306
  "border-color": LC_NODE_BORDER,
307
- opacity: 0.92,
 
 
 
308
  },
309
  },
310
  // ── Cluster colours (cluster-louvain-colors) β€” placed BEFORE trust support so
@@ -318,13 +379,54 @@ export const cytoscapeStyle: StylesheetJson = [
318
  "background-color": "#7c3aed",
319
  },
320
  },
321
- // ── Pending review dot β€” only for model_inferred + pending ───────────────
 
 
 
 
 
 
322
  {
323
  selector: ".review-pending.origin-model-inferred",
324
  style: {
325
- "pie-size": "18%",
326
- "pie-1-background-color": "#f59e0b",
327
- "pie-1-background-size": 100,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  },
329
  },
330
  ];
@@ -423,6 +525,44 @@ export function spotlightTurnNodes(cy: Core, commands: RendererCommand[], durati
423
  }
424
  }
425
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  /**
427
  * Full reset: remove all elements, tear down callout overlays, and clear
428
  * transient state. Called only on `is_snapshot: true` (load / reconnect).
@@ -631,6 +771,63 @@ function dimExcept(cy: Core, keepIds: string[]): void {
631
  });
632
  }
633
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
634
  function selectElement(cy: Core, id: string | null): void {
635
  const state = getScratch(cy);
636
  // Suppress re-dispatch: this selection is backend-driven, not user-driven.
@@ -790,25 +987,37 @@ export function wireTrustTooltip(cy: Core): void {
790
 
791
  // ── Rename gesture + connect mode ────────────────────────────────────────────
792
 
 
 
 
 
 
 
 
 
 
 
 
 
 
793
  /**
794
- * Show an inline text input over a node to rename it. On Enter or blur it commits
795
- * the trimmed value via `onCommit` (only when non-empty AND changed); Escape
796
- * cancels. Extracted from the double-tap gesture so the radial menu's "Rename"
797
- * command reuses the exact same affordance.
798
  *
799
- * XSS guard: `input.value` is used as plain text β€” NEVER assigned to innerHTML.
 
800
  */
801
- export function promptRename(cy: Core, node: NodeSingular, onCommit: (label: string) => void): void {
802
- const id = node.id();
803
- const currentLabel = (node.data("label") as string | undefined) ?? id;
804
-
805
  const container = cy.container();
806
- if (!container) return;
807
  const parent = container.parentElement ?? container;
808
 
809
  const input = document.createElement("input");
810
  input.type = "text";
811
- input.value = currentLabel;
 
812
  input.style.position = "absolute";
813
  input.style.zIndex = "2000";
814
  input.style.background = "#1a1a2e";
@@ -823,9 +1032,8 @@ export function promptRename(cy: Core, node: NodeSingular, onCommit: (label: str
823
  (parent as HTMLElement).style.position = "relative";
824
  }
825
 
826
- const pos = node.renderedPosition();
827
- input.style.left = `${pos.x - 40}px`;
828
- input.style.top = `${pos.y - 12}px`;
829
  parent.appendChild(input);
830
  input.focus();
831
  input.select();
@@ -835,11 +1043,9 @@ export function promptRename(cy: Core, node: NodeSingular, onCommit: (label: str
835
  function commit(): void {
836
  if (committed) return;
837
  committed = true;
838
- const newLabel = input.value.trim();
839
  input.remove();
840
- if (newLabel && newLabel !== currentLabel) {
841
- onCommit(newLabel);
842
- }
843
  }
844
 
845
  input.addEventListener("keydown", (e) => {
@@ -852,6 +1058,94 @@ export function promptRename(cy: Core, node: NodeSingular, onCommit: (label: str
852
  }
853
  });
854
  input.addEventListener("blur", commit);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
855
  }
856
 
857
  /**
@@ -968,7 +1262,9 @@ interface CxtMenuCore extends Core {
968
  */
969
  export type CxtMenuDispatch = (payload: {
970
  index: null;
971
- value: { kind: string; id: string; action?: string; label?: string };
 
 
972
  selected: boolean;
973
  }) => void;
974
 
@@ -991,6 +1287,27 @@ export function wireCxtMenu(cy: Core, dispatch: CxtMenuDispatch): void {
991
  // openMenu() early-return.
992
  if (ele.hasClass("fogged")) return [];
993
  const id = ele.id();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
994
  const reviewCommand = (action: "accepted" | "rejected"): CxtMenuCommand => ({
995
  content: action === "accepted" ? "βœ“ Accept" : "βœ• Reject",
996
  select: () => dispatch({ index: null, value: { kind: "review", id, action }, selected: false }),
@@ -1014,12 +1331,41 @@ export function wireCxtMenu(cy: Core, dispatch: CxtMenuDispatch): void {
1014
  dispatch({ index: null, value: { kind: "rename", id, label }, selected: false })
1015
  ),
1016
  };
1017
- return [reviewCommand("accepted"), reviewCommand("rejected"), focusCommand, expandCommand, renameCommand];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1018
  };
1019
 
1020
  // Edge menu with an in-canvas, two-step delete confirm (deletion is irreversible
1021
  // except via undo). First right-click arms the edge (red dashed); the follow-up
1022
  // menu on the same edge offers Confirm / Cancel.
 
 
 
 
1023
  const buildEdgeCommands = (ele: CxtMenuEle): CxtMenuCommand[] => {
1024
  if (ele.hasClass("fogged")) return [];
1025
  const id = ele.id();
@@ -1041,22 +1387,35 @@ export function wireCxtMenu(cy: Core, dispatch: CxtMenuDispatch): void {
1041
  { content: "Cancel", select: () => clearArmed() },
1042
  ];
1043
  }
1044
- return [
1045
- {
1046
- content: "βœ• Delete",
1047
- // Arm only β€” does NOT delete. The red highlight signals "right-click again".
1048
- select: () => {
1049
- scratch.pendingDeleteEdgeId = id;
1050
- cy.getElementById(id).addClass("pending-delete");
1051
- },
 
 
 
 
 
 
 
 
 
 
1052
  },
1053
- ];
 
1054
  };
1055
 
1056
  (cy as CxtMenuCore).cxtmenu({
1057
  selector: "node",
1058
  commands: buildNodeCommands,
1059
- menuRadius: () => 80,
 
 
1060
  fillColor: "rgba(17, 19, 26, 0.92)", // matches LC_LABEL_BG pill tone
1061
  activeFillColor: "rgba(59, 91, 219, 0.85)", // LC_NODE_BG indigo
1062
  itemColor: LC_NODE_TEXT,
@@ -1095,6 +1454,12 @@ export function wireCxtMenu(cy: Core, dispatch: CxtMenuDispatch): void {
1095
  content: "🎨 Clusters",
1096
  select: () => dispatch({ index: null, value: { kind: "cluster", id: "" }, selected: false }),
1097
  },
 
 
 
 
 
 
1098
  ],
1099
  menuRadius: () => 70,
1100
  fillColor: "rgba(17, 19, 26, 0.92)",
 
25
  const LC_INFERRED_EDGE = "#9f7aea"; // purple (legible on dark, less garish than #a0a)
26
  const LC_EDGE_CONTRADICTS = "#ef4444"; // red β€” "contradicts" relations (high-signal trust edge)
27
  const LC_EDGE_SUPPORTS = "#34d399"; // green β€” "supports" relations (matches user-asserted accent)
28
+ const LC_REVIEW_AMBER = "#f59e0b"; // awaiting-review border (matches the legend swatch in main.py)
29
+ const LC_REVIEW_AMBER_BRIGHT = "#fbbf24"; // pulse peak β€” brighter amber so the border "breathes"
30
 
31
  // Fixed cluster-colour hue pool (cluster-louvain-colors). One hue per `cluster-N`
32
  // class emitted by the backend (renderer_adapter cluster deltas). Deliberately
 
68
  connectSource: string | null;
69
  /** Edge id armed for deletion (radial menu first step); null = none pending. */
70
  pendingDeleteEdgeId: string | null;
71
+ /** Node id armed for deletion (radial menu first step); null = none pending. */
72
+ pendingDeleteNodeId: string | null;
73
+ /** Interval id driving the awaiting-review border pulse; null when stopped. */
74
+ reviewPulseTimer: ReturnType<typeof setInterval> | null;
75
+ /** Current phase of the review pulse (true = peak/bright, false = trough). */
76
+ reviewPulsePeak: boolean;
77
  }
78
 
79
  const SCRATCH_NS = "_lc";
 
89
  connectMode: false,
90
  connectSource: null,
91
  pendingDeleteEdgeId: null,
92
+ pendingDeleteNodeId: null,
93
+ reviewPulseTimer: null,
94
+ reviewPulsePeak: false,
95
  };
96
  cy.scratch(SCRATCH_NS, state);
97
  }
 
259
  opacity: 0.4,
260
  },
261
  },
262
+ // Hover-fade: while hovering a node, everything outside its closed neighbourhood
263
+ // fades so the local structure pops (the drop-in users expect from Obsidian/Kumu).
264
+ {
265
+ selector: ".hover-faded",
266
+ style: {
267
+ opacity: 0.12,
268
+ },
269
+ },
270
+ // Post-turn dim-burst: briefly fade the rest of the graph (burst-dim) and glow
271
+ // what the agent just added (burst-glow) so "what just changed" is unmistakable.
272
+ {
273
+ selector: ".burst-dim",
274
+ style: {
275
+ opacity: 0.12,
276
+ },
277
+ },
278
+ {
279
+ selector: ".burst-glow",
280
+ style: {
281
+ "overlay-color": LC_SELECTED,
282
+ "overlay-opacity": 0.3,
283
+ "overlay-padding": 8,
284
+ },
285
+ },
286
  // T3-03: keyboard focus ring β€” distinct outset border, composes with .selected/.highlighted.
287
  {
288
  selector: ".focus-ring",
 
311
  "line-style": "dashed",
312
  },
313
  },
314
+ // Node armed for deletion (radial "Delete node" first step) β€” mirrors the edge
315
+ // armed treatment: a red (LC_EDGE_CONTRADICTS) dashed border so the node reads as
316
+ // "right-click again to confirm". Cleared by the Confirm/Cancel follow-up menu.
317
+ {
318
+ selector: "node.pending-delete",
319
+ style: {
320
+ "border-color": LC_EDGE_CONTRADICTS,
321
+ "border-style": "dashed",
322
+ "border-width": 4,
323
+ },
324
+ },
325
+ // Edge hover-emphasis (wireEdgeHover): brighten + thicken the hovered edge so it
326
+ // pops from the slate background. Class toggle only β€” cheap and reversible.
327
+ {
328
+ selector: "edge.edge-hover",
329
+ style: {
330
+ width: 4,
331
+ "line-color": LC_HIGHLIGHTED,
332
+ "target-arrow-color": LC_HIGHLIGHTED,
333
+ opacity: 1,
334
+ "z-index": 10,
335
+ },
336
+ },
337
  // ── Trust origin β†’ shape + border style (node-shape-encoding) ─────────────
338
  // Shape reinforces the border-style origin signal (redundant encoding aids
339
  // colour-blind / low-contrast reading). Shapes are convex with a large interior
 
362
  shape: "hexagon",
363
  "border-style": "dashed",
364
  "border-color": LC_NODE_BORDER,
365
+ // NB: no base opacity here. A static opacity:0.92 here is a LATER stylesheet
366
+ // rule than .dimmed/.hover-faded/.burst-dim (opacity 0.12/0.4) and would WIN,
367
+ // making hover-fade + dim-burst invisible on model_inferred nodes (the whole
368
+ // seed). The hexagon + dashed amber border already distinguish inferred nodes.
369
  },
370
  },
371
  // ── Cluster colours (cluster-louvain-colors) β€” placed BEFORE trust support so
 
379
  "background-color": "#7c3aed",
380
  },
381
  },
382
+ // ── Awaiting review β€” amber dashed border + gentle pulse (model_inferred) ──
383
+ // Replaces a prior 18% Cytoscape pie that rendered DEAD-CENTRE under the
384
+ // centred node label β†’ invisible. An amber border overrides the indigo dashed
385
+ // origin border (above) so an awaiting-review proposal is unmistakable; the
386
+ // `transition-*` props smooth the `.pulse-peak` toggle (driven by
387
+ // startReviewPulse) into a breathing pulse. Pies can't be corner badges in
388
+ // Cytoscape (always centred), so a border treatment is the correct affordance.
389
  {
390
  selector: ".review-pending.origin-model-inferred",
391
  style: {
392
+ "border-color": LC_REVIEW_AMBER,
393
+ "border-width": 4,
394
+ "border-style": "dashed",
395
+ "transition-property": "border-width, border-color",
396
+ // Numeric time = milliseconds (Cytoscape `time` type implicitUnits:'ms') β€” 650ms.
397
+ "transition-duration": 650,
398
+ "transition-timing-function": "ease-in-out",
399
+ },
400
+ },
401
+ // Pulse peak β€” startReviewPulse toggles this class ~every 800ms; the transition
402
+ // on the rule above smooths the border-width/colour step so it "breathes".
403
+ {
404
+ selector: ".review-pending.origin-model-inferred.pulse-peak",
405
+ style: {
406
+ "border-width": 7,
407
+ "border-color": LC_REVIEW_AMBER_BRIGHT,
408
+ },
409
+ },
410
+ // Awaiting-review on EDGES (e.g. a freshly-proposed "hidden connection"). Border
411
+ // props are no-ops on edges, so the amber awaiting-review signal becomes an amber
412
+ // dashed LINE β€” the edge equivalent of the node's amber border. Same trust language.
413
+ {
414
+ selector: "edge.review-pending.origin-model-inferred",
415
+ style: {
416
+ "line-color": LC_REVIEW_AMBER,
417
+ "line-style": "dashed",
418
+ "target-arrow-color": LC_REVIEW_AMBER,
419
+ width: 3,
420
+ "transition-property": "width, line-color",
421
+ "transition-duration": 650,
422
+ },
423
+ },
424
+ {
425
+ selector: "edge.review-pending.origin-model-inferred.pulse-peak",
426
+ style: {
427
+ width: 5,
428
+ "line-color": LC_REVIEW_AMBER_BRIGHT,
429
+ "target-arrow-color": LC_REVIEW_AMBER_BRIGHT,
430
  },
431
  },
432
  ];
 
525
  }
526
  }
527
 
528
+ /**
529
+ * Start the "awaiting review" border pulse. Every `periodMs`, toggle `.pulse-peak`
530
+ * on all model-inferred review-pending nodes so the amber border breathes (the
531
+ * stylesheet `transition-*` on `.review-pending.origin-model-inferred` smooths the
532
+ * step into a gentle pulse). This is the spatial cue that a proposal needs the
533
+ * user's decision β€” the prior centred pie was invisible under the node label.
534
+ *
535
+ * Idempotent: a second call is a no-op while a timer is live. The timer lives for
536
+ * the `cy` lifetime; the host MUST call {@link stopReviewPulse} before
537
+ * `cy.destroy()` so the interval never touches a destroyed core.
538
+ */
539
+ export function startReviewPulse(cy: Core, periodMs = 800): void {
540
+ const state = getScratch(cy);
541
+ if (state.reviewPulseTimer != null) return;
542
+ state.reviewPulseTimer = setInterval(() => {
543
+ const pending = cy.$(".review-pending.origin-model-inferred");
544
+ if (pending.length === 0) {
545
+ // Nothing awaiting review β€” keep the phase at trough so a freshly-added
546
+ // pending node starts calm rather than mid-flash.
547
+ state.reviewPulsePeak = false;
548
+ return;
549
+ }
550
+ state.reviewPulsePeak = !state.reviewPulsePeak;
551
+ if (state.reviewPulsePeak) pending.addClass("pulse-peak");
552
+ else pending.removeClass("pulse-peak");
553
+ }, periodMs);
554
+ }
555
+
556
+ /** Stop the review pulse and clear its interval. Safe to call when not running. */
557
+ export function stopReviewPulse(cy: Core): void {
558
+ const state = getScratch(cy);
559
+ if (state.reviewPulseTimer != null) {
560
+ clearInterval(state.reviewPulseTimer);
561
+ state.reviewPulseTimer = null;
562
+ state.reviewPulsePeak = false;
563
+ }
564
+ }
565
+
566
  /**
567
  * Full reset: remove all elements, tear down callout overlays, and clear
568
  * transient state. Called only on `is_snapshot: true` (load / reconnect).
 
771
  });
772
  }
773
 
774
+ /**
775
+ * Post-turn "dim-burst": fade the rest of the graph and glow what the agent just
776
+ * added/touched for `duration` ms, so the eye lands on the change. The glow + dim
777
+ * auto-clear, leaving no persistent chrome (calm-canvas safe). Fogged elements are
778
+ * left untouched (they carry their own opacity β€” re-dimming/restoring them would
779
+ * invert it). A no-op when `ids` resolves to nothing.
780
+ */
781
+ export function dimBurst(cy: Core, ids: string[], duration = 2200): void {
782
+ const fresh = cy.collection();
783
+ for (const id of ids) fresh.merge(cy.getElementById(id));
784
+ if (fresh.length === 0) return;
785
+ const keep = fresh.closedNeighborhood();
786
+ const others = cy.elements().filter((el) => !keep.has(el) && !el.hasClass("fogged"));
787
+ others.addClass("burst-dim");
788
+ fresh.addClass("burst-glow");
789
+ setTimeout(() => {
790
+ others.removeClass("burst-dim");
791
+ fresh.removeClass("burst-glow");
792
+ }, duration);
793
+ }
794
+
795
+ /**
796
+ * Hover-fade: while the pointer is over a node, fade everything outside that node's
797
+ * closed neighbourhood (the node + its edges + adjacent nodes) so local structure
798
+ * pops. Uses its own `.hover-faded` class so it never clobbers search/focus `.dimmed`
799
+ * state, and clears on mouseout. Fogged nodes don't trigger it (they're not "real"
800
+ * targets yet). Idempotent-safe: re-wiring would double-bind, so call once per cy.
801
+ */
802
+ export function wireHoverFade(cy: Core): void {
803
+ cy.on("mouseover", "node", (evt) => {
804
+ const node = evt.target as NodeSingular;
805
+ if (node.hasClass("fogged")) return;
806
+ const keep = node.closedNeighborhood();
807
+ cy.elements()
808
+ .filter((el) => !keep.has(el))
809
+ .addClass("hover-faded");
810
+ });
811
+ cy.on("mouseout", "node", () => {
812
+ cy.elements().removeClass("hover-faded");
813
+ });
814
+ }
815
+
816
+ /**
817
+ * Edge hover-emphasis: while the pointer is over an edge, add `.edge-hover` so the
818
+ * stylesheet brightens + thickens it (and bumps its z-index above its neighbours);
819
+ * remove it on mouseout. Class toggle only β€” cheap and fully reversible, so it never
820
+ * mutates graph state. Call once per `cy` (re-wiring would double-bind).
821
+ */
822
+ export function wireEdgeHover(cy: Core): void {
823
+ cy.on("mouseover", "edge", (evt) => {
824
+ (evt.target as EdgeSingular).addClass("edge-hover");
825
+ });
826
+ cy.on("mouseout", "edge", (evt) => {
827
+ (evt.target as EdgeSingular).removeClass("edge-hover");
828
+ });
829
+ }
830
+
831
  function selectElement(cy: Core, id: string | null): void {
832
  const state = getScratch(cy);
833
  // Suppress re-dispatch: this selection is backend-driven, not user-driven.
 
987
 
988
  // ── Rename gesture + connect mode ────────────────────────────────────────────
989
 
990
+ /** Options for {@link buildInlineInput}: the shared inline-input affordance. */
991
+ interface InlineInputOptions {
992
+ /** Initial value (plain text; NEVER assigned to innerHTML). */
993
+ value?: string;
994
+ /** Placeholder shown when the input is empty. */
995
+ placeholder?: string;
996
+ /** Absolute left/top in the parent's coordinate space (px). */
997
+ left: number;
998
+ top: number;
999
+ /** Called with the trimmed value on commit; the caller decides whether to act. */
1000
+ onCommit: (value: string) => void;
1001
+ }
1002
+
1003
  /**
1004
+ * Create an absolutely-positioned inline `<input>` over the cy container's parent.
1005
+ * Enter / blur commit the trimmed value via `onCommit`; Escape cancels. The input
1006
+ * removes itself from the DOM on commit OR cancel. Shared by {@link promptRename},
1007
+ * {@link promptSummary}, and {@link promptNewNode} so all three behave identically.
1008
  *
1009
+ * XSS guard: `input.value` is read as plain text β€” NEVER assigned to innerHTML.
1010
+ * Returns false (and does nothing) when the cy container is absent.
1011
  */
1012
+ function buildInlineInput(cy: Core, opts: InlineInputOptions): boolean {
 
 
 
1013
  const container = cy.container();
1014
+ if (!container) return false;
1015
  const parent = container.parentElement ?? container;
1016
 
1017
  const input = document.createElement("input");
1018
  input.type = "text";
1019
+ input.value = opts.value ?? "";
1020
+ if (opts.placeholder) input.placeholder = opts.placeholder;
1021
  input.style.position = "absolute";
1022
  input.style.zIndex = "2000";
1023
  input.style.background = "#1a1a2e";
 
1032
  (parent as HTMLElement).style.position = "relative";
1033
  }
1034
 
1035
+ input.style.left = `${opts.left}px`;
1036
+ input.style.top = `${opts.top}px`;
 
1037
  parent.appendChild(input);
1038
  input.focus();
1039
  input.select();
 
1043
  function commit(): void {
1044
  if (committed) return;
1045
  committed = true;
1046
+ const value = input.value.trim();
1047
  input.remove();
1048
+ opts.onCommit(value);
 
 
1049
  }
1050
 
1051
  input.addEventListener("keydown", (e) => {
 
1058
  }
1059
  });
1060
  input.addEventListener("blur", commit);
1061
+ return true;
1062
+ }
1063
+
1064
+ /**
1065
+ * Show an inline text input over a node to rename it. On Enter or blur it commits
1066
+ * the trimmed value via `onCommit` (only when non-empty AND changed); Escape
1067
+ * cancels. Extracted from the double-tap gesture so the radial menu's "Rename"
1068
+ * command reuses the exact same affordance.
1069
+ *
1070
+ * XSS guard: `input.value` is used as plain text β€” NEVER assigned to innerHTML.
1071
+ */
1072
+ export function promptRename(cy: Core, node: NodeSingular, onCommit: (label: string) => void): void {
1073
+ const id = node.id();
1074
+ const currentLabel = (node.data("label") as string | undefined) ?? id;
1075
+ const pos = node.renderedPosition();
1076
+ buildInlineInput(cy, {
1077
+ value: currentLabel,
1078
+ left: pos.x - 40,
1079
+ top: pos.y - 12,
1080
+ onCommit: (newLabel) => {
1081
+ // Commit only a non-empty, changed label (preserves the prior behaviour).
1082
+ if (newLabel && newLabel !== currentLabel) onCommit(newLabel);
1083
+ },
1084
+ });
1085
+ }
1086
+
1087
+ /**
1088
+ * Show an inline text input anchored at the edge's rendered midpoint to relabel
1089
+ * it. Prefilled with the edge's current `label` data field; on Enter or blur
1090
+ * commits the trimmed value via `onCommit` (only when non-empty AND changed);
1091
+ * Escape cancels. Mirrors {@link promptRename} but uses `edge.renderedMidpoint()`
1092
+ * for placement (edges have no `renderedPosition`).
1093
+ *
1094
+ * XSS guard: `input.value` is used as plain text β€” NEVER assigned to innerHTML.
1095
+ */
1096
+ export function promptEdgeLabel(cy: Core, edge: EdgeSingular, onCommit: (label: string) => void): void {
1097
+ const currentLabel = (edge.data("label") as string | undefined) ?? "";
1098
+ const pos = edge.renderedMidpoint();
1099
+ buildInlineInput(cy, {
1100
+ value: currentLabel,
1101
+ placeholder: "Edge label…",
1102
+ left: pos.x - 40,
1103
+ top: pos.y - 12,
1104
+ onCommit: (newLabel) => {
1105
+ if (newLabel && newLabel !== currentLabel) onCommit(newLabel);
1106
+ },
1107
+ });
1108
+ }
1109
+
1110
+ /**
1111
+ * Show an inline text input over a node to edit its summary. Empty value +
1112
+ * "Edit summary…" placeholder (the frontend doesn't hold the current summary).
1113
+ * Commits the trimmed text via `onCommit` only when non-empty; Escape cancels.
1114
+ *
1115
+ * XSS guard: `input.value` is used as plain text β€” NEVER assigned to innerHTML.
1116
+ */
1117
+ export function promptSummary(cy: Core, node: NodeSingular, onCommit: (text: string) => void): void {
1118
+ const pos = node.renderedPosition();
1119
+ buildInlineInput(cy, {
1120
+ placeholder: "Edit summary…",
1121
+ left: pos.x - 40,
1122
+ top: pos.y - 12,
1123
+ onCommit: (text) => {
1124
+ if (text) onCommit(text);
1125
+ },
1126
+ });
1127
+ }
1128
+
1129
+ /**
1130
+ * Show an inline text input centered over the canvas to create a new node. Empty
1131
+ * value + "New node label…" placeholder. Commits the trimmed label via `onCommit`
1132
+ * only when non-empty; Escape cancels.
1133
+ *
1134
+ * XSS guard: `input.value` is used as plain text β€” NEVER assigned to innerHTML.
1135
+ */
1136
+ export function promptNewNode(cy: Core, onCommit: (label: string) => void): void {
1137
+ const container = cy.container();
1138
+ // Center over the canvas using the container's pixel box (renderer-owned space).
1139
+ const cx = container ? container.clientWidth / 2 - 60 : 0;
1140
+ const cy0 = container ? container.clientHeight / 2 - 10 : 0;
1141
+ buildInlineInput(cy, {
1142
+ placeholder: "New node label…",
1143
+ left: cx,
1144
+ top: cy0,
1145
+ onCommit: (label) => {
1146
+ if (label) onCommit(label);
1147
+ },
1148
+ });
1149
  }
1150
 
1151
  /**
 
1262
  */
1263
  export type CxtMenuDispatch = (payload: {
1264
  index: null;
1265
+ // `id` is optional because the add_node command has no node id yet (the backend
1266
+ // mints one). Every other command supplies it.
1267
+ value: { kind: string; id?: string; action?: string; label?: string; text?: string };
1268
  selected: boolean;
1269
  }) => void;
1270
 
 
1287
  // openMenu() early-return.
1288
  if (ele.hasClass("fogged")) return [];
1289
  const id = ele.id();
1290
+ const scratch = getScratch(cy);
1291
+ const clearArmed = (): void => {
1292
+ scratch.pendingDeleteNodeId = null;
1293
+ cy.getElementById(id).removeClass("pending-delete");
1294
+ };
1295
+ // Armed for deletion (second right-click on the same node): offer only a
1296
+ // Confirm / Cancel pair. Mirrors the edge two-step delete confirm β€” a
1297
+ // node deletion is destructive, so it must be a deliberate second action.
1298
+ if (scratch.pendingDeleteNodeId === id) {
1299
+ return [
1300
+ {
1301
+ content: "⚠ Confirm",
1302
+ fillColor: "rgba(180, 40, 40, 0.9)",
1303
+ select: () => {
1304
+ clearArmed();
1305
+ dispatch({ index: null, value: { kind: "delete_node", id }, selected: false });
1306
+ },
1307
+ },
1308
+ { content: "Cancel", select: () => clearArmed() },
1309
+ ];
1310
+ }
1311
  const reviewCommand = (action: "accepted" | "rejected"): CxtMenuCommand => ({
1312
  content: action === "accepted" ? "βœ“ Accept" : "βœ• Reject",
1313
  select: () => dispatch({ index: null, value: { kind: "review", id, action }, selected: false }),
 
1331
  dispatch({ index: null, value: { kind: "rename", id, label }, selected: false })
1332
  ),
1333
  };
1334
+ const summaryCommand: CxtMenuCommand = {
1335
+ content: "✎ Summary",
1336
+ // Inline input (empty + placeholder); commits a new summary via edit_summary.
1337
+ select: () =>
1338
+ promptSummary(cy, ele as NodeSingular, (text) =>
1339
+ dispatch({ index: null, value: { kind: "edit_summary", id, text }, selected: false })
1340
+ ),
1341
+ };
1342
+ const deleteCommand: CxtMenuCommand = {
1343
+ content: "πŸ—‘ Delete",
1344
+ // Arm only β€” does NOT delete. The red dashed border signals "right-click
1345
+ // again to confirm" (the Confirm/Cancel pair appears on the armed branch).
1346
+ select: () => {
1347
+ scratch.pendingDeleteNodeId = id;
1348
+ cy.getElementById(id).addClass("pending-delete");
1349
+ },
1350
+ };
1351
+ return [
1352
+ reviewCommand("accepted"),
1353
+ reviewCommand("rejected"),
1354
+ focusCommand,
1355
+ expandCommand,
1356
+ renameCommand,
1357
+ summaryCommand,
1358
+ deleteCommand,
1359
+ ];
1360
  };
1361
 
1362
  // Edge menu with an in-canvas, two-step delete confirm (deletion is irreversible
1363
  // except via undo). First right-click arms the edge (red dashed); the follow-up
1364
  // menu on the same edge offers Confirm / Cancel.
1365
+ // Full command set (wedge order): Accept | Reject | Edit label | Delete
1366
+ // (non-armed state). Accept/Reject mirror the node reviewCommand; Edit-label
1367
+ // opens a promptEdgeLabel inline input (non-destructive, no arm step);
1368
+ // Delete arms on first click and requires a second confirm (destructive).
1369
  const buildEdgeCommands = (ele: CxtMenuEle): CxtMenuCommand[] => {
1370
  if (ele.hasClass("fogged")) return [];
1371
  const id = ele.id();
 
1387
  { content: "Cancel", select: () => clearArmed() },
1388
  ];
1389
  }
1390
+ const edgeReviewCommand = (action: "accepted" | "rejected"): CxtMenuCommand => ({
1391
+ content: action === "accepted" ? "βœ“ Accept" : "βœ• Reject",
1392
+ select: () => dispatch({ index: null, value: { kind: "review_edge", id, action }, selected: false }),
1393
+ });
1394
+ const editLabelCommand: CxtMenuCommand = {
1395
+ content: "✎ Edit label",
1396
+ // Non-destructive β€” no arm step. Opens inline input; commits relabel_edge.
1397
+ select: () =>
1398
+ promptEdgeLabel(cy, ele as EdgeSingular, (label) =>
1399
+ dispatch({ index: null, value: { kind: "relabel_edge", id, label }, selected: false })
1400
+ ),
1401
+ };
1402
+ const deleteCommand: CxtMenuCommand = {
1403
+ content: "πŸ—‘ Delete",
1404
+ // Arm only β€” does NOT delete. The red highlight signals "right-click again".
1405
+ select: () => {
1406
+ scratch.pendingDeleteEdgeId = id;
1407
+ cy.getElementById(id).addClass("pending-delete");
1408
  },
1409
+ };
1410
+ return [edgeReviewCommand("accepted"), edgeReviewCommand("rejected"), editLabelCommand, deleteCommand];
1411
  };
1412
 
1413
  (cy as CxtMenuCore).cxtmenu({
1414
  selector: "node",
1415
  commands: buildNodeCommands,
1416
+ // Larger wheel to fit the expanded command set (Accept/Reject/Focus/Expand/
1417
+ // Rename/Summary/Delete) without crowding the wedges.
1418
+ menuRadius: () => 100,
1419
  fillColor: "rgba(17, 19, 26, 0.92)", // matches LC_LABEL_BG pill tone
1420
  activeFillColor: "rgba(59, 91, 219, 0.85)", // LC_NODE_BG indigo
1421
  itemColor: LC_NODE_TEXT,
 
1454
  content: "🎨 Clusters",
1455
  select: () => dispatch({ index: null, value: { kind: "cluster", id: "" }, selected: false }),
1456
  },
1457
+ {
1458
+ content: "οΌ‹ Add node",
1459
+ // Centered inline input; commits a new node label via add_node.
1460
+ select: () =>
1461
+ promptNewNode(cy, (label) => dispatch({ index: null, value: { kind: "add_node", label }, selected: false })),
1462
+ },
1463
  ],
1464
  menuRadius: () => 70,
1465
  fillColor: "rgba(17, 19, 26, 0.92)",
cytoscapecanvas/frontend/types.ts CHANGED
@@ -107,7 +107,9 @@ export interface ConnectEvent {
107
  export type SelectPayload =
108
  | { kind: "node" | "edge" | "none"; id: string }
109
  | { kind: "rename"; id: string; label: string }
110
- | { kind: "connect"; source: string; target: string; label: string };
 
 
111
 
112
  export interface CytoscapeCanvasEvents {
113
  change: never;
 
107
  export type SelectPayload =
108
  | { kind: "node" | "edge" | "none"; id: string }
109
  | { kind: "rename"; id: string; label: string }
110
+ | { kind: "connect"; source: string; target: string; label: string }
111
+ | { kind: "review_edge"; id: string; action: string }
112
+ | { kind: "relabel_edge"; id: string; label: string };
113
 
114
  export interface CytoscapeCanvasEvents {
115
  change: never;
fixtures/seed_local_ai.json ADDED
@@ -0,0 +1,729 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "graph": {
3
+ "nodes": [
4
+ {
5
+ "id": "concept::local_language_model",
6
+ "kind": "concept",
7
+ "label": "Local Language Model",
8
+ "summary": "A model running on a user's own machine that ensures data privacy.",
9
+ "properties": {},
10
+ "metadata": {
11
+ "origin": "model_inferred",
12
+ "support_state": "unverified"
13
+ },
14
+ "evidence_refs": [
15
+ {
16
+ "source_id": "text::what-you-build-with-local-ai",
17
+ "node_id": "concept::local_language_model",
18
+ "start_line": 1,
19
+ "end_line": 1,
20
+ "snippet_hash": "527e4efd4bcea8eb4e7857615797c4c9c7d4e1e3700f7224968865312cc26d35"
21
+ }
22
+ ]
23
+ },
24
+ {
25
+ "id": "concept::rag",
26
+ "kind": "concept",
27
+ "label": "Retrieval-Augmented Generation (RAG)",
28
+ "summary": "A technique that grounds model answers in specific documents to reduce hallucinations.",
29
+ "properties": {},
30
+ "metadata": {
31
+ "origin": "model_inferred",
32
+ "support_state": "unverified"
33
+ },
34
+ "evidence_refs": [
35
+ {
36
+ "source_id": "text::what-you-build-with-local-ai",
37
+ "node_id": "concept::rag",
38
+ "start_line": 1,
39
+ "end_line": 3,
40
+ "snippet_hash": "e36d3a792740cfda99064bdbd5a9efecf4fb40473945d4bcf35ecf9eed0c88ea"
41
+ }
42
+ ]
43
+ },
44
+ {
45
+ "id": "concept::embeddings",
46
+ "kind": "concept",
47
+ "label": "Embeddings",
48
+ "summary": "Numeric vectors that capture the semantic meaning of text.",
49
+ "properties": {},
50
+ "metadata": {
51
+ "origin": "model_inferred",
52
+ "support_state": "unverified"
53
+ },
54
+ "evidence_refs": [
55
+ {
56
+ "source_id": "text::what-you-build-with-local-ai",
57
+ "node_id": "concept::embeddings",
58
+ "start_line": 3,
59
+ "end_line": 3,
60
+ "snippet_hash": "a1b134578828dda6a026d13c149b5a883c0d7703a65b85baba3b71c36b9556fe"
61
+ }
62
+ ]
63
+ },
64
+ {
65
+ "id": "concept::vector_database",
66
+ "kind": "concept",
67
+ "label": "Vector Database",
68
+ "summary": "A storage system that allows for searching by meaning rather than keywords.",
69
+ "properties": {},
70
+ "metadata": {
71
+ "origin": "model_inferred",
72
+ "support_state": "unverified"
73
+ },
74
+ "evidence_refs": [
75
+ {
76
+ "source_id": "text::what-you-build-with-local-ai",
77
+ "node_id": "concept::vector_database",
78
+ "start_line": 3,
79
+ "end_line": 3,
80
+ "snippet_hash": "3434f2f64ea638101826dbe1aada727f6ffdb23e659e5512b1b6fe7b204574c2"
81
+ }
82
+ ]
83
+ },
84
+ {
85
+ "id": "concept::agent",
86
+ "kind": "concept",
87
+ "label": "Agent",
88
+ "summary": "A model that can perform actions by invoking tools to complete tasks.",
89
+ "properties": {},
90
+ "metadata": {
91
+ "origin": "model_inferred",
92
+ "support_state": "unverified"
93
+ },
94
+ "evidence_refs": [
95
+ {
96
+ "source_id": "text::what-you-build-with-local-ai",
97
+ "node_id": "concept::agent",
98
+ "start_line": 3,
99
+ "end_line": 3,
100
+ "snippet_hash": "669c0a4763e07218022e5ace4af8b0d143cefec33fc9fc0d12f8b4a63800ed42"
101
+ }
102
+ ]
103
+ },
104
+ {
105
+ "id": "concept::tool_calling",
106
+ "kind": "concept",
107
+ "label": "Tool Calling",
108
+ "summary": "The process where a model decides to invoke a function or API.",
109
+ "properties": {},
110
+ "metadata": {
111
+ "origin": "model_inferred",
112
+ "support_state": "unverified"
113
+ },
114
+ "evidence_refs": [
115
+ {
116
+ "source_id": "text::what-you-build-with-local-ai",
117
+ "node_id": "concept::tool_calling",
118
+ "start_line": 3,
119
+ "end_line": 3,
120
+ "snippet_hash": "2ff6b971eeb8ebcc4ebf47cf517b7a7065f9fb3e8b577712d2a65d394b04f681"
121
+ }
122
+ ]
123
+ },
124
+ {
125
+ "id": "concept::structured_output",
126
+ "kind": "concept",
127
+ "label": "Structured Output",
128
+ "summary": "Machine-readable output, such as JSON, required for tool calling and precise RAG citations.",
129
+ "properties": {},
130
+ "metadata": {
131
+ "origin": "model_inferred",
132
+ "support_state": "unverified"
133
+ },
134
+ "evidence_refs": [
135
+ {
136
+ "source_id": "text::what-you-build-with-local-ai",
137
+ "node_id": "concept::structured_output",
138
+ "start_line": 3,
139
+ "end_line": 3,
140
+ "snippet_hash": "d153caffaef0d004d5ea80a9d2d79de76444b0f1af4a22f69436a5b5c4c7a26a"
141
+ }
142
+ ]
143
+ },
144
+ {
145
+ "id": "concept::fine_tuning",
146
+ "kind": "concept",
147
+ "label": "Fine-tuning",
148
+ "summary": "Adjusting a model's weights on specific examples to teach lasting behavior.",
149
+ "properties": {},
150
+ "metadata": {
151
+ "origin": "model_inferred",
152
+ "support_state": "unverified"
153
+ },
154
+ "evidence_refs": [
155
+ {
156
+ "source_id": "text::what-you-build-with-local-ai",
157
+ "node_id": "concept::fine_tuning",
158
+ "start_line": 5,
159
+ "end_line": 5,
160
+ "snippet_hash": "8bd39e784347707c5d4b96bc28d11ba3b99a139e2798a6e29a67ff5377212fae"
161
+ }
162
+ ]
163
+ },
164
+ {
165
+ "id": "concept::lora",
166
+ "kind": "concept",
167
+ "label": "LoRA",
168
+ "summary": "A parameter-efficient fine-tuning method that trains only small adapter layers.",
169
+ "properties": {},
170
+ "metadata": {
171
+ "origin": "model_inferred",
172
+ "support_state": "unverified"
173
+ },
174
+ "evidence_refs": [
175
+ {
176
+ "source_id": "text::what-you-build-with-local-ai",
177
+ "node_id": "concept::lora",
178
+ "start_line": 5,
179
+ "end_line": 5,
180
+ "snippet_hash": "44d1331560c7ce9bd6e813333f32e3ab6b816117dfbbf6d1faf83f5dd9e9ce6b"
181
+ }
182
+ ]
183
+ },
184
+ {
185
+ "id": "concept::open_source_models",
186
+ "kind": "concept",
187
+ "label": "Open-source Models",
188
+ "summary": "Models with open weights that enable privacy, inspection, and customization.",
189
+ "properties": {},
190
+ "metadata": {
191
+ "origin": "model_inferred",
192
+ "support_state": "unverified"
193
+ },
194
+ "evidence_refs": [
195
+ {
196
+ "source_id": "text::what-you-build-with-local-ai",
197
+ "node_id": "concept::open_source_models",
198
+ "start_line": 5,
199
+ "end_line": 5,
200
+ "snippet_hash": "87998699bfff48c57e15c35e7fbfb2a952e5b52bb9fcbc3260ca6bac16c5d30c"
201
+ }
202
+ ]
203
+ }
204
+ ],
205
+ "edges": [
206
+ {
207
+ "id": "concept::rag::prerequisite_of::concept::embeddings",
208
+ "source": "concept::rag",
209
+ "target": "concept::embeddings",
210
+ "type": "prerequisite_of",
211
+ "label": "depends on",
212
+ "properties": {
213
+ "origin": "model_inferred",
214
+ "support_state": "unverified"
215
+ }
216
+ },
217
+ {
218
+ "id": "concept::embeddings::related_to::concept::vector_database",
219
+ "source": "concept::embeddings",
220
+ "target": "concept::vector_database",
221
+ "type": "related_to",
222
+ "label": "stored in",
223
+ "properties": {
224
+ "origin": "model_inferred",
225
+ "support_state": "unverified"
226
+ }
227
+ },
228
+ {
229
+ "id": "concept::agent::explains::concept::tool_calling",
230
+ "source": "concept::agent",
231
+ "target": "concept::tool_calling",
232
+ "type": "explains",
233
+ "label": "works by",
234
+ "properties": {
235
+ "origin": "model_inferred",
236
+ "support_state": "unverified"
237
+ }
238
+ },
239
+ {
240
+ "id": "concept::tool_calling::prerequisite_of::concept::structured_output",
241
+ "source": "concept::tool_calling",
242
+ "target": "concept::structured_output",
243
+ "type": "prerequisite_of",
244
+ "label": "depends on",
245
+ "properties": {
246
+ "origin": "model_inferred",
247
+ "support_state": "unverified"
248
+ }
249
+ },
250
+ {
251
+ "id": "concept::structured_output::related_to::concept::rag",
252
+ "source": "concept::structured_output",
253
+ "target": "concept::rag",
254
+ "type": "related_to",
255
+ "label": "disciplines",
256
+ "properties": {
257
+ "origin": "model_inferred",
258
+ "support_state": "unverified"
259
+ }
260
+ },
261
+ {
262
+ "id": "concept::fine_tuning::explains::concept::lora",
263
+ "source": "concept::fine_tuning",
264
+ "target": "concept::lora",
265
+ "type": "explains",
266
+ "label": "efficient version of",
267
+ "properties": {
268
+ "origin": "model_inferred",
269
+ "support_state": "unverified"
270
+ }
271
+ },
272
+ {
273
+ "id": "concept::rag::related_to::concept::fine_tuning",
274
+ "source": "concept::rag",
275
+ "target": "concept::fine_tuning",
276
+ "type": "related_to",
277
+ "label": "complementary to",
278
+ "properties": {
279
+ "origin": "model_inferred",
280
+ "support_state": "unverified"
281
+ }
282
+ },
283
+ {
284
+ "id": "concept::open_source_models::prerequisite_of::concept::fine_tuning",
285
+ "source": "concept::open_source_models",
286
+ "target": "concept::fine_tuning",
287
+ "type": "prerequisite_of",
288
+ "label": "enables",
289
+ "properties": {
290
+ "origin": "model_inferred",
291
+ "support_state": "unverified"
292
+ }
293
+ },
294
+ {
295
+ "id": "concept::open_source_models::related_to::concept::local_language_model",
296
+ "source": "concept::open_source_models",
297
+ "target": "concept::local_language_model",
298
+ "type": "related_to",
299
+ "label": "underpins",
300
+ "properties": {
301
+ "origin": "model_inferred",
302
+ "support_state": "unverified"
303
+ }
304
+ }
305
+ ],
306
+ "claims": [
307
+ {
308
+ "id": "claim::concept::local_language_model",
309
+ "claim_type": "concept_node",
310
+ "target_id": "concept::local_language_model",
311
+ "source_id": "",
312
+ "text": "A model running on a user's own machine that ensures data privacy.",
313
+ "origin": "model_inferred",
314
+ "support_state": "unverified",
315
+ "review_state": "pending",
316
+ "evidence_refs": [
317
+ {
318
+ "source_id": "text::what-you-build-with-local-ai",
319
+ "node_id": "concept::local_language_model",
320
+ "start_line": 1,
321
+ "end_line": 1,
322
+ "snippet_hash": "527e4efd4bcea8eb4e7857615797c4c9c7d4e1e3700f7224968865312cc26d35"
323
+ }
324
+ ]
325
+ },
326
+ {
327
+ "id": "claim::concept::rag",
328
+ "claim_type": "concept_node",
329
+ "target_id": "concept::rag",
330
+ "source_id": "",
331
+ "text": "A technique that grounds model answers in specific documents to reduce hallucinations.",
332
+ "origin": "model_inferred",
333
+ "support_state": "unverified",
334
+ "review_state": "pending",
335
+ "evidence_refs": [
336
+ {
337
+ "source_id": "text::what-you-build-with-local-ai",
338
+ "node_id": "concept::rag",
339
+ "start_line": 1,
340
+ "end_line": 3,
341
+ "snippet_hash": "e36d3a792740cfda99064bdbd5a9efecf4fb40473945d4bcf35ecf9eed0c88ea"
342
+ }
343
+ ]
344
+ },
345
+ {
346
+ "id": "claim::concept::embeddings",
347
+ "claim_type": "concept_node",
348
+ "target_id": "concept::embeddings",
349
+ "source_id": "",
350
+ "text": "Numeric vectors that capture the semantic meaning of text.",
351
+ "origin": "model_inferred",
352
+ "support_state": "unverified",
353
+ "review_state": "pending",
354
+ "evidence_refs": [
355
+ {
356
+ "source_id": "text::what-you-build-with-local-ai",
357
+ "node_id": "concept::embeddings",
358
+ "start_line": 3,
359
+ "end_line": 3,
360
+ "snippet_hash": "a1b134578828dda6a026d13c149b5a883c0d7703a65b85baba3b71c36b9556fe"
361
+ }
362
+ ]
363
+ },
364
+ {
365
+ "id": "claim::concept::vector_database",
366
+ "claim_type": "concept_node",
367
+ "target_id": "concept::vector_database",
368
+ "source_id": "",
369
+ "text": "A storage system that allows for searching by meaning rather than keywords.",
370
+ "origin": "model_inferred",
371
+ "support_state": "unverified",
372
+ "review_state": "pending",
373
+ "evidence_refs": [
374
+ {
375
+ "source_id": "text::what-you-build-with-local-ai",
376
+ "node_id": "concept::vector_database",
377
+ "start_line": 3,
378
+ "end_line": 3,
379
+ "snippet_hash": "3434f2f64ea638101826dbe1aada727f6ffdb23e659e5512b1b6fe7b204574c2"
380
+ }
381
+ ]
382
+ },
383
+ {
384
+ "id": "claim::concept::agent",
385
+ "claim_type": "concept_node",
386
+ "target_id": "concept::agent",
387
+ "source_id": "",
388
+ "text": "A model that can perform actions by invoking tools to complete tasks.",
389
+ "origin": "model_inferred",
390
+ "support_state": "unverified",
391
+ "review_state": "pending",
392
+ "evidence_refs": [
393
+ {
394
+ "source_id": "text::what-you-build-with-local-ai",
395
+ "node_id": "concept::agent",
396
+ "start_line": 3,
397
+ "end_line": 3,
398
+ "snippet_hash": "669c0a4763e07218022e5ace4af8b0d143cefec33fc9fc0d12f8b4a63800ed42"
399
+ }
400
+ ]
401
+ },
402
+ {
403
+ "id": "claim::concept::tool_calling",
404
+ "claim_type": "concept_node",
405
+ "target_id": "concept::tool_calling",
406
+ "source_id": "",
407
+ "text": "The process where a model decides to invoke a function or API.",
408
+ "origin": "model_inferred",
409
+ "support_state": "unverified",
410
+ "review_state": "pending",
411
+ "evidence_refs": [
412
+ {
413
+ "source_id": "text::what-you-build-with-local-ai",
414
+ "node_id": "concept::tool_calling",
415
+ "start_line": 3,
416
+ "end_line": 3,
417
+ "snippet_hash": "2ff6b971eeb8ebcc4ebf47cf517b7a7065f9fb3e8b577712d2a65d394b04f681"
418
+ }
419
+ ]
420
+ },
421
+ {
422
+ "id": "claim::concept::structured_output",
423
+ "claim_type": "concept_node",
424
+ "target_id": "concept::structured_output",
425
+ "source_id": "",
426
+ "text": "Machine-readable output, such as JSON, required for tool calling and precise RAG citations.",
427
+ "origin": "model_inferred",
428
+ "support_state": "unverified",
429
+ "review_state": "pending",
430
+ "evidence_refs": [
431
+ {
432
+ "source_id": "text::what-you-build-with-local-ai",
433
+ "node_id": "concept::structured_output",
434
+ "start_line": 3,
435
+ "end_line": 3,
436
+ "snippet_hash": "d153caffaef0d004d5ea80a9d2d79de76444b0f1af4a22f69436a5b5c4c7a26a"
437
+ }
438
+ ]
439
+ },
440
+ {
441
+ "id": "claim::concept::fine_tuning",
442
+ "claim_type": "concept_node",
443
+ "target_id": "concept::fine_tuning",
444
+ "source_id": "",
445
+ "text": "Adjusting a model's weights on specific examples to teach lasting behavior.",
446
+ "origin": "model_inferred",
447
+ "support_state": "unverified",
448
+ "review_state": "pending",
449
+ "evidence_refs": [
450
+ {
451
+ "source_id": "text::what-you-build-with-local-ai",
452
+ "node_id": "concept::fine_tuning",
453
+ "start_line": 5,
454
+ "end_line": 5,
455
+ "snippet_hash": "8bd39e784347707c5d4b96bc28d11ba3b99a139e2798a6e29a67ff5377212fae"
456
+ }
457
+ ]
458
+ },
459
+ {
460
+ "id": "claim::concept::lora",
461
+ "claim_type": "concept_node",
462
+ "target_id": "concept::lora",
463
+ "source_id": "",
464
+ "text": "A parameter-efficient fine-tuning method that trains only small adapter layers.",
465
+ "origin": "model_inferred",
466
+ "support_state": "unverified",
467
+ "review_state": "pending",
468
+ "evidence_refs": [
469
+ {
470
+ "source_id": "text::what-you-build-with-local-ai",
471
+ "node_id": "concept::lora",
472
+ "start_line": 5,
473
+ "end_line": 5,
474
+ "snippet_hash": "44d1331560c7ce9bd6e813333f32e3ab6b816117dfbbf6d1faf83f5dd9e9ce6b"
475
+ }
476
+ ]
477
+ },
478
+ {
479
+ "id": "claim::concept::open_source_models",
480
+ "claim_type": "concept_node",
481
+ "target_id": "concept::open_source_models",
482
+ "source_id": "",
483
+ "text": "Models with open weights that enable privacy, inspection, and customization.",
484
+ "origin": "model_inferred",
485
+ "support_state": "unverified",
486
+ "review_state": "pending",
487
+ "evidence_refs": [
488
+ {
489
+ "source_id": "text::what-you-build-with-local-ai",
490
+ "node_id": "concept::open_source_models",
491
+ "start_line": 5,
492
+ "end_line": 5,
493
+ "snippet_hash": "87998699bfff48c57e15c35e7fbfb2a952e5b52bb9fcbc3260ca6bac16c5d30c"
494
+ }
495
+ ]
496
+ },
497
+ {
498
+ "id": "claim::semantic::concept::rag->concept::embeddings::prerequisite_of",
499
+ "claim_type": "semantic_edge",
500
+ "target_id": "concept::rag::prerequisite_of::concept::embeddings",
501
+ "source_id": "concept::rag",
502
+ "text": "prerequisite_of",
503
+ "origin": "model_inferred",
504
+ "support_state": "unverified",
505
+ "review_state": "pending",
506
+ "evidence_refs": [
507
+ {
508
+ "source_id": "text::what-you-build-with-local-ai",
509
+ "node_id": "concept::rag::prerequisite_of::concept::embeddings",
510
+ "start_line": 1,
511
+ "end_line": 10,
512
+ "snippet_hash": "bfc6a0f0a78bf80ff6e83ef84be0aedfd0deca978d5414a69cb36fe4e050c32f"
513
+ }
514
+ ]
515
+ },
516
+ {
517
+ "id": "claim::semantic::concept::embeddings->concept::vector_database::related_to",
518
+ "claim_type": "semantic_edge",
519
+ "target_id": "concept::embeddings::related_to::concept::vector_database",
520
+ "source_id": "concept::embeddings",
521
+ "text": "related_to",
522
+ "origin": "model_inferred",
523
+ "support_state": "unverified",
524
+ "review_state": "pending",
525
+ "evidence_refs": [
526
+ {
527
+ "source_id": "text::what-you-build-with-local-ai",
528
+ "node_id": "concept::embeddings::related_to::concept::vector_database",
529
+ "start_line": 1,
530
+ "end_line": 10,
531
+ "snippet_hash": "bfc6a0f0a78bf80ff6e83ef84be0aedfd0deca978d5414a69cb36fe4e050c32f"
532
+ }
533
+ ]
534
+ },
535
+ {
536
+ "id": "claim::semantic::concept::agent->concept::tool_calling::explains",
537
+ "claim_type": "semantic_edge",
538
+ "target_id": "concept::agent::explains::concept::tool_calling",
539
+ "source_id": "concept::agent",
540
+ "text": "explains",
541
+ "origin": "model_inferred",
542
+ "support_state": "unverified",
543
+ "review_state": "pending",
544
+ "evidence_refs": [
545
+ {
546
+ "source_id": "text::what-you-build-with-local-ai",
547
+ "node_id": "concept::agent::explains::concept::tool_calling",
548
+ "start_line": 1,
549
+ "end_line": 10,
550
+ "snippet_hash": "bfc6a0f0a78bf80ff6e83ef84be0aedfd0deca978d5414a69cb36fe4e050c32f"
551
+ }
552
+ ]
553
+ },
554
+ {
555
+ "id": "claim::semantic::concept::tool_calling->concept::structured_output::prerequisite_of",
556
+ "claim_type": "semantic_edge",
557
+ "target_id": "concept::tool_calling::prerequisite_of::concept::structured_output",
558
+ "source_id": "concept::tool_calling",
559
+ "text": "prerequisite_of",
560
+ "origin": "model_inferred",
561
+ "support_state": "unverified",
562
+ "review_state": "pending",
563
+ "evidence_refs": [
564
+ {
565
+ "source_id": "text::what-you-build-with-local-ai",
566
+ "node_id": "concept::tool_calling::prerequisite_of::concept::structured_output",
567
+ "start_line": 1,
568
+ "end_line": 10,
569
+ "snippet_hash": "bfc6a0f0a78bf80ff6e83ef84be0aedfd0deca978d5414a69cb36fe4e050c32f"
570
+ }
571
+ ]
572
+ },
573
+ {
574
+ "id": "claim::semantic::concept::structured_output->concept::rag::related_to",
575
+ "claim_type": "semantic_edge",
576
+ "target_id": "concept::structured_output::related_to::concept::rag",
577
+ "source_id": "concept::structured_output",
578
+ "text": "related_to",
579
+ "origin": "model_inferred",
580
+ "support_state": "unverified",
581
+ "review_state": "pending",
582
+ "evidence_refs": [
583
+ {
584
+ "source_id": "text::what-you-build-with-local-ai",
585
+ "node_id": "concept::structured_output::related_to::concept::rag",
586
+ "start_line": 1,
587
+ "end_line": 10,
588
+ "snippet_hash": "bfc6a0f0a78bf80ff6e83ef84be0aedfd0deca978d5414a69cb36fe4e050c32f"
589
+ }
590
+ ]
591
+ },
592
+ {
593
+ "id": "claim::semantic::concept::fine_tuning->concept::lora::explains",
594
+ "claim_type": "semantic_edge",
595
+ "target_id": "concept::fine_tuning::explains::concept::lora",
596
+ "source_id": "concept::fine_tuning",
597
+ "text": "explains",
598
+ "origin": "model_inferred",
599
+ "support_state": "unverified",
600
+ "review_state": "pending",
601
+ "evidence_refs": [
602
+ {
603
+ "source_id": "text::what-you-build-with-local-ai",
604
+ "node_id": "concept::fine_tuning::explains::concept::lora",
605
+ "start_line": 1,
606
+ "end_line": 10,
607
+ "snippet_hash": "bfc6a0f0a78bf80ff6e83ef84be0aedfd0deca978d5414a69cb36fe4e050c32f"
608
+ }
609
+ ]
610
+ },
611
+ {
612
+ "id": "claim::semantic::concept::rag->concept::fine_tuning::related_to",
613
+ "claim_type": "semantic_edge",
614
+ "target_id": "concept::rag::related_to::concept::fine_tuning",
615
+ "source_id": "concept::rag",
616
+ "text": "related_to",
617
+ "origin": "model_inferred",
618
+ "support_state": "unverified",
619
+ "review_state": "pending",
620
+ "evidence_refs": [
621
+ {
622
+ "source_id": "text::what-you-build-with-local-ai",
623
+ "node_id": "concept::rag::related_to::concept::fine_tuning",
624
+ "start_line": 1,
625
+ "end_line": 10,
626
+ "snippet_hash": "bfc6a0f0a78bf80ff6e83ef84be0aedfd0deca978d5414a69cb36fe4e050c32f"
627
+ }
628
+ ]
629
+ },
630
+ {
631
+ "id": "claim::semantic::concept::open_source_models->concept::fine_tuning::prerequisite_of",
632
+ "claim_type": "semantic_edge",
633
+ "target_id": "concept::open_source_models::prerequisite_of::concept::fine_tuning",
634
+ "source_id": "concept::open_source_models",
635
+ "text": "prerequisite_of",
636
+ "origin": "model_inferred",
637
+ "support_state": "unverified",
638
+ "review_state": "pending",
639
+ "evidence_refs": [
640
+ {
641
+ "source_id": "text::what-you-build-with-local-ai",
642
+ "node_id": "concept::open_source_models::prerequisite_of::concept::fine_tuning",
643
+ "start_line": 1,
644
+ "end_line": 10,
645
+ "snippet_hash": "bfc6a0f0a78bf80ff6e83ef84be0aedfd0deca978d5414a69cb36fe4e050c32f"
646
+ }
647
+ ]
648
+ },
649
+ {
650
+ "id": "claim::semantic::concept::open_source_models->concept::local_language_model::related_to",
651
+ "claim_type": "semantic_edge",
652
+ "target_id": "concept::open_source_models::related_to::concept::local_language_model",
653
+ "source_id": "concept::open_source_models",
654
+ "text": "related_to",
655
+ "origin": "model_inferred",
656
+ "support_state": "unverified",
657
+ "review_state": "pending",
658
+ "evidence_refs": [
659
+ {
660
+ "source_id": "text::what-you-build-with-local-ai",
661
+ "node_id": "concept::open_source_models::related_to::concept::local_language_model",
662
+ "start_line": 1,
663
+ "end_line": 10,
664
+ "snippet_hash": "bfc6a0f0a78bf80ff6e83ef84be0aedfd0deca978d5414a69cb36fe4e050c32f"
665
+ }
666
+ ]
667
+ }
668
+ ],
669
+ "metadata": {
670
+ "schema_version": "0.1.0",
671
+ "node_count": 10,
672
+ "edge_count": 9
673
+ }
674
+ },
675
+ "scene": {
676
+ "scene_id": "seed_local_ai_initial",
677
+ "visible_node_ids": [
678
+ "concept::rag",
679
+ "concept::fine_tuning",
680
+ "concept::structured_output",
681
+ "concept::embeddings",
682
+ "concept::tool_calling",
683
+ "concept::open_source_models",
684
+ "concept::lora",
685
+ "concept::vector_database",
686
+ "concept::local_language_model",
687
+ "concept::agent"
688
+ ],
689
+ "visible_edge_ids": [
690
+ "concept::rag::prerequisite_of::concept::embeddings",
691
+ "concept::embeddings::related_to::concept::vector_database",
692
+ "concept::agent::explains::concept::tool_calling",
693
+ "concept::tool_calling::prerequisite_of::concept::structured_output",
694
+ "concept::structured_output::related_to::concept::rag",
695
+ "concept::fine_tuning::explains::concept::lora",
696
+ "concept::rag::related_to::concept::fine_tuning",
697
+ "concept::open_source_models::prerequisite_of::concept::fine_tuning",
698
+ "concept::open_source_models::related_to::concept::local_language_model"
699
+ ],
700
+ "fogged_node_ids": [],
701
+ "highlighted_ids": [],
702
+ "selected_id": "",
703
+ "callouts": [],
704
+ "node_positions": {
705
+ "concept::rag": [359.3254889740797, 413.93124693993104],
706
+ "concept::fine_tuning": [427.90556718584685, 501.1807331162357],
707
+ "concept::structured_output": [459.9388127470801, 266.3831793829436],
708
+ "concept::embeddings": [263.19949505847364, 575.2638023784646],
709
+ "concept::tool_calling": [562.8504920579213, 157.5174883009194],
710
+ "concept::open_source_models": [667.7714962697, 680.5737168938729],
711
+ "concept::lora": [445.549280906602, 680.8558430944295],
712
+ "concept::vector_database": [200.66715948740705, 724.4091315938329],
713
+ "concept::local_language_model": [900.0, 876.1056318950547],
714
+ "concept::agent": [712.7922073128894, 123.77922640431535]
715
+ },
716
+ "node_cluster_map": {
717
+ "concept::tool_calling": 0,
718
+ "concept::rag": 0,
719
+ "concept::agent": 0,
720
+ "concept::structured_output": 0,
721
+ "concept::fine_tuning": 1,
722
+ "concept::local_language_model": 1,
723
+ "concept::open_source_models": 1,
724
+ "concept::lora": 1,
725
+ "concept::vector_database": 2,
726
+ "concept::embeddings": 2
727
+ }
728
+ }
729
+ }
scripts/playwright_cxtmenu.py CHANGED
@@ -109,6 +109,51 @@ EDGE_POINT = """
109
  }
110
  """
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
  def main() -> int:
114
  console: list[str] = []
@@ -217,13 +262,61 @@ def main() -> int:
217
  report["edge_menu_wedges"] = edge_items
218
  page.screenshot(path=str(OUT / "cxtmenu_04_edge.png"))
219
  edge_joined = " ".join(edge_items).lower()
220
- if "delete" not in edge_joined:
221
- failures.append(
222
- f"edge right-click did not open the Delete menu; got {edge_items}"
223
- )
 
 
 
224
  page.mouse.up(button="right")
225
  page.wait_for_timeout(150)
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  browser.close()
228
 
229
  # C. No page errors (console 'error' lines or pageerror entries).
 
109
  }
110
  """
111
 
112
+ # First non-fogged node, preferring one still awaiting review (amber). Used to
113
+ # verify the reject-removes-node FIX: rejecting must KEEP the node on canvas.
114
+ PICK_REVIEWABLE_NODE = """
115
+ () => {
116
+ const cy = window.__cy;
117
+ if (!cy) return null;
118
+ const pending = cy.nodes().filter((n) => !n.hasClass('fogged') && n.hasClass('review-pending'));
119
+ const n = pending[0] || cy.nodes().filter((n) => !n.hasClass('fogged'))[0];
120
+ if (!n) return null;
121
+ const rp = n.renderedPosition();
122
+ const rect = cy.container().getBoundingClientRect();
123
+ return {
124
+ id: n.id(), x: rect.left + rp.x, y: rect.top + rp.y,
125
+ reviewPending: n.hasClass('review-pending'),
126
+ };
127
+ }
128
+ """
129
+
130
+ # Live state of a specific node id (plus total node count) β€” for before/after a reject.
131
+ NODE_STATE = """
132
+ (id) => {
133
+ const cy = window.__cy;
134
+ if (!cy) return null;
135
+ const n = cy.getElementById(id);
136
+ return { exists: n.length > 0, classes: n.length ? n.classes() : [], total: cy.nodes().length };
137
+ }
138
+ """
139
+
140
+ # Page-coord center of the OPEN menu wedge whose text matches `label` (case-insensitive).
141
+ WEDGE_BBOX = """
142
+ (label) => {
143
+ const wraps = Array.from(document.querySelectorAll('.cxtmenu'));
144
+ const open = wraps.find((w) => {
145
+ const p = w.querySelector('div');
146
+ return p && getComputedStyle(p).display === 'block';
147
+ });
148
+ if (!open) return null;
149
+ const el = Array.from(open.querySelectorAll('.cxtmenu-content'))
150
+ .find((e) => (e.textContent || '').toLowerCase().includes(label.toLowerCase()));
151
+ if (!el) return null;
152
+ const r = el.getBoundingClientRect();
153
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
154
+ }
155
+ """
156
+
157
 
158
  def main() -> int:
159
  console: list[str] = []
 
262
  report["edge_menu_wedges"] = edge_items
263
  page.screenshot(path=str(OUT / "cxtmenu_04_edge.png"))
264
  edge_joined = " ".join(edge_items).lower()
265
+ # WF-A: the edge menu now offers Accept / Reject / Edit label / Delete
266
+ # (parity with the node review menu), not just Delete.
267
+ for want in ("accept", "reject", "edit", "delete"):
268
+ if want not in edge_joined:
269
+ failures.append(
270
+ f"edge menu missing a '{want}' wedge; got {edge_items}"
271
+ )
272
  page.mouse.up(button="right")
273
  page.wait_for_timeout(150)
274
 
275
+ # F. Reject a node's claim β†’ the node must STAY on the canvas.
276
+ # This is the rendered proof of the reject-removes-node FIX: before the fix,
277
+ # a node-claim rejection emitted remove_element(#node_id) and the canvas
278
+ # deleted the whole node; now it clears the amber badge but keeps the node.
279
+ rnode = page.evaluate(PICK_REVIEWABLE_NODE)
280
+ report["reject_node"] = rnode
281
+ if not rnode:
282
+ failures.append("no node available to test reject")
283
+ else:
284
+ before = page.evaluate(NODE_STATE, rnode["id"])
285
+ report["reject_node_before"] = before
286
+ page.mouse.move(rnode["x"], rnode["y"])
287
+ page.mouse.down(button="right")
288
+ page.wait_for_timeout(250)
289
+ wedge = page.evaluate(WEDGE_BBOX, "reject")
290
+ report["reject_wedge_bbox"] = wedge
291
+ if not wedge:
292
+ failures.append("no Reject wedge found in the node menu")
293
+ page.mouse.up(button="right")
294
+ else:
295
+ # Swipe to the Reject wedge and release β†’ fires the reject dispatch.
296
+ page.mouse.move(wedge["x"], wedge["y"])
297
+ page.wait_for_timeout(150)
298
+ page.mouse.up(button="right")
299
+ # Wait for the backend round-trip + renderer patch to apply.
300
+ page.wait_for_timeout(2000)
301
+ after = page.evaluate(NODE_STATE, rnode["id"])
302
+ report["reject_node_after"] = after
303
+ page.screenshot(path=str(OUT / "cxtmenu_05_after_reject.png"))
304
+ if not after["exists"]:
305
+ failures.append(
306
+ f"REJECT REMOVED NODE {rnode['id']!r} β€” the reject-removes-node "
307
+ "bug is NOT fixed (node gone from cy after reject)"
308
+ )
309
+ if before and after["total"] != before["total"]:
310
+ failures.append(
311
+ f"node count changed on reject: {before['total']} -> "
312
+ f"{after['total']} (reject must not delete nodes)"
313
+ )
314
+ # Soft signal: a single-claim node should also lose its amber badge.
315
+ report["reject_amber_cleared"] = (
316
+ rnode.get("reviewPending")
317
+ and "review-pending" not in after["classes"]
318
+ )
319
+
320
  browser.close()
321
 
322
  # C. No page errors (console 'error' lines or pageerror entries).
src/loosecanvas/agent_harness.py CHANGED
@@ -19,6 +19,7 @@ llama.cpp gotcha (verified by the spike): do NOT pass ``stream_options`` to
19
  from __future__ import annotations
20
 
21
  import asyncio
 
22
  import os
23
  import time
24
  from collections.abc import AsyncIterator
@@ -53,41 +54,41 @@ from loosecanvas.turn_logic import (
53
  SessionRecord,
54
  TurnResult,
55
  _get_lock,
 
56
  session_store,
57
  )
58
 
 
 
59
  # ── System prompt ──────────────────────────────────────────────────────────────
60
 
61
- SYSTEM_PROMPT = """You are a calm, curious collaborator helping the user build understanding on a shared canvas.
62
- You are NOT a chatbot, NOT a JSON emitter, NOT an authority on the graph.
63
-
64
- The canvas is your primary surface. Your voice is a quieter companion. Talk when it clarifies the map, act when the move is obvious, or do both when both matter.
65
-
66
- VOICE CALIBRATION (scales inversely with confidence):
67
- - On deterministic facts (what's visible on the canvas, structure you can read): be silent or minimal. Let the structure speak.
68
- - On model-inferred claims (relationships, patterns, labels you notice): be a suggestor. Offer one clear idea. Use hedged language: "This appears to...", "I notice that...".
69
- - On open questions and hypotheses: be a provocateur. Challenge the current view. Ask "But what if...". Surface the uncertainty.
70
-
71
- WHEN YOU ACT:
72
- - Call tools to reveal, center, highlight, propose, or edit the canvas.
73
- - Narrate your intent in one short clause, then CALL THE TOOL in the same reply. Narrating is NOT acting: never say "I'll link them now" / "I'll connect them" / "next I'll..." and then stop. If you announce an action, you must emit its tool call in this same turn -- do not defer it to a later turn or wait for the user to say "continue".
74
- - FINISH THE WHOLE BUILD BEFORE YOU YIELD. When a request implies several steps (add concepts AND connect them; add multiple nodes; rename several edges), perform every tool call needed to complete it in this one turn. Do not split "create the nodes" and "connect the nodes" across turns. Only stop early to ask for confirmation on a destructive removal, or when you genuinely need information you cannot read with get_canvas_state.
75
- - Do NOT recap what the tool already visibly did.
76
- - Call get_canvas_state first if you are unsure what is visible, hidden, or selected -- it is your external memory. Never guess node ids.
77
- - Navigation (reveal_node, center_on, highlight, fog_all_except, show_callout, expand_around): act autonomously.
78
- - Proposals (propose_edge_label, propose_note, propose_hypothesis, propose_question): frame as ideas the user can accept or dismiss.
79
- - Autonomous edits (create_node, create_edge, update_node): act without waiting.
80
- - To grow the map: call create_node to add a NEW concept the canvas is missing (it becomes a model-inferred, unverified node), then create_edge to connect it -- in the SAME turn. Always emit each create_node BEFORE the create_edge that links to it. Add as many nodes and edges as the request warrants in one turn; a newly created node has no edges until you add them, so never leave a node you just created unconnected.
81
- - Destructive removal (remove_node, remove_edge): ALWAYS ask the user to confirm in conversation FIRST. Never call a remove tool until the user has explicitly said yes.
82
-
83
- WHEN YOU SPEAK:
84
- - Keep prose warm but economical. No filler, no "As an AI...", no emojis.
85
- - If the user's message is informational and no graph change is warranted, just talk -- do not force an action.
86
- - One or two sentences when confident; a longer explanation only when uncertain or interpretive.
87
-
88
- TRUST MODEL:
89
- - Your proposals are proposals: the user (and the validator) decide what becomes real. Never assert unsupported claims as fact.
90
- - Ground what you propose in evidence visible on the canvas or in the conversation.
91
  """
92
 
93
  # ── Streaming events ─────────────────────────────────────────────────────────
@@ -318,7 +319,7 @@ def _compose_user_message(
318
  if record is not None:
319
  record.attention.last_selected_node_id = sel_id
320
  return f"[The user currently has node '{sel_id}' selected.]\n\n{message}"
321
- if record is not None and record.attention.last_selected_node_id:
322
  remembered = record.attention.last_selected_node_id
323
  record.attention.last_selected_node_id = "" # consume β€” one-turn grace
324
  return f"[The user was just looking at node '{remembered}'.]\n\n{message}"
@@ -370,6 +371,41 @@ def _build_turn_messages(
370
  return messages
371
 
372
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  async def run_agent_turn_streaming(
374
  session_id: str,
375
  selection: dict[str, Any],
@@ -425,7 +461,7 @@ async def run_agent_turn_streaming(
425
  for call in tool_call_chunks:
426
  name = call.get("name") if isinstance(call, dict) else None
427
  if name:
428
- yield ToolActivityEvent(message=f"{name}…")
429
  content = getattr(token, "content", "")
430
  if isinstance(content, str) and content and node == "model":
431
  parts.append(content)
@@ -442,3 +478,68 @@ async def run_agent_turn_streaming(
442
  )
443
  finally:
444
  lock.release()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  from __future__ import annotations
20
 
21
  import asyncio
22
+ import logging
23
  import os
24
  import time
25
  from collections.abc import AsyncIterator
 
54
  SessionRecord,
55
  TurnResult,
56
  _get_lock,
57
+ effective_node_label,
58
  session_store,
59
  )
60
 
61
+ logger = logging.getLogger(__name__)
62
+
63
  # ── System prompt ──────────────────────────────────────────────────────────────
64
 
65
+ SYSTEM_PROMPT = """You are a warm, curious thinking partner helping the user grow a shared map of ideas on a canvas. You build understanding together: the user sets the direction, and you bring initiative, a sharp eye for connections, and a real willingness to act. Talk like a thoughtful person, not a system -- you are a collaborator, never a form to fill out or a function that emits JSON.
66
+
67
+ The canvas is where the thinking becomes visible. In any turn you can speak, act (call tools to shape the canvas), or do both -- whatever actually helps the user think.
68
+
69
+ HOW YOU TALK
70
+ - Be a genuine conversational partner. A sentence of orientation almost always helps: what you notice, what you just did, what you're curious about. Reserve silence for pure navigation (a pan or a zoom) where words would only be noise.
71
+ - You don't need to read back changes the canvas already shows -- instead say what they mean, or ask what's worth doing next.
72
+ - Match your certainty to the evidence. State what's plainly on the canvas matter-of-factly. Offer an inferred relationship or pattern as an idea -- "I notice...", "this looks like..." -- that the user can keep or drop. On the genuinely open questions, be a friendly provocateur: ask "but what if...", surface the tension, invite pushback.
73
+ - Warm and natural, but no filler and no emojis. Ask a real follow-up when it would move the thinking forward.
74
+
75
+ CALIBRATE YOUR VOICE TO YOUR CONFIDENCE
76
+ - Facts the user explicitly named or drew (user-asserted, deterministic): state them plainly and matter-of-factly. Do not hedge what is already established.
77
+ - Your own inferences, suggestions, or pattern-readings (model-inferred): hedge clearly. Use "I notice…", "one reading is…", "this looks like…" so the user can accept or drop the idea.
78
+ - Open questions, hypotheses, genuine unknowns: be a friendly provocateur. Ask "but what if…", surface the tension, invite pushback. Make clear you are raising a question, not asserting an answer.
79
+
80
+ If the user asks you to shift how you talk β€” be more skeptical, more concise, blunter, gentler, whatever β€” just go with it for the rest of the conversation; you'll remember.
81
+
82
+ HOW YOU ACT -- you can reshape the map the way the user could by hand: add ideas, rename and annotate them, connect them, surface hidden links, and (with the user's yes) remove things.
83
+ - When you say you'll do something, do it in the same turn. Don't promise an edit and then stop -- if you mention it, make the tool call now.
84
+ - Carry a request all the way through. If "add these ideas and connect them" implies several moves, make every tool call needed to finish it this turn. A new node has no edges until you draw them, so add the node first, then connect it -- never leave a node you just created floating.
85
+ - Reach for whatever fits: reveal_node / center_on / highlight to guide attention; create_node, create_edge, update_node, update_edge to grow and refine (update_edge relabels an existing connection so it shows on the canvas β€” use it when the user wants the edges clearer or more informative); propose_edge_label / propose_note / propose_hypothesis / propose_question to float an idea the user can accept or dismiss. Follow where the user wants to go rather than running a fixed script.
86
+ - Removing is the one move you always confirm first. Ask in plain conversation, and only call remove_node or remove_edge once the user has clearly said yes.
87
+ - When the user says to accept, approve, reject, or dismiss a proposal or a node/edge with an amber "awaiting review" badge, call review_element(target_id, action) where action is "accepted" or "rejected" β€” call get_canvas_state first to find the right id.
88
+ - get_canvas_state is your eyes on the current map -- lean on it whenever you're unsure what's visible or selected. If you reference something that isn't there, the system tells you, so act and adjust rather than over-checking everything first.
89
+
90
+ WHAT STAYS TRUE
91
+ - What you add starts as a proposal: the user (and the validator) decide what becomes real. Don't state a guess as a fact -- ground what you propose in what's on the canvas or what the user has said.
 
 
 
92
  """
93
 
94
  # ── Streaming events ─────────────────────────────────────────────────────────
 
319
  if record is not None:
320
  record.attention.last_selected_node_id = sel_id
321
  return f"[The user currently has node '{sel_id}' selected.]\n\n{message}"
322
+ elif record is not None and record.attention.last_selected_node_id:
323
  remembered = record.attention.last_selected_node_id
324
  record.attention.last_selected_node_id = "" # consume β€” one-turn grace
325
  return f"[The user was just looking at node '{remembered}'.]\n\n{message}"
 
371
  return messages
372
 
373
 
374
+ # Friendly, present-tense narration for the streamed tool-activity ("what the agent
375
+ # is doing now") so the chat reads like a collaborator, not a function trace. Unknown
376
+ # tools fall back to a humanized name. Only the STREAMED narration is humanized; the
377
+ # final collapsible receipts keep the precise tool:id labels (they're the audit detail).
378
+ _FRIENDLY_TOOL_NAMES = {
379
+ "create_node": "Adding a concept",
380
+ "add_concept": "Adding a concept",
381
+ "create_concept": "Adding a concept",
382
+ "create_edge": "Drawing a connection",
383
+ "connect_nodes": "Drawing a connection",
384
+ "add_edge": "Drawing a connection",
385
+ "propose_hypothesis": "Proposing a hidden connection",
386
+ "rename_node": "Renaming a concept",
387
+ "update_node": "Updating a concept",
388
+ "update_edge": "Relabeling a connection",
389
+ "set_summary": "Annotating a concept",
390
+ "annotate_node": "Annotating a concept",
391
+ "reveal_node": "Revealing a related idea",
392
+ "expand_node": "Revealing related ideas",
393
+ "remove_node": "Removing a concept",
394
+ "remove_edge": "Removing a connection",
395
+ "get_canvas_state": "Looking at the map",
396
+ "search_nodes": "Searching the map",
397
+ "review_element": "Reviewing a proposal",
398
+ }
399
+
400
+
401
+ def _friendly_tool(name: str) -> str:
402
+ """Map a raw tool name to a friendly present-tense phrase for live narration."""
403
+ if name in _FRIENDLY_TOOL_NAMES:
404
+ return _FRIENDLY_TOOL_NAMES[name]
405
+ words = name.replace("_", " ").strip()
406
+ return (words[:1].upper() + words[1:]) if words else name
407
+
408
+
409
  async def run_agent_turn_streaming(
410
  session_id: str,
411
  selection: dict[str, Any],
 
461
  for call in tool_call_chunks:
462
  name = call.get("name") if isinstance(call, dict) else None
463
  if name:
464
+ yield ToolActivityEvent(message=f"{_friendly_tool(name)}…")
465
  content = getattr(token, "content", "")
466
  if isinstance(content, str) and content and node == "model":
467
  parts.append(content)
 
478
  )
479
  finally:
480
  lock.release()
481
+
482
+
483
+ # ── First-turn opener (magic build) ─────────────────────────────────────────────
484
+ #
485
+ # The very first Send builds the graph via the deterministic text→graph extractor
486
+ # (turn_logic.stream_text_session), which mints the session but does NOT run the
487
+ # conversational agent. To make turn one feel like every later turn, ``main.py``
488
+ # streams THIS opener into the chat right after the graph paints β€” the same warm
489
+ # voice the user meets on turn two, naming the concepts that were actually pulled
490
+ # out. It is a plain (tool-less) streaming completion, so it never mutates the
491
+ # canvas and never pollutes the agent's conversation memory; the source document is
492
+ # seeded into that memory on the first real agent turn (see ``_build_turn_messages``).
493
+
494
+ _OPENER_SYSTEM = """You just helped the user turn their own text into a first map of ideas on a shared canvas. This is the opening line of your conversation with them β€” speak in your own warm, curious voice as their thinking partner.
495
+
496
+ In two to four sentences: weave in a few of the concepts you actually pulled out (they are listed below), make clear this first pass is your inference β€” a starting point they own and can reshape β€” and invite the next move (tightening a connection, renaming something, adding what's missing, or digging into one idea). No filler, no emojis, and don't just recite counts. The user is the driver."""
497
+
498
+
499
+ async def stream_build_opener(session_id: str) -> AsyncIterator[str]:
500
+ """Stream a warm, topic-aware opening line for a just-built magic-build graph.
501
+
502
+ Yields prose chunks (to be appended into the chat bubble exactly like a normal
503
+ turn's ``TokenEvent``). Replaces the old canned "Built a graph: N concepts"
504
+ template so turn one sounds like the same collaborator the user meets on every
505
+ later turn. If the model call fails for any reason, falls back to a single warm
506
+ static line that still names the extracted concepts β€” so turn one never lands
507
+ silent or with a bare count.
508
+ """
509
+ record = session_store.get(session_id)
510
+ labels: list[str] = []
511
+ n_edges = 0
512
+ if record is not None:
513
+ labels = [
514
+ effective_node_label(node, record.graph.claims.values())
515
+ for node in record.graph.nodes.values()
516
+ ]
517
+ n_edges = len(record.graph.edges)
518
+ sample = ", ".join(labels[:8]) if labels else "a handful of ideas"
519
+ user_msg = (
520
+ f"Concepts you extracted: {sample}. "
521
+ f"You also drew {n_edges} relation(s) between them."
522
+ )
523
+ emitted = False
524
+ try:
525
+ model = make_model()
526
+ async for chunk in model.astream(
527
+ [("system", _OPENER_SYSTEM), ("human", user_msg)]
528
+ ):
529
+ content = getattr(chunk, "content", "")
530
+ if isinstance(content, str) and content:
531
+ emitted = True
532
+ yield content
533
+ except Exception: # noqa: BLE001 β€” an opener hiccup must never break the build
534
+ logger.debug("build opener stream failed; using static fallback", exc_info=True)
535
+ # Only fall back when NOTHING streamed β€” a mid-stream failure that already
536
+ # emitted real tokens must not double-post the static line on top of them.
537
+ if emitted:
538
+ return
539
+ # Fallback: warm, names the concepts, never a bare count.
540
+ connected = " and connected them" if n_edges else ""
541
+ yield (
542
+ f"Here's a first pass at your map β€” I pulled out {sample}{connected}. "
543
+ "It's all my inference for now, a starting point you own. "
544
+ "Tell me what to sharpen, rename, or add."
545
+ )
src/loosecanvas/agent_tools.py CHANGED
@@ -27,12 +27,23 @@ from typing import Any
27
 
28
  from langchain.tools import tool
29
 
30
- from loosecanvas.contracts import SceneAction, SceneActionType, ScenePlan, SceneState
 
 
 
 
 
 
31
  from loosecanvas.turn_logic import (
32
  _AGENT_MAX_ACTIONS,
33
  _AGENT_MAX_REVEAL,
 
 
34
  SessionRecord,
35
  TurnResult,
 
 
 
36
  session_store,
37
  )
38
  from loosecanvas.validator import validate_sceneplan
@@ -47,6 +58,7 @@ class AgentTurnContext:
47
  session_id: str
48
  accumulated_actions: list[SceneAction] = field(default_factory=list)
49
  activity: list[str] = field(default_factory=list)
 
50
 
51
 
52
  _current_turn: ContextVar[AgentTurnContext | None] = ContextVar(
@@ -127,19 +139,36 @@ def _validate_and_accumulate(action: SceneAction, activity_label: str) -> str:
127
 
128
  @tool
129
  def get_canvas_state() -> str:
130
- """Return a compact JSON snapshot: visible nodes/labels, fogged count, visible edge ids, selection.
131
- Call this first to discover valid IDs before using any other tool."""
 
 
132
  ctx = get_turn_context()
133
  record: SessionRecord = session_store[ctx.session_id]
134
  scene: SceneState = record.scene
 
135
  visible_nodes = []
136
  for nid in scene.visible_node_ids:
137
  node = record.graph.get_node(nid)
138
  label = node.label if node is not None else nid
139
  visible_nodes.append({"id": nid, "label": label})
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  return json.dumps(
141
  {
142
  "visible_nodes": visible_nodes,
 
143
  "fogged_count": len(scene.fogged_node_ids),
144
  "visible_edge_ids": list(scene.visible_edge_ids),
145
  "selected_id": scene.selected_id,
@@ -334,6 +363,18 @@ def update_node(node_id: str, label: str = "", note: str = "") -> str:
334
  )
335
 
336
 
 
 
 
 
 
 
 
 
 
 
 
 
337
  @tool
338
  def remove_node(node_id: str) -> str:
339
  """Remove a visible node from the graph (permanent).
@@ -379,6 +420,76 @@ def expand_around(node_id: str) -> str:
379
  return json.dumps({"status": "rejected", "reason": reason, "hint": hint})
380
 
381
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
382
  # ── Exported tool list ─────────────────────────────────────────────────────────
383
 
384
  TOOLS: list[Any] = [
@@ -395,9 +506,11 @@ TOOLS: list[Any] = [
395
  create_node,
396
  create_edge,
397
  update_node,
 
398
  remove_node,
399
  remove_edge,
400
  expand_around,
 
401
  ]
402
 
403
 
@@ -411,18 +524,67 @@ def finalize_agent_turn(
411
  ) -> TurnResult | None:
412
  """Validate accumulated actions, reduce, apply patches, and finalize the turn.
413
 
414
- Returns ``None`` when no actions were accumulated (pure-speech turn β€” no canvas
415
- patch). Otherwise returns the ``TurnResult`` produced by the authoritative
416
- validate→reduce→apply→_finalize_turn pipeline.
 
 
 
 
 
 
417
  """
418
  from loosecanvas.turn_logic import (
419
  apply_agent_actions,
420
  ) # imported here to avoid import-time circularity
421
 
422
  ctx = get_turn_context()
 
 
 
 
423
  if not ctx.accumulated_actions:
424
- return None
425
- record: SessionRecord = session_store[session_id]
426
- return apply_agent_actions(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  record, ctx.accumulated_actions, selection, assistant_message
428
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  from langchain.tools import tool
29
 
30
+ from loosecanvas.contracts import (
31
+ RendererCommand,
32
+ SceneAction,
33
+ SceneActionType,
34
+ ScenePlan,
35
+ SceneState,
36
+ )
37
  from loosecanvas.turn_logic import (
38
  _AGENT_MAX_ACTIONS,
39
  _AGENT_MAX_REVEAL,
40
+ RendererPatch,
41
+ ReviewClaimResult,
42
  SessionRecord,
43
  TurnResult,
44
+ effective_edge_label,
45
+ review_edge_claim,
46
+ review_node_claim,
47
  session_store,
48
  )
49
  from loosecanvas.validator import validate_sceneplan
 
58
  session_id: str
59
  accumulated_actions: list[SceneAction] = field(default_factory=list)
60
  activity: list[str] = field(default_factory=list)
61
+ review_patches: list[RendererPatch] = field(default_factory=list)
62
 
63
 
64
  _current_turn: ContextVar[AgentTurnContext | None] = ContextVar(
 
139
 
140
  @tool
141
  def get_canvas_state() -> str:
142
+ """Return a compact JSON snapshot of the canvas. Call this first to discover valid IDs.
143
+ Includes visible_nodes (id+label), visible_edges (id, source, target, current label β€”
144
+ use these to relabel a connection with update_edge), fogged_count, and selected_id.
145
+ """
146
  ctx = get_turn_context()
147
  record: SessionRecord = session_store[ctx.session_id]
148
  scene: SceneState = record.scene
149
+ claims = record.graph.claims.values()
150
  visible_nodes = []
151
  for nid in scene.visible_node_ids:
152
  node = record.graph.get_node(nid)
153
  label = node.label if node is not None else nid
154
  visible_nodes.append({"id": nid, "label": label})
155
+ visible_edges = []
156
+ for eid in scene.visible_edge_ids:
157
+ edge = record.graph.get_edge(eid)
158
+ if edge is None:
159
+ continue
160
+ visible_edges.append(
161
+ {
162
+ "id": eid,
163
+ "source": edge.source,
164
+ "target": edge.target,
165
+ "label": effective_edge_label(edge, claims),
166
+ }
167
+ )
168
  return json.dumps(
169
  {
170
  "visible_nodes": visible_nodes,
171
+ "visible_edges": visible_edges,
172
  "fogged_count": len(scene.fogged_node_ids),
173
  "visible_edge_ids": list(scene.visible_edge_ids),
174
  "selected_id": scene.selected_id,
 
363
  )
364
 
365
 
366
+ @tool
367
+ def update_edge(edge_id: str, label: str) -> str:
368
+ """Relabel an existing visible edge so the new, clearer label shows on the canvas.
369
+ Use this to make a connection more informative or descriptive. Requires edge_id
370
+ (call get_canvas_state to list visible_edges with their ids and current labels) and
371
+ a non-empty label."""
372
+ return _validate_and_accumulate(
373
+ SceneAction(type=SceneActionType.update_edge, edge_id=edge_id, label=label),
374
+ f"update_edge:{edge_id}",
375
+ )
376
+
377
+
378
  @tool
379
  def remove_node(node_id: str) -> str:
380
  """Remove a visible node from the graph (permanent).
 
420
  return json.dumps({"status": "rejected", "reason": reason, "hint": hint})
421
 
422
 
423
+ @tool
424
+ def review_element(target_id: str, action: str) -> str:
425
+ """Accept or reject a model-inferred proposal (node or edge) awaiting review.
426
+
427
+ Call get_canvas_state first to discover valid node and edge ids on the canvas.
428
+ ``action`` must be "accepted" or "rejected".
429
+ Only works on model-inferred elements whose review badge (amber border) is still
430
+ showing β€” i.e. elements with review_state "pending".
431
+
432
+ Returns a JSON string with status, target_id, and the new review_state.
433
+ """
434
+ if action not in ("accepted", "rejected"):
435
+ return json.dumps(
436
+ {
437
+ "status": "error",
438
+ "reason": "action must be 'accepted' or 'rejected'",
439
+ "target_id": target_id,
440
+ }
441
+ )
442
+ ctx = get_turn_context()
443
+ record: SessionRecord | None = session_store.get(ctx.session_id)
444
+ if record is None:
445
+ return json.dumps(
446
+ {"status": "error", "reason": "session not found", "target_id": target_id}
447
+ )
448
+
449
+ # Determine whether target is a node or an edge and call the appropriate helper.
450
+ result: ReviewClaimResult | None
451
+ if target_id in record.graph.nodes:
452
+ try:
453
+ result = review_node_claim(ctx.session_id, target_id, action)
454
+ except Exception as exc: # noqa: BLE001
455
+ return json.dumps(
456
+ {"status": "error", "reason": str(exc), "target_id": target_id}
457
+ )
458
+ elif target_id in record.graph.edges:
459
+ try:
460
+ result = review_edge_claim(ctx.session_id, target_id, action)
461
+ except Exception as exc: # noqa: BLE001
462
+ return json.dumps(
463
+ {"status": "error", "reason": str(exc), "target_id": target_id}
464
+ )
465
+ else:
466
+ return json.dumps(
467
+ {
468
+ "status": "not_found",
469
+ "reason": f"no node or edge with id {target_id!r} β€” call get_canvas_state to see valid ids",
470
+ "target_id": target_id,
471
+ }
472
+ )
473
+
474
+ if result is None:
475
+ return json.dumps(
476
+ {
477
+ "status": "not_reviewable",
478
+ "reason": f"{target_id!r} has no model-inferred claims awaiting review",
479
+ "target_id": target_id,
480
+ }
481
+ )
482
+
483
+ ctx.review_patches.append(result.renderer_patch)
484
+ return json.dumps(
485
+ {
486
+ "status": "ok",
487
+ "target_id": target_id,
488
+ "review_state": result.review_state,
489
+ }
490
+ )
491
+
492
+
493
  # ── Exported tool list ─────────────────────────────────────────────────────────
494
 
495
  TOOLS: list[Any] = [
 
506
  create_node,
507
  create_edge,
508
  update_node,
509
+ update_edge,
510
  remove_node,
511
  remove_edge,
512
  expand_around,
513
+ review_element,
514
  ]
515
 
516
 
 
524
  ) -> TurnResult | None:
525
  """Validate accumulated actions, reduce, apply patches, and finalize the turn.
526
 
527
+ Returns ``None`` when no actions were accumulated AND no review patches exist
528
+ (pure-speech turn β€” no canvas patch).
529
+
530
+ When accumulated_actions is empty but review_patches is non-empty (the user only
531
+ asked to accept/reject something), builds a minimal TurnResult carrying the merged
532
+ review ops so the canvas updates.
533
+
534
+ Otherwise runs the authoritative validate→reduce→apply→_finalize_turn pipeline and
535
+ merges any review patch operations into the returned RendererPatch.
536
  """
537
  from loosecanvas.turn_logic import (
538
  apply_agent_actions,
539
  ) # imported here to avoid import-time circularity
540
 
541
  ctx = get_turn_context()
542
+ review_ops: list[RendererCommand] = [
543
+ op for rp in ctx.review_patches for op in rp.operations
544
+ ]
545
+
546
  if not ctx.accumulated_actions:
547
+ if not review_ops:
548
+ # Pure-speech turn: no canvas changes at all.
549
+ return None
550
+ # Review-only turn: build a minimal TurnResult with just the review ops.
551
+ record: SessionRecord = session_store[session_id]
552
+ record.patch_id += 1
553
+ patch = RendererPatch(
554
+ patch_id=record.patch_id,
555
+ is_snapshot=False,
556
+ scene_id=record.render_scene_id,
557
+ operations=review_ops,
558
+ )
559
+ return TurnResult(
560
+ renderer_patch=patch,
561
+ inspector_update=None,
562
+ assistant_message=assistant_message,
563
+ scene_patch=None,
564
+ status="success",
565
+ warnings=[],
566
+ )
567
+
568
+ record = session_store[session_id]
569
+ turn_result = apply_agent_actions(
570
  record, ctx.accumulated_actions, selection, assistant_message
571
  )
572
+ if not review_ops:
573
+ return turn_result
574
+
575
+ # Merge review ops into the returned patch (new patch_id, same scene/snapshot).
576
+ record.patch_id += 1
577
+ merged_patch = RendererPatch(
578
+ patch_id=record.patch_id,
579
+ is_snapshot=turn_result.renderer_patch.is_snapshot,
580
+ scene_id=turn_result.renderer_patch.scene_id,
581
+ operations=list(turn_result.renderer_patch.operations) + review_ops,
582
+ )
583
+ return TurnResult(
584
+ renderer_patch=merged_patch,
585
+ inspector_update=turn_result.inspector_update,
586
+ assistant_message=turn_result.assistant_message,
587
+ scene_patch=turn_result.scene_patch,
588
+ status=turn_result.status,
589
+ warnings=turn_result.warnings,
590
+ )
src/loosecanvas/contracts.py CHANGED
@@ -215,7 +215,7 @@ class SuggestedPathway(BaseModel):
215
 
216
 
217
  class SceneActionType(StrEnum):
218
- """The 15 ``SceneAction`` variants. Must serialize INLINE (no ``$ref``)."""
219
 
220
  # Visual actions (low-risk; no Claims created).
221
  center_on = "center_on"
@@ -234,6 +234,7 @@ class SceneActionType(StrEnum):
234
  remove_edge = "remove_edge" # uses SceneAction.edge_id
235
  # User-edit actions (T2-03):
236
  update_node = "update_node" # uses target_id + (label | note)
 
237
  create_edge = "create_edge" # uses source_id + target_id + label
238
  # Graph-growth action: the agent proposes a NEW concept node (model_inferred).
239
  create_node = "create_node" # uses target_id (new id) + label + note (summary)
 
215
 
216
 
217
  class SceneActionType(StrEnum):
218
+ """The 16 ``SceneAction`` variants. Must serialize INLINE (no ``$ref``)."""
219
 
220
  # Visual actions (low-risk; no Claims created).
221
  center_on = "center_on"
 
234
  remove_edge = "remove_edge" # uses SceneAction.edge_id
235
  # User-edit actions (T2-03):
236
  update_node = "update_node" # uses target_id + (label | note)
237
+ update_edge = "update_edge" # uses edge_id + label (relabel an existing edge)
238
  create_edge = "create_edge" # uses source_id + target_id + label
239
  # Graph-growth action: the agent proposes a NEW concept node (model_inferred).
240
  create_node = "create_node" # uses target_id (new id) + label + note (summary)
src/loosecanvas/exporter.py CHANGED
@@ -15,6 +15,16 @@ Trust filtering (guards against bug **R14** β€” user-confirmed inferences being
15
 
16
  ``open_question`` claims are routed to their own ``open_questions`` section, mapping
17
  ``Claim.text`` onto the output ``note`` key (``Claim`` has **no** ``note`` field).
 
 
 
 
 
 
 
 
 
 
18
  """
19
 
20
  from __future__ import annotations
@@ -22,7 +32,7 @@ from __future__ import annotations
22
  from datetime import UTC, datetime
23
  from typing import Any
24
 
25
- from loosecanvas.contracts import Claim, MapEvent, SuggestedPathway
26
  from loosecanvas.graph_repository import LooseGraphRepository
27
 
28
  SCHEMA_VERSION = "0.1.0"
@@ -46,6 +56,45 @@ def _include_claim(claim: Claim) -> bool:
46
  return False
47
 
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  async def export_session(
50
  graph: LooseGraphRepository,
51
  *,
@@ -58,16 +107,41 @@ async def export_session(
58
  The result is deterministic for a fixed input (aside from the ``created_at`` stamp)
59
  and round-trips through ``json.dumps``/``json.loads`` unchanged.
60
  """
61
- nodes = [node.model_dump(mode="json") for node in graph.nodes.values()]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  edges = [
63
- edge.model_dump(mode="json", by_alias=True) for edge in graph.edges.values()
 
 
 
 
64
  ]
65
 
66
  claims: list[dict[str, Any]] = []
67
  open_questions: list[dict[str, str]] = []
68
  for claim in graph.claims.values():
69
  if claim.claim_type == _OPEN_QUESTION_TYPE:
70
- open_questions.append({"target_id": claim.target_id, "note": claim.text})
 
 
 
 
 
71
  continue
72
  if _include_claim(claim):
73
  claims.append(claim.model_dump(mode="json"))
 
15
 
16
  ``open_question`` claims are routed to their own ``open_questions`` section, mapping
17
  ``Claim.text`` onto the output ``note`` key (``Claim`` has **no** ``note`` field).
18
+
19
+ Topology redaction (guards against the **trust-gate leak** β€” unreviewed model guesses
20
+ leaking as full topology): the same trust rule that filters claims also filters the
21
+ ``nodes`` and ``edges`` arrays. Dropping the claim alone is not enough β€” an unreviewed
22
+ ``model_inferred`` node (``create_node``) or edge (``find_connection``/``propose_hypothesis``)
23
+ would otherwise still ship its full shape. Element trust is derived from its governing
24
+ claims (claims whose ``target_id`` is the element id), falling back to the element's own
25
+ ``properties["origin"]`` / ``metadata["origin"]`` only when it has **no** governing claim
26
+ (e.g. an element that is not yet claim-backed). After excluding nodes, any edge whose
27
+ source or target was dropped is cascade-removed.
28
  """
29
 
30
  from __future__ import annotations
 
32
  from datetime import UTC, datetime
33
  from typing import Any
34
 
35
+ from loosecanvas.contracts import Claim, Edge, MapEvent, Node, SuggestedPathway
36
  from loosecanvas.graph_repository import LooseGraphRepository
37
 
38
  SCHEMA_VERSION = "0.1.0"
 
56
  return False
57
 
58
 
59
+ def _include_origin(origin: str, *, has_accepting_review: bool) -> bool:
60
+ """Trust decision for an element, mirroring :func:`_include_claim`.
61
+
62
+ ``deterministic``/``user_asserted`` always pass; ``model_inferred`` passes only when
63
+ an accepting review exists. Any unknown origin fails closed.
64
+ """
65
+ if origin in (_DETERMINISTIC_ORIGIN, _USER_ASSERTED_ORIGIN):
66
+ return True
67
+ if origin == _MODEL_INFERRED_ORIGIN:
68
+ return has_accepting_review
69
+ return False
70
+
71
+
72
+ def _element_origin(element: Node | Edge) -> str:
73
+ """Best-effort origin for an element with no governing claim.
74
+
75
+ Producers disagree on where they stamp origin: ``_model_node``/``_hypothesis_edge``
76
+ use ``properties["origin"]`` while the seed fixture's nodes use ``metadata``. Check
77
+ both. Absent any stamp, treat as ``deterministic`` (extraction output is trusted).
78
+ """
79
+ origin = element.properties.get("origin")
80
+ if origin is None and isinstance(element, Node):
81
+ origin = element.metadata.get("origin")
82
+ return origin if origin is not None else _DETERMINISTIC_ORIGIN
83
+
84
+
85
+ def _include_element(element: Node | Edge, governing: list[Claim]) -> bool:
86
+ """Return ``True`` if ``element`` (a node or edge) is trustworthy enough to export.
87
+
88
+ With governing claims: pass if **any** governing claim passes :func:`_include_claim`
89
+ (an accepting review on one claim promotes the element). Without governing claims:
90
+ fall back to the element's own stamped origin.
91
+ """
92
+ if governing:
93
+ return any(_include_claim(c) for c in governing)
94
+ origin = _element_origin(element)
95
+ return _include_origin(origin, has_accepting_review=False)
96
+
97
+
98
  async def export_session(
99
  graph: LooseGraphRepository,
100
  *,
 
107
  The result is deterministic for a fixed input (aside from the ``created_at`` stamp)
108
  and round-trips through ``json.dumps``/``json.loads`` unchanged.
109
  """
110
+ # Group governing claims by the id they target so element trust can aggregate them.
111
+ claims_by_target: dict[str, list[Claim]] = {}
112
+ for claim in graph.claims.values():
113
+ claims_by_target.setdefault(claim.target_id, []).append(claim)
114
+
115
+ # Topology redaction: drop unreviewed model inferences at the artifact level, then
116
+ # cascade-drop any edge whose endpoint was excluded.
117
+ kept_node_ids: set[str] = {
118
+ node.id
119
+ for node in graph.nodes.values()
120
+ if _include_element(node, claims_by_target.get(node.id, []))
121
+ }
122
+ nodes = [
123
+ node.model_dump(mode="json")
124
+ for node in graph.nodes.values()
125
+ if node.id in kept_node_ids
126
+ ]
127
  edges = [
128
+ edge.model_dump(mode="json", by_alias=True)
129
+ for edge in graph.edges.values()
130
+ if edge.source in kept_node_ids
131
+ and edge.target in kept_node_ids
132
+ and _include_element(edge, claims_by_target.get(edge.id, []))
133
  ]
134
 
135
  claims: list[dict[str, Any]] = []
136
  open_questions: list[dict[str, str]] = []
137
  for claim in graph.claims.values():
138
  if claim.claim_type == _OPEN_QUESTION_TYPE:
139
+ # Open questions are also trust-gated: an unreviewed model_inferred question
140
+ # (reducer._question_claim mints exactly that) must not leak.
141
+ if _include_claim(claim):
142
+ open_questions.append(
143
+ {"target_id": claim.target_id, "note": claim.text}
144
+ )
145
  continue
146
  if _include_claim(claim):
147
  claims.append(claim.model_dump(mode="json"))
src/loosecanvas/extractors/fixture_adapter.py CHANGED
@@ -38,9 +38,18 @@ def load_fixture_from_dict(data: dict[str, Any]) -> tuple[LooseGraph, SceneState
38
  """Validate an inline fixture ``dict`` into ``(LooseGraph, SceneState)``.
39
 
40
  The explicit ``scene`` is read verbatim β€” no auto-curation (M12) is performed.
 
 
 
 
 
41
  """
42
- graph = LooseGraph.model_validate(data["graph"])
43
- scene = SceneState.model_validate(data["scene"])
 
 
 
 
44
  return graph, scene
45
 
46
 
 
38
  """Validate an inline fixture ``dict`` into ``(LooseGraph, SceneState)``.
39
 
40
  The explicit ``scene`` is read verbatim β€” no auto-curation (M12) is performed.
41
+
42
+ A corrupt/partial fixture (missing the top-level ``graph`` or ``scene`` key) raises
43
+ ``ValueError`` β€” NOT a bare ``KeyError``. ``/api/load_graph`` maps ``ValueError`` to
44
+ HTTP 400 (a ``KeyError`` would surface as an opaque 500), and on_seed's resilience
45
+ handler treats it as a normal load failure and falls back instead of crashing.
46
  """
47
+ raw_graph = data.get("graph")
48
+ raw_scene = data.get("scene")
49
+ if raw_graph is None or raw_scene is None:
50
+ raise ValueError("fixture missing graph/scene")
51
+ graph = LooseGraph.model_validate(raw_graph)
52
+ scene = SceneState.model_validate(raw_scene)
53
  return graph, scene
54
 
55
 
src/loosecanvas/extractors/text_graph_adapter.py CHANGED
@@ -72,18 +72,34 @@ _ALLOWED_EDGE_KINDS: frozenset[str] = frozenset(
72
  _SLUG_RE = re.compile(r"[^a-z0-9_]+")
73
 
74
  _SYSTEM_PROMPT = """\
75
- Read this text and identify the key concepts and the relationships between them.
76
- Return a JSON object only. No prose outside the JSON.
 
77
  Schema:
78
  {
79
  "concept_nodes": [{"id": "concept::<snake_case>", "label": "<Name>",
80
  "summary": "<1 sentence>", "start_char": <int>, "end_char": <int>}],
81
  "semantic_edges": [{"source_id": "concept::<...>", "target_id": "concept::<...>",
82
  "type": "explains|prerequisite_of|contradicts|related_to|mentions",
83
- "label": "<short label>"}]
84
  }
85
- start_char/end_char are character offsets into the provided text marking where the
86
- concept is introduced. All edge source_id/target_id must reference a concept id.\
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  """
88
 
89
  _TEXT_GRAPH_SCHEMA: dict[str, Any] = {
@@ -114,7 +130,10 @@ _TEXT_GRAPH_SCHEMA: dict[str, Any] = {
114
  "type": {"type": "string", "enum": sorted(_ALLOWED_EDGE_KINDS)},
115
  "label": {"type": "string"},
116
  },
117
- "required": ["source_id", "target_id", "type"],
 
 
 
118
  "additionalProperties": False,
119
  },
120
  },
@@ -375,11 +394,11 @@ def _ingest_edge(
375
  id=claim_id,
376
  claim_type=_SEMANTIC_EDGE_CLAIM,
377
  source_id=src,
378
- target_id=tgt,
379
  text=kind,
380
  origin=_MODEL_INFERRED,
381
  support_state=_UNVERIFIED,
382
- evidence_refs=[_chunk_span_evidence(chunk, source_id, tgt)],
383
  )
384
 
385
 
 
72
  _SLUG_RE = re.compile(r"[^a-z0-9_]+")
73
 
74
  _SYSTEM_PROMPT = """\
75
+ Read this text and build a clear, genuinely useful concept map: the key ideas and how
76
+ they connect. Return a JSON object only. No prose outside the JSON.
77
+
78
  Schema:
79
  {
80
  "concept_nodes": [{"id": "concept::<snake_case>", "label": "<Name>",
81
  "summary": "<1 sentence>", "start_char": <int>, "end_char": <int>}],
82
  "semantic_edges": [{"source_id": "concept::<...>", "target_id": "concept::<...>",
83
  "type": "explains|prerequisite_of|contradicts|related_to|mentions",
84
+ "label": "<relationship phrase>"}]
85
  }
86
+
87
+ Quality bar β€” write it for a human reader, not a parser:
88
+ - NODE label: the specific, recognizable name of the concept the way a person would say
89
+ it β€” a concrete noun phrase in Title Case ("Retrieval-Augmented Generation", "Local AI",
90
+ "llama.cpp"), preferring the exact term the text uses. Never a vague umbrella word, a
91
+ whole sentence, or a bare fragment. The id is that same name in snake_case.
92
+ - NODE summary: one plain, informative sentence saying what the concept actually is.
93
+ - EDGE label: a short, SPECIFIC phrase describing how source relates to target, written so
94
+ it reads naturally as "source <label> target" β€” e.g. "grounds answers in your documents",
95
+ "runs models offline through", "adapts a base model to your domain". Do NOT fall back on
96
+ empty connectors like "uses", "related to", "is part of", or the bare relationship type
97
+ when the text supports something more precise. Every edge needs a descriptive label.
98
+ - "type" is the relationship CATEGORY (it picks the arrow); "label" is the human-readable
99
+ detail. They are different β€” never just copy the type into the label.
100
+
101
+ start_char/end_char are character offsets into the provided text marking where the concept
102
+ is introduced. Every edge source_id/target_id must reference a concept id you listed.\
103
  """
104
 
105
  _TEXT_GRAPH_SCHEMA: dict[str, Any] = {
 
130
  "type": {"type": "string", "enum": sorted(_ALLOWED_EDGE_KINDS)},
131
  "label": {"type": "string"},
132
  },
133
+ # ``label`` is required so the grammar always emits a descriptive
134
+ # relationship phrase β€” a blank edge label renders as a bare line and
135
+ # is exactly the "generic/uninformative edges" problem we're fixing.
136
+ "required": ["source_id", "target_id", "type", "label"],
137
  "additionalProperties": False,
138
  },
139
  },
 
394
  id=claim_id,
395
  claim_type=_SEMANTIC_EDGE_CLAIM,
396
  source_id=src,
397
+ target_id=edge_id,
398
  text=kind,
399
  origin=_MODEL_INFERRED,
400
  support_state=_UNVERIFIED,
401
+ evidence_refs=[_chunk_span_evidence(chunk, source_id, edge_id)],
402
  )
403
 
404
 
src/loosecanvas/graph_repository.py CHANGED
@@ -212,15 +212,29 @@ class LooseGraphRepository:
212
  else patch.set_selected_id
213
  )
214
 
 
 
 
 
 
 
 
 
 
 
215
  return SceneState(
216
  scene_id=scene.scene_id,
217
  visible_node_ids=visible_nodes,
218
  visible_edge_ids=visible_edges,
219
  fogged_node_ids=fogged_nodes,
220
- highlighted_ids=list(patch.set_highlighted_ids),
221
  selected_id=selected_id,
222
  callouts=callouts,
223
  node_positions=dict(scene.node_positions),
 
 
 
 
224
  )
225
 
226
  # ── Fog replenishment (own adjacency only; no NetworkX, no M12) ───────────
 
212
  else patch.set_selected_id
213
  )
214
 
215
+ # ``set_highlighted_ids`` is "unset" when empty (the real producer, reducer.py,
216
+ # only assigns a NON-empty list β€” ``if highlighted:``). Mirror ``selected_id``:
217
+ # preserve the existing highlight when unset, override when explicitly set. Else a
218
+ # benign turn after a search would wipe the highlight (diff_scene β†’ remove_class).
219
+ highlighted_ids = (
220
+ list(scene.highlighted_ids)
221
+ if not patch.set_highlighted_ids
222
+ else list(patch.set_highlighted_ids)
223
+ )
224
+
225
  return SceneState(
226
  scene_id=scene.scene_id,
227
  visible_node_ids=visible_nodes,
228
  visible_edge_ids=visible_edges,
229
  fogged_node_ids=fogged_nodes,
230
+ highlighted_ids=highlighted_ids,
231
  selected_id=selected_id,
232
  callouts=callouts,
233
  node_positions=dict(scene.node_positions),
234
+ # ``ScenePatch`` has no cluster slot β€” cluster colours live on the scene and
235
+ # are recomputed only by the 'Colour clusters' path; carry them forward so a
236
+ # later turn never drops them (diff_scene β†’ remove_class cluster-N).
237
+ node_cluster_map=dict(scene.node_cluster_map),
238
  )
239
 
240
  # ── Fog replenishment (own adjacency only; no NetworkX, no M12) ───────────
src/loosecanvas/main.py CHANGED
@@ -29,6 +29,7 @@ from loosecanvas.agent_harness import (
29
  TokenEvent,
30
  ToolActivityEvent,
31
  run_agent_turn_streaming,
 
32
  )
33
  from loosecanvas.contracts import RendererCommand
34
  from loosecanvas.llm_client import LLMClient
@@ -40,6 +41,7 @@ from loosecanvas.turn_logic import (
40
  _commit_snapshot,
41
  apply_user_edge,
42
  apply_user_edit,
 
43
  inspector_for_selection,
44
  load_fixture_session,
45
  load_repo_session,
@@ -49,8 +51,8 @@ from loosecanvas.turn_logic import (
49
  push_focus,
50
  redo_turn,
51
  save_session,
 
52
  session_store,
53
- stream_text_session,
54
  undo_turn,
55
  )
56
 
@@ -59,7 +61,105 @@ _WORKSPACE_ROOT = Path(__file__).resolve().parents[2]
59
  _FIXTURES: dict[str, Path] = {
60
  "annotated_graph": _WORKSPACE_ROOT / "fixtures" / "annotated_graph.json",
61
  "small_graph": _WORKSPACE_ROOT / "fixtures" / "small_graph.json",
 
62
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
  # Public-Space safety: cap pasted/loaded text so one request can't monopolize the
65
  # single (--parallel 1) GPU slot. ~100 KB is generous for an article paste; the
@@ -208,6 +308,21 @@ def _tool_receipt_messages(activity: list[str]) -> list[dict[str, Any]]:
208
  return messages
209
 
210
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  async def _search_nodes(session_id: str, query: str, limit: int = 50) -> dict[str, Any]:
212
  """Shared fog-aware search core. Pure-read; never mutates session state."""
213
  limit = max(0, min(limit, _SEARCH_LIMIT_CAP))
@@ -471,6 +586,13 @@ _CSS = """
471
  padding: 0.5rem;
472
  opacity: 0.7;
473
  }
 
 
 
 
 
 
 
474
  /* ── P0-03: CSS-grid layout shell ──────────────────────────────────── */
475
  /* Expand the Gradio app container to full viewport width */
476
  .gradio-container {
@@ -485,17 +607,20 @@ _CSS = """
485
  /* Gradio wraps custom components in a <span> β€” that span is the actual flex item,
486
  not #lc-canvas itself. Target it directly to defeat the 25%/75% default split. */
487
  #lc-canvas-row > span {
488
- flex: 1 1 64% !important;
489
  min-width: 0 !important;
490
  }
491
- /* Right rail (inspector + chat). Height is a FIXED px value, never vh β€” see the
492
- canvas rule below for why. overflow-y lets the rail scroll if its content is tall. */
 
493
  #lc-inspector-col {
494
- flex: 0 0 34% !important;
495
  min-width: 0 !important;
496
  overflow-y: auto;
497
  max-height: 540px;
498
  }
 
 
499
  /* Cap the inspector JSON so the chat below it stays on screen; it scrolls if a node
500
  has many claims. */
501
  #lc-inspector { max-height: 150px; overflow: auto; }
@@ -565,8 +690,11 @@ _HEADER_HTML = """
565
 
566
  # Trust legend. The SVG swatches MUST match renderer.ts's originβ†’shape rules
567
  # (round-rectangle/solid = deterministic, ellipse/solid-green = user-asserted,
568
- # hexagon/dashed = model-inferred, + amber pie-dot = review-pending) so the on-canvas
569
- # encoding is decodable at a glance instead of only on hover.
 
 
 
570
  _LEGEND_HTML = """
571
  <div class="lc-legend" role="note" aria-label="Trust legend">
572
  <span class="lc-legend-title">Trust map</span>
@@ -594,8 +722,7 @@ _LEGEND_HTML = """
594
  <span class="lc-legend-item">
595
  <svg class="lc-legend-svg" width="26" height="22" aria-hidden="true">
596
  <polygon points="13,2 22,7 22,15 13,20 4,15 4,7"
597
- fill="#3b5bdb" stroke="#748ffc" stroke-width="2" stroke-dasharray="3 2"/>
598
- <circle cx="21" cy="4" r="3.6" fill="#f59e0b"/>
599
  </svg>
600
  Awaiting your review
601
  </span>
@@ -651,6 +778,20 @@ async def on_search(q: str, sid: str) -> tuple[dict[str, Any], list[dict[str, An
651
  )
652
  )
653
  record.scene.highlighted_ids = vis_ids
 
 
 
 
 
 
 
 
 
 
 
 
 
 
654
 
655
  record.patch_id += 1
656
  patch2 = RendererPatch(
@@ -784,28 +925,10 @@ def build_app() -> tuple[Any, Any]:
784
  session_id = gr.State()
785
  current_selection = gr.State({"kind": "none", "id": ""})
786
 
787
- with gr.Row():
788
- fixture_dropdown = gr.Dropdown(
789
- choices=list(_FIXTURES.keys()),
790
- value="annotated_graph",
791
- label="Fixture",
792
- scale=3,
793
- )
794
- load_btn = gr.Button("Load graph", scale=1)
795
-
796
- with gr.Accordion("Build from text (Gemma)", open=False):
797
- text_title = gr.Textbox(
798
- label="Title",
799
- placeholder="e.g. Gradient Descent",
800
- value="Pasted text",
801
- )
802
- text_content = gr.Textbox(
803
- label="Content",
804
- placeholder="Paste prose, notes, or an article β€” Gemma extracts a "
805
- "concept graph (requires the LLM server).",
806
- lines=8,
807
- )
808
- build_btn = gr.Button("Build graph from text", variant="primary")
809
 
810
  # P0-03 + whitespace reclaim: canvas (left) shares one row with a right rail
811
  # holding the inspector + chat, so the canvas and the conversation are on
@@ -813,15 +936,17 @@ def build_app() -> tuple[Any, Any]:
813
  # dead band. CSS flex gives canvas ~64% / rail ~34% (defeating Gradio's
814
  # min-width collapse); Index.svelte's ResizeObserver re-fits the canvas.
815
  onboarding = gr.Markdown(
816
- "**loosecanvas** β€” paste text above and click *Build graph from text* to create a "
817
- "knowledge graph, or load a fixture below.",
 
818
  visible=True,
819
  elem_classes=["lc-onboarding"],
820
  )
821
  gr.HTML(_LEGEND_HTML)
 
822
  with gr.Row(elem_id="lc-canvas-row"):
823
  canvas = CytoscapeCanvas(elem_id="lc-canvas")
824
- with gr.Column(scale=3, elem_id="lc-inspector-col"):
825
  inspector = gr.JSON(label="Inspector", elem_id="lc-inspector")
826
  gr.Markdown(
827
  "*Select a node to see where each claim came from and whether "
@@ -834,25 +959,46 @@ def build_app() -> tuple[Any, Any]:
834
  reject_btn = gr.Button("βœ— Reject", variant="secondary", scale=1)
835
  dispute_btn = gr.Button("? Dispute", variant="secondary", scale=1)
836
  claim_review_status = gr.Markdown("", visible=False)
837
- chatbot = gr.Chatbot(label="Assistant", height=220, elem_id="lc-chat")
838
- with gr.Row():
839
- msg = gr.Textbox(
840
- placeholder="Ask about the graph…",
841
- scale=8,
842
- show_label=False,
843
- # A1: keep lines=1 so Enter still submits (no Enter-semantics
844
- # regression), but auto-grow up to 6 lines for a pasted paragraph.
845
- lines=1,
846
- max_lines=6,
847
- autofocus=True,
848
- )
849
- send_btn = gr.Button("Send", scale=1)
 
 
 
 
 
850
  status_md = gr.Markdown(value="", elem_classes=["lc-turn-status"])
 
 
 
851
  with gr.Row():
852
- undo_btn = gr.Button("↩ Undo", scale=1)
853
- redo_btn = gr.Button("β†ͺ Redo", scale=1)
854
  cluster_btn = gr.Button("🎨 Colour clusters", scale=1)
855
- with gr.Row():
 
 
 
 
 
 
 
 
 
 
 
 
 
856
  search_box = gr.Textbox(
857
  placeholder="Search graph nodes…",
858
  scale=9,
@@ -869,98 +1015,129 @@ def build_app() -> tuple[Any, Any]:
869
 
870
  # ── Handlers ──────────────────────────────────────────────────────────
871
 
872
- async def on_load_graph(
873
- fixture_name: str,
874
- ) -> tuple[Any, str, Any]:
875
- path = _FIXTURES.get(fixture_name, _FIXTURES["annotated_graph"])
876
- sid, snapshot = await load_fixture_session(fixture_path=path)
877
- return snapshot.model_dump(), sid, gr.update(visible=False)
878
-
879
- async def on_build_text(content: str, title: str) -> AsyncGenerator[Any]:
880
- # P2-02 streamed build: a status appears immediately (UI not frozen for
881
- # the ~15-30s extraction), then the graph snaps in on completion. The six
882
- # outputs, in order, are canvas, session_id, chatbot, onboarding, status_md
883
- # and text_content. The canvas is left untouched until the final success
884
- # event so a partial or error frame never clears it; A4 clears text_content
885
- # only on a successful build.
886
- if not content or not content.strip():
887
- yield (
888
- gr.update(),
889
- "",
890
- [
891
- {
892
- "role": "assistant",
893
- "content": "⚠️ Paste some text first, then click Build.",
894
- }
895
- ],
896
- gr.update(),
897
- "",
898
- gr.update(),
899
- )
900
- return
901
- if len(content) > _MAX_TEXT_LEN:
902
- yield (
903
- gr.update(),
904
- "",
905
- [
906
- {
907
- "role": "assistant",
908
- "content": (
909
- f"⚠️ That text is too long (>{_MAX_TEXT_LEN:,} chars). "
910
- "Please shorten it and try again."
911
- ),
912
- }
913
- ],
914
- gr.update(),
915
- "",
916
- gr.update(),
917
- )
918
- return
919
- async for prog in stream_text_session(
920
- content, title or "Pasted text", LLMClient()
921
- ):
922
- if prog.is_partial:
923
  yield (
924
  gr.update(),
925
  gr.update(),
926
  gr.update(),
927
  gr.update(),
928
- f"⏳ {prog.status}",
929
  gr.update(),
930
- )
931
- elif prog.is_error:
932
- yield (
933
- gr.update(),
934
- gr.update(),
935
- prog.chat_message,
936
- gr.update(),
937
- "",
938
  gr.update(),
939
  )
940
- else:
941
- yield (
942
- prog.canvas_patch,
943
- prog.session_id,
944
- prog.chat_message,
945
- gr.update(visible=False),
946
- "",
947
- gr.update(value=""), # A4: clear the source textarea
948
- )
949
-
950
- async def on_turn(
951
- message: str, sid: str, selection: dict[str, Any], history: list[Any]
952
- ) -> AsyncGenerator[Any]:
953
- # Outputs: [canvas, inspector, chatbot, msg, status_md, send_btn].
954
- if not sid:
955
- # A3: preserve the draft β€” warn in status, don't commit to history.
956
  yield (
957
  gr.update(),
958
  gr.update(),
959
- gr.update(),
960
- gr.update(),
961
- "⚠️ No graph loaded yet β€” load one first.",
 
 
962
  gr.update(),
963
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
964
  return
965
  base_history = list(history) + [{"role": "user", "content": message}]
966
  # A2: disable the composer + Send for the turn; keep the text visible.
@@ -971,15 +1148,27 @@ def build_app() -> tuple[Any, Any]:
971
  gr.update(interactive=False),
972
  "Thinking…",
973
  gr.update(interactive=False),
 
 
974
  )
975
  reply = ""
 
976
  try:
977
  async for ev in run_agent_turn_streaming(sid, selection, message):
978
  if isinstance(ev, TokenEvent):
979
  reply += ev.text
980
- streaming_history = base_history + [
981
- {"role": "assistant", "content": reply}
982
- ]
 
 
 
 
 
 
 
 
 
983
  yield (
984
  gr.update(),
985
  gr.update(),
@@ -987,15 +1176,23 @@ def build_app() -> tuple[Any, Any]:
987
  gr.update(),
988
  gr.update(),
989
  gr.update(),
 
 
990
  )
991
  elif isinstance(ev, ToolActivityEvent):
 
 
 
992
  yield (
993
  gr.update(),
994
  gr.update(),
995
- gr.update(),
 
996
  gr.update(),
997
  ev.message,
998
  gr.update(),
 
 
999
  )
1000
  elif isinstance(ev, FinalEvent):
1001
  reply = ev.assistant_message or reply
@@ -1005,6 +1202,8 @@ def build_app() -> tuple[Any, Any]:
1005
  prefix = f"{reply}\n\n" if reply else ""
1006
  reply = f"{prefix}⚠️ Some actions were rejected: {reasons}"
1007
  tool_receipts = _tool_receipt_messages(ev.tool_activity)
 
 
1008
  final_history = list(base_history)
1009
  if reply:
1010
  final_history.append(
@@ -1032,6 +1231,8 @@ def build_app() -> tuple[Any, Any]:
1032
  gr.update(value="", interactive=True),
1033
  "",
1034
  gr.update(interactive=True),
 
 
1035
  )
1036
  except Exception as exc: # noqa: BLE001
1037
  error_history = base_history + [
@@ -1045,6 +1246,8 @@ def build_app() -> tuple[Any, Any]:
1045
  gr.update(interactive=True),
1046
  "",
1047
  gr.update(interactive=True),
 
 
1048
  )
1049
 
1050
  async def on_select(
@@ -1204,6 +1407,126 @@ def build_app() -> tuple[Any, Any]:
1204
  pass
1205
  return {"kind": "none", "id": ""}, None, {}, gr.update(visible=False)
1206
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1207
  # Normal selection
1208
  selection: dict[str, Any] = {"kind": kind, "id": str(raw.get("id", ""))}
1209
  # T3-03: update focus history for node selections
@@ -1223,41 +1546,39 @@ def build_app() -> tuple[Any, Any]:
1223
  # real class object so the event-data parameter is detected.
1224
  on_select.__annotations__["evt"] = gr.SelectData
1225
 
1226
- # Manual reload via button
1227
- load_btn.click(
1228
- on_load_graph,
1229
- inputs=[fixture_dropdown],
1230
- outputs=[canvas, session_id, onboarding],
1231
- )
1232
-
1233
- # Deploy-only first-paint seed. Default OFF preserves the P0-01 blank-slate
1234
- # workspace; the public HF Space sets LOOSECANVAS_DEMO_SEED=1 so a visitor
1235
- # lands on a populated canvas (instant-magic) instead of an empty one. This
1236
- # is a narrowly-scoped, opt-in deviation from P0-01 for the demo deployment.
1237
- if os.environ.get("LOOSECANVAS_DEMO_SEED", "0") == "1":
1238
- demo.load(
1239
- on_load_graph,
1240
- inputs=[fixture_dropdown],
1241
- outputs=[canvas, session_id, onboarding],
1242
- )
1243
-
1244
- # Build a graph from pasted text via Gemma (M04b adapter).
1245
- build_btn.click(
1246
- on_build_text,
1247
- inputs=[text_content, text_title],
1248
- outputs=[canvas, session_id, chatbot, onboarding, status_md, text_content],
1249
- )
1250
 
1251
  turn_inputs = [msg, session_id, current_selection, chatbot]
 
 
 
 
1252
  turn_outputs = [canvas, inspector, chatbot, msg, status_md, send_btn]
1253
- msg.submit(on_turn, turn_inputs, turn_outputs)
1254
- send_btn.click(on_turn, turn_inputs, turn_outputs)
1255
-
 
 
 
 
 
 
1256
  async def on_undo(
1257
  sid: str, history: list[Any]
1258
- ) -> tuple[Any, Any, list[Any], str, str]:
1259
  if not sid:
1260
- return gr.update(), gr.update(), history, "", "No session"
 
 
 
 
 
 
 
1261
  result = await undo_turn(sid)
1262
  return (
1263
  result.renderer_patch.model_dump(mode="json"),
@@ -1265,13 +1586,21 @@ def build_app() -> tuple[Any, Any]:
1265
  history,
1266
  "",
1267
  result.status,
 
1268
  )
1269
 
1270
  async def on_redo(
1271
  sid: str, history: list[Any]
1272
- ) -> tuple[Any, Any, list[Any], str, str]:
1273
  if not sid:
1274
- return gr.update(), gr.update(), history, "", "No session"
 
 
 
 
 
 
 
1275
  result = await redo_turn(sid)
1276
  return (
1277
  result.renderer_patch.model_dump(mode="json"),
@@ -1279,6 +1608,7 @@ def build_app() -> tuple[Any, Any]:
1279
  history,
1280
  "",
1281
  result.status,
 
1282
  )
1283
 
1284
  undo_btn.click(on_undo, inputs=[session_id, chatbot], outputs=turn_outputs)
@@ -1296,18 +1626,64 @@ def build_app() -> tuple[Any, Any]:
1296
 
1297
  cluster_btn.click(on_cluster, inputs=[session_id], outputs=[canvas])
1298
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1299
  async def on_back(
1300
  sid: str, history: list[Any]
1301
- ) -> tuple[dict[str, Any] | None, Any, list[Any], str, str, list[Any]]:
1302
  """T3-03: navigate back in focus history via pop_focus."""
1303
  if not sid:
1304
- return None, None, history, "", "No session", []
1305
  record = session_store.get(sid)
1306
  if record is None:
1307
- return None, None, history, "", "No session", []
1308
  prev = pop_focus(record)
1309
  if prev is None:
1310
- return gr.update(), gr.update(), history, "", "no_back_history", []
 
 
 
 
 
 
 
 
1311
  record.patch_id += 1
1312
  patch = RendererPatch(
1313
  patch_id=record.patch_id,
@@ -1321,7 +1697,15 @@ def build_app() -> tuple[Any, Any]:
1321
  ],
1322
  )
1323
  insp = inspector_for_selection(sid, {"kind": "node", "id": prev})
1324
- return patch.model_dump(mode="json"), insp, history, "", "back", []
 
 
 
 
 
 
 
 
1325
 
1326
  search_box.submit(
1327
  on_search,
 
29
  TokenEvent,
30
  ToolActivityEvent,
31
  run_agent_turn_streaming,
32
+ stream_build_opener,
33
  )
34
  from loosecanvas.contracts import RendererCommand
35
  from loosecanvas.llm_client import LLMClient
 
41
  _commit_snapshot,
42
  apply_user_edge,
43
  apply_user_edit,
44
+ find_connection_session,
45
  inspector_for_selection,
46
  load_fixture_session,
47
  load_repo_session,
 
51
  push_focus,
52
  redo_turn,
53
  save_session,
54
+ session_locks,
55
  session_store,
 
56
  undo_turn,
57
  )
58
 
 
61
  _FIXTURES: dict[str, Path] = {
62
  "annotated_graph": _WORKSPACE_ROOT / "fixtures" / "annotated_graph.json",
63
  "small_graph": _WORKSPACE_ROOT / "fixtures" / "small_graph.json",
64
+ "seed_local_ai": _WORKSPACE_ROOT / "fixtures" / "seed_local_ai.json",
65
  }
66
+ # Open-world entry seeds this graph on page load (zero-LLM sync path) so a cold
67
+ # model never paints an empty canvas. on_seed falls back to annotated_graph until
68
+ # the baked hackathon-meta seed (seed_local_ai.json) exists, so this is safe to
69
+ # point at the baked seed before the file lands β€” it upgrades on the next restart.
70
+ _SEED_FIXTURE_NAME = "seed_local_ai"
71
+
72
+ # Open-world entry: the composer is PREFILLED with this editable seed text and the canvas
73
+ # starts empty. The first Send builds a graph FROM this text (on_turn's no-session branch
74
+ # runs Gemma extraction) β€” edit it and you get a different map; the non-determinism is the
75
+ # point. Kept short enough to extract fast and rich enough to yield a few linked concepts.
76
+ _SEED_TEXT = (
77
+ "Local AI puts the whole stack on your own machine: open-weight models like Gemma "
78
+ "run offline through llama.cpp, so your data never leaves the room. "
79
+ "Retrieval-augmented generation grounds answers in your own documents, agents call "
80
+ "tools to act on them, and fine-tuning adapts a base model to your domain. "
81
+ "The thread tying them together is sovereignty: you own the model, the data, and "
82
+ "the meaning you build from them."
83
+ )
84
+
85
+ # Friendly onboarding copy shown when seeding can't paint a starter map (e.g. every
86
+ # fixture is missing/corrupt). NEVER leaks 'session_cap' or a stack trace, and never
87
+ # leaves the "Building your starter map…" banner stuck over an empty canvas.
88
+ _SEED_FALLBACK_MESSAGE = (
89
+ "**loosecanvas** β€” the local AI proposes, you decide. "
90
+ "Your starter map isn't available right now, but you can begin: "
91
+ "tell me what you're trying to map and I'll start sketching it with you."
92
+ )
93
+
94
+
95
+ def _evict_lru_session() -> bool:
96
+ """Evict the single least-recently-seen session (smallest ``last_seen``).
97
+
98
+ Drops the record AND its paired lock together (mirrors ``_evict_stale_sessions``).
99
+ Returns ``True`` if a session was evicted, ``False`` if the store was already empty.
100
+
101
+ The alternate loaders (``load_fixture_session`` et al.) raise
102
+ ``OverflowError("session_cap")`` at capacity. The existing eviction is TTL-only, so
103
+ a store full of FRESH sessions hard-rejects a new visitor β€” bricking the open-world
104
+ seed-on-load entry. Evicting the LRU session here makes room so a fresh visitor
105
+ ALWAYS gets a seeded canvas (mirrors a classic LRU cache under pressure).
106
+ """
107
+ if not session_store:
108
+ return False
109
+ victim = min(session_store, key=lambda sid: session_store[sid].last_seen)
110
+ session_store.pop(victim, None)
111
+ session_locks.pop(victim, None)
112
+ return True
113
+
114
+
115
+ async def _seed_snapshot() -> tuple[Any, str, str]:
116
+ """Resolve the seed-on-load snapshot resiliently. Returns
117
+ ``(renderer_value, session_id, onboarding_message)``.
118
+
119
+ Resilience contract (adversarial-QA hardened):
120
+
121
+ * **Session cap** β€” on ``OverflowError("session_cap")`` evict the LRU session and
122
+ retry, so a busy HF Space always seeds a fresh visitor instead of leaving an empty
123
+ canvas + a leaked 'session_cap' toast.
124
+ * **Missing/corrupt seed** β€” fall back from the configured seed fixture to the
125
+ shipped ``annotated_graph`` on ANY load failure (not only ``not path.exists()``;
126
+ a partial/corrupt JSON now raises ``ValueError`` rather than ``KeyError``).
127
+ * **Total failure** β€” if nothing loads, return an empty-but-valid ``RendererPatch``
128
+ snapshot + a FRIENDLY onboarding message; never crash, never leak internals, never
129
+ leave the "Building…" banner stuck over a blank canvas.
130
+ """
131
+ # Candidate fixtures in preference order, de-duped (seed may equal the fallback).
132
+ candidates: list[Path] = []
133
+ for name in (_SEED_FIXTURE_NAME, "annotated_graph"):
134
+ path = _FIXTURES.get(name)
135
+ if path is not None and path not in candidates:
136
+ candidates.append(path)
137
+
138
+ for path in candidates:
139
+ if not path.exists():
140
+ continue
141
+ # One retry budget per fixture for the cap: evict LRU, then try again.
142
+ for attempt in range(2):
143
+ try:
144
+ sid, snapshot = await load_fixture_session(fixture_path=path)
145
+ return snapshot.model_dump(), sid, ""
146
+ except OverflowError:
147
+ # Session cap: make room and retry once. If nothing could be evicted
148
+ # (store somehow empty), stop retrying this fixture.
149
+ if attempt == 0 and _evict_lru_session():
150
+ continue
151
+ break
152
+ except Exception: # corrupt/partial fixture, path error, etc.
153
+ logger.exception("seed-on-load failed for fixture %s", path)
154
+ break # try the next candidate fixture
155
+
156
+ # Nothing loaded: empty-but-valid snapshot + friendly onboarding (no leaked cap).
157
+ logger.warning("seed-on-load exhausted all fixtures; serving empty starter canvas")
158
+ empty = RendererPatch(
159
+ patch_id=0, is_snapshot=True, scene_id="empty-seed", operations=[]
160
+ )
161
+ return empty.model_dump(), "", _SEED_FALLBACK_MESSAGE
162
+
163
 
164
  # Public-Space safety: cap pasted/loaded text so one request can't monopolize the
165
  # single (--parallel 1) GPU slot. ~100 KB is generous for an article paste; the
 
308
  return messages
309
 
310
 
311
+ def _tool_steplist_message(steps: list[str], status: str) -> dict[str, Any]:
312
+ """Return a Gradio collapsible thought-panel message showing live tool steps.
313
+
314
+ ``steps`` is the ordered list of friendly tool-activity labels accumulated so far.
315
+ ``status`` is ``"pending"`` (spinner, panel open mid-turn) or ``"done"`` (closed).
316
+ An empty ``steps`` list produces a placeholder bullet so the panel is never blank.
317
+ """
318
+ lines = [f"- {s}" for s in steps] if steps else ["- Working…"]
319
+ return {
320
+ "role": "assistant",
321
+ "content": "\n".join(lines),
322
+ "metadata": {"title": "Working…", "status": status},
323
+ }
324
+
325
+
326
  async def _search_nodes(session_id: str, query: str, limit: int = 50) -> dict[str, Any]:
327
  """Shared fog-aware search core. Pure-read; never mutates session state."""
328
  limit = max(0, min(limit, _SEARCH_LIMIT_CAP))
 
586
  padding: 0.5rem;
587
  opacity: 0.7;
588
  }
589
+ /* Consequence copy under the Export button: reads as quiet helper text, not a heading. */
590
+ .lc-export-note {
591
+ text-align: center;
592
+ opacity: 0.6;
593
+ font-size: 0.8rem;
594
+ margin-top: 0.15rem;
595
+ }
596
  /* ── P0-03: CSS-grid layout shell ──────────────────────────────────── */
597
  /* Expand the Gradio app container to full viewport width */
598
  .gradio-container {
 
607
  /* Gradio wraps custom components in a <span> β€” that span is the actual flex item,
608
  not #lc-canvas itself. Target it directly to defeat the 25%/75% default split. */
609
  #lc-canvas-row > span {
610
+ flex: 1 1 100% !important;
611
  min-width: 0 !important;
612
  }
613
+ /* Slim inspector rail (the LIGHT path β€” chat is now a co-equal hero below the canvas,
614
+ not in this rail). Height is a FIXED px value, never vh β€” see the canvas rule below
615
+ for why. overflow-y lets the rail scroll if its content is tall. */
616
  #lc-inspector-col {
617
+ flex: 0 0 26% !important;
618
  min-width: 0 !important;
619
  overflow-y: auto;
620
  max-height: 540px;
621
  }
622
+ /* Chat + composer are full-width heroes under the canvas. */
623
+ #lc-chat-row, #lc-composer-row { width: 100%; }
624
  /* Cap the inspector JSON so the chat below it stays on screen; it scrolls if a node
625
  has many claims. */
626
  #lc-inspector { max-height: 150px; overflow: auto; }
 
690
 
691
  # Trust legend. The SVG swatches MUST match renderer.ts's originβ†’shape rules
692
  # (round-rectangle/solid = deterministic, ellipse/solid-green = user-asserted,
693
+ # hexagon/dashed-indigo = model-inferred, + AMBER dashed border = awaiting-review)
694
+ # so the on-canvas encoding is decodable at a glance instead of only on hover.
695
+ # The awaiting-review swatch mirrors the .review-pending.origin-model-inferred rule
696
+ # (amber dashed border + gentle pulse) β€” NOT a corner dot (Cytoscape pies centre under
697
+ # the label and were invisible); keep this swatch in sync with that stylesheet rule.
698
  _LEGEND_HTML = """
699
  <div class="lc-legend" role="note" aria-label="Trust legend">
700
  <span class="lc-legend-title">Trust map</span>
 
722
  <span class="lc-legend-item">
723
  <svg class="lc-legend-svg" width="26" height="22" aria-hidden="true">
724
  <polygon points="13,2 22,7 22,15 13,20 4,15 4,7"
725
+ fill="#3b5bdb" stroke="#f59e0b" stroke-width="2.5" stroke-dasharray="3 2"/>
 
726
  </svg>
727
  Awaiting your review
728
  </span>
 
778
  )
779
  )
780
  record.scene.highlighted_ids = vis_ids
781
+ else:
782
+ # No visible matches β†’ clear any PRIOR search dim/highlight so a stale match
783
+ # from a previous query doesn't stay lit (QA: 'Ret' then a no-match term left
784
+ # the old highlight + dim stuck). Mirrors the empty-query reset above.
785
+ ops2.append(RendererCommand(op="clear_dim"))
786
+ for nid in record.scene.highlighted_ids:
787
+ ops2.append(
788
+ RendererCommand(
789
+ op="remove_class",
790
+ selector=f"#{nid}",
791
+ data={"class": "highlighted"},
792
+ )
793
+ )
794
+ record.scene.highlighted_ids = []
795
 
796
  record.patch_id += 1
797
  patch2 = RendererPatch(
 
925
  session_id = gr.State()
926
  current_selection = gr.State({"kind": "none", "id": ""})
927
 
928
+ # Open-world entry: no fixture picker, no "build from text" accordion. The
929
+ # canvas is seeded on load (demo.load -> on_seed) and the user shapes it by
930
+ # chatting β€” "chat in, spatial review out". (The text->graph path still lives
931
+ # in turn_logic.stream_text_session + the /api build route; just not in the UI.)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
932
 
933
  # P0-03 + whitespace reclaim: canvas (left) shares one row with a right rail
934
  # holding the inspector + chat, so the canvas and the conversation are on
 
936
  # dead band. CSS flex gives canvas ~64% / rail ~34% (defeating Gradio's
937
  # min-width collapse); Index.svelte's ResizeObserver re-fits the canvas.
938
  onboarding = gr.Markdown(
939
+ "**loosecanvas** β€” the local AI proposes, you decide. "
940
+ "✏️ The text below is a starting point β€” edit it however you like, then press "
941
+ "**Send** to grow a knowledge graph from it. Nothing is built until you do.",
942
  visible=True,
943
  elem_classes=["lc-onboarding"],
944
  )
945
  gr.HTML(_LEGEND_HTML)
946
+ # The graph is the hero; the inspector is a slim LIGHT-path rail beside it.
947
  with gr.Row(elem_id="lc-canvas-row"):
948
  canvas = CytoscapeCanvas(elem_id="lc-canvas")
949
+ with gr.Column(scale=1, elem_id="lc-inspector-col", visible=False):
950
  inspector = gr.JSON(label="Inspector", elem_id="lc-inspector")
951
  gr.Markdown(
952
  "*Select a node to see where each claim came from and whether "
 
959
  reject_btn = gr.Button("βœ— Reject", variant="secondary", scale=1)
960
  dispute_btn = gr.Button("? Dispute", variant="secondary", scale=1)
961
  claim_review_status = gr.Markdown("", visible=False)
962
+ # Chat is the second hero: a full-width row under the canvas (not tucked in the
963
+ # inspector rail), so "chat in, spatial review out" reads at a glance. The
964
+ # composer is PREFILLED with editable seed text β€” the first Send builds the map.
965
+ with gr.Row(elem_id="lc-chat-row"):
966
+ chatbot = gr.Chatbot(label="Assistant", height=360, elem_id="lc-chat")
967
+ with gr.Row(elem_id="lc-composer-row"):
968
+ msg = gr.Textbox(
969
+ value=_SEED_TEXT,
970
+ placeholder="Describe a topic, then press Send to grow a map…",
971
+ scale=8,
972
+ show_label=False,
973
+ # Multi-line so the seed paragraph is comfortably editable; Enter still
974
+ # submits (Shift+Enter for a newline).
975
+ lines=3,
976
+ max_lines=10,
977
+ autofocus=True,
978
+ )
979
+ send_btn = gr.Button("Send", variant="primary", scale=1)
980
  status_md = gr.Markdown(value="", elem_classes=["lc-turn-status"])
981
+ # Time-travel (undo/redo/back) + search are hidden for now β€” they don't fit the
982
+ # chat+graph paradigm. The handlers + wiring stay intact (flip visible=True to
983
+ # restore); see on_undo/on_redo/on_back and test_time_travel_output_arity.
984
  with gr.Row():
985
+ undo_btn = gr.Button("↩ Undo", scale=1, visible=False)
986
+ redo_btn = gr.Button("β†ͺ Redo", scale=1, visible=False)
987
  cluster_btn = gr.Button("🎨 Colour clusters", scale=1)
988
+ # On-demand "surprising knowledge": propose a cross-cluster link the
989
+ # structure implies but no edge yet states. Never auto-applied β€” it
990
+ # lands as an amber model proposal you accept or reject.
991
+ find_conn_btn = gr.Button("✨ Find a hidden connection", scale=1)
992
+ # Export is the trust-gate payoff: the exporter drops model_inferred
993
+ # claims you haven't accepted, so the file is exactly what you vouched for.
994
+ export_btn = gr.DownloadButton(
995
+ "⬇ Export map", scale=1, elem_classes=["lc-export-btn"]
996
+ )
997
+ gr.Markdown(
998
+ "*Export carries only the facts you've accepted β€” unreviewed AI guesses stay out.*",
999
+ elem_classes=["lc-export-note"],
1000
+ )
1001
+ with gr.Row(visible=False): # search/back hidden for now (kept wired)
1002
  search_box = gr.Textbox(
1003
  placeholder="Search graph nodes…",
1004
  scale=9,
 
1015
 
1016
  # ── Handlers ──────────────────────────────────────────────────────────
1017
 
1018
+ async def on_open() -> tuple[Any, str, Any]:
1019
+ """Open-world entry: paint an EMPTY canvas and leave the editable seed text in
1020
+ the composer. No graph is built until the user presses Send β€” then on_turn's
1021
+ no-session branch runs Gemma extraction on whatever text is there (the magic).
1022
+
1023
+ Returns ``(empty snapshot, empty session id, invitation banner)``. The empty
1024
+ session id routes the first Send into the build branch; seeing the graph
1025
+ appear from your own words is the moment that sells 'chat in, map out'."""
1026
+ empty = RendererPatch(
1027
+ patch_id=0,
1028
+ is_snapshot=True,
1029
+ scene_id="open-world-empty",
1030
+ operations=[],
1031
+ )
1032
+ return empty.model_dump(mode="json"), "", gr.update(visible=True)
1033
+
1034
+ async def on_turn(
1035
+ message: str, sid: str, selection: dict[str, Any], history: list[Any]
1036
+ ) -> AsyncGenerator[Any]:
1037
+ # Outputs: [canvas, inspector, chatbot, msg, status_md, send_btn,
1038
+ # session_id, onboarding]. The last two are set ONLY by the
1039
+ # no-session "magic build" branch below; normal turns pass them
1040
+ # through unchanged (the same sid + a no-op onboarding update).
1041
+ if not sid:
1042
+ # MAGIC BUILD: no graph yet β†’ the composer text IS the seed. Gemma
1043
+ # extracts a graph from it (non-deterministic β€” edit the text, get a
1044
+ # different map). Stream a growing-state, then the built snapshot + the
1045
+ # new session id, and retire the onboarding banner. The text→graph path
1046
+ # lives in turn_logic.stream_text_session (lazy-imported like the others).
1047
+ from loosecanvas.turn_logic import stream_text_session
1048
+
1049
+ if not message.strip():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1050
  yield (
1051
  gr.update(),
1052
  gr.update(),
1053
  gr.update(),
1054
  gr.update(),
1055
+ "✏️ Add or edit some text, then press Send to grow your map.",
1056
  gr.update(),
1057
+ sid,
 
 
 
 
 
 
 
1058
  gr.update(),
1059
  )
1060
+ return
1061
+ base_history = list(history) + [{"role": "user", "content": message}]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1062
  yield (
1063
  gr.update(),
1064
  gr.update(),
1065
+ base_history,
1066
+ gr.update(interactive=False),
1067
+ "🌱 Growing your map from your text…",
1068
+ gr.update(interactive=False),
1069
+ sid,
1070
  gr.update(),
1071
  )
1072
+ async for prog in stream_text_session(
1073
+ message, "Your text", LLMClient()
1074
+ ):
1075
+ if prog.is_partial:
1076
+ yield (
1077
+ gr.update(),
1078
+ gr.update(),
1079
+ gr.update(),
1080
+ gr.update(interactive=False),
1081
+ prog.status or "Calling Gemma…",
1082
+ gr.update(interactive=False),
1083
+ sid,
1084
+ gr.update(),
1085
+ )
1086
+ elif prog.is_error:
1087
+ # Build failed (e.g. LLM down) β†’ surface in chat, keep the text
1088
+ # for a retry, leave the session empty + the banner up.
1089
+ yield (
1090
+ gr.update(),
1091
+ gr.update(),
1092
+ base_history + list(prog.chat_message),
1093
+ gr.update(interactive=True),
1094
+ "",
1095
+ gr.update(interactive=True),
1096
+ sid,
1097
+ gr.update(),
1098
+ )
1099
+ else:
1100
+ # Success β†’ paint the built graph, adopt the new session id,
1101
+ # and retire the invitation banner FIRST (the map appears),
1102
+ # then stream a warm, agent-voiced opening line into the chat
1103
+ # so turn one reads like every later turn instead of a canned
1104
+ # summary. Keep the composer disabled until the opener finishes.
1105
+ new_sid = prog.session_id or sid
1106
+ yield (
1107
+ prog.canvas_patch or gr.update(),
1108
+ gr.update(),
1109
+ gr.update(),
1110
+ gr.update(interactive=False),
1111
+ "",
1112
+ gr.update(interactive=False),
1113
+ new_sid,
1114
+ gr.update(visible=False),
1115
+ )
1116
+ opener = ""
1117
+ async for tok in stream_build_opener(new_sid):
1118
+ opener += tok
1119
+ yield (
1120
+ gr.update(),
1121
+ gr.update(),
1122
+ base_history
1123
+ + [{"role": "assistant", "content": opener}],
1124
+ gr.update(interactive=False),
1125
+ gr.update(),
1126
+ gr.update(interactive=False),
1127
+ new_sid,
1128
+ gr.update(visible=False),
1129
+ )
1130
+ # Finalize turn one: clear the composer and re-enable Send.
1131
+ yield (
1132
+ gr.update(),
1133
+ gr.update(),
1134
+ base_history + [{"role": "assistant", "content": opener}],
1135
+ gr.update(value="", interactive=True),
1136
+ "",
1137
+ gr.update(interactive=True),
1138
+ new_sid,
1139
+ gr.update(visible=False),
1140
+ )
1141
  return
1142
  base_history = list(history) + [{"role": "user", "content": message}]
1143
  # A2: disable the composer + Send for the turn; keep the text visible.
 
1148
  gr.update(interactive=False),
1149
  "Thinking…",
1150
  gr.update(interactive=False),
1151
+ sid,
1152
+ gr.update(),
1153
  )
1154
  reply = ""
1155
+ step_labels: list[str] = []
1156
  try:
1157
  async for ev in run_agent_turn_streaming(sid, selection, message):
1158
  if isinstance(ev, TokenEvent):
1159
  reply += ev.text
1160
+ # Build streaming history: if steps are accumulating, keep the
1161
+ # live step-list bubble followed by the partial prose bubble so
1162
+ # both are visible mid-turn.
1163
+ if step_labels:
1164
+ streaming_history = list(base_history) + [
1165
+ _tool_steplist_message(step_labels, "pending"),
1166
+ {"role": "assistant", "content": reply},
1167
+ ]
1168
+ else:
1169
+ streaming_history = base_history + [
1170
+ {"role": "assistant", "content": reply}
1171
+ ]
1172
  yield (
1173
  gr.update(),
1174
  gr.update(),
 
1176
  gr.update(),
1177
  gr.update(),
1178
  gr.update(),
1179
+ sid,
1180
+ gr.update(),
1181
  )
1182
  elif isinstance(ev, ToolActivityEvent):
1183
+ # Accumulate the friendly step label and yield a live collapsible
1184
+ # step-list bubble in the chatbot; also update the status strip.
1185
+ step_labels.append(ev.message)
1186
  yield (
1187
  gr.update(),
1188
  gr.update(),
1189
+ list(base_history)
1190
+ + [_tool_steplist_message(step_labels, "pending")],
1191
  gr.update(),
1192
  ev.message,
1193
  gr.update(),
1194
+ sid,
1195
+ gr.update(),
1196
  )
1197
  elif isinstance(ev, FinalEvent):
1198
  reply = ev.assistant_message or reply
 
1202
  prefix = f"{reply}\n\n" if reply else ""
1203
  reply = f"{prefix}⚠️ Some actions were rejected: {reasons}"
1204
  tool_receipts = _tool_receipt_messages(ev.tool_activity)
1205
+ # Drop the pending step-list bubble: final history starts clean
1206
+ # from base_history (user message only) then appends prose + receipts.
1207
  final_history = list(base_history)
1208
  if reply:
1209
  final_history.append(
 
1231
  gr.update(value="", interactive=True),
1232
  "",
1233
  gr.update(interactive=True),
1234
+ sid,
1235
+ gr.update(),
1236
  )
1237
  except Exception as exc: # noqa: BLE001
1238
  error_history = base_history + [
 
1246
  gr.update(interactive=True),
1247
  "",
1248
  gr.update(interactive=True),
1249
+ sid,
1250
+ gr.update(),
1251
  )
1252
 
1253
  async def on_select(
 
1407
  pass
1408
  return {"kind": "none", "id": ""}, None, {}, gr.update(visible=False)
1409
 
1410
+ if kind == "review_edge" and sid:
1411
+ # B2 radial-cxtmenu: Accept/Reject fired on an EDGE β†’ transition the
1412
+ # edge's first actionable governing claim. Mirrors the kind=='review'
1413
+ # branch above. Payload: {kind:'review_edge', id:<edge_id>,
1414
+ # action:'accepted'|'rejected'|'disputed'}.
1415
+ from loosecanvas.claim_state_machine import InvalidTransitionError
1416
+ from loosecanvas.turn_logic import review_edge_claim
1417
+
1418
+ edge_id = str(raw.get("id", ""))
1419
+ action = str(raw.get("action", ""))
1420
+ if edge_id and action in {"accepted", "rejected", "disputed"}:
1421
+ try:
1422
+ review_result = review_edge_claim(sid, edge_id, action)
1423
+ except (KeyError, InvalidTransitionError):
1424
+ review_result = None
1425
+ if review_result is not None:
1426
+ sel = {"kind": "edge", "id": edge_id}
1427
+ has_claims = bool(
1428
+ review_result.inspector.get("governing_claims")
1429
+ if review_result.inspector is not None
1430
+ else []
1431
+ )
1432
+ return (
1433
+ sel,
1434
+ review_result.inspector,
1435
+ review_result.renderer_patch.model_dump(mode="json"),
1436
+ gr.update(visible=has_claims),
1437
+ )
1438
+ # Graceful no-op: refresh the edge inspector without mutating the graph.
1439
+ sel = {"kind": "edge", "id": edge_id}
1440
+ insp = inspector_for_selection(sid, sel)
1441
+ insp_claims = insp.get("governing_claims") if insp is not None else []
1442
+ return sel, insp, {}, gr.update(visible=bool(insp_claims))
1443
+
1444
+ if kind == "relabel_edge" and sid:
1445
+ # B2 radial-cxtmenu: "Edit edge label" β†’ overlay a user_asserted
1446
+ # label claim on the edge (no LLM round-trip). Mirrors the
1447
+ # kind=='rename' branch above.
1448
+ # Payload: {kind:'relabel_edge', id:<edge_id>, label:<new_label>}.
1449
+ from loosecanvas.turn_logic import apply_user_edge_edit
1450
+
1451
+ edge_id = str(raw.get("id", ""))
1452
+ new_label = str(raw.get("label", ""))
1453
+ if edge_id and new_label:
1454
+ try:
1455
+ result = await apply_user_edge_edit(
1456
+ sid, edge_id, label=new_label
1457
+ )
1458
+ sel = {"kind": "edge", "id": edge_id}
1459
+ return (
1460
+ sel,
1461
+ result.inspector_update,
1462
+ result.renderer_patch.model_dump(mode="json"),
1463
+ gr.update(),
1464
+ )
1465
+ except (KeyError, ValueError):
1466
+ pass
1467
+ sel = {"kind": "edge", "id": edge_id}
1468
+ return sel, inspector_for_selection(sid, sel), {}, gr.update()
1469
+
1470
+ if kind == "edit_summary" and sid:
1471
+ # Radial cxtmenu: "Edit summary" β†’ overlay a user_asserted summary
1472
+ # claim on the node (reuses the existing apply_user_edit path).
1473
+ node_id = str(raw.get("id", ""))
1474
+ text = str(raw.get("text", ""))
1475
+ if node_id and text:
1476
+ try:
1477
+ result = await apply_user_edit(sid, node_id, summary=text)
1478
+ sel = {"kind": "node", "id": node_id}
1479
+ return (
1480
+ sel,
1481
+ result.inspector_update,
1482
+ result.renderer_patch.model_dump(mode="json"),
1483
+ gr.update(),
1484
+ )
1485
+ except (KeyError, ValueError):
1486
+ pass
1487
+ sel = {"kind": "node", "id": node_id}
1488
+ return sel, inspector_for_selection(sid, sel), {}, gr.update()
1489
+
1490
+ if kind == "delete_node" and sid:
1491
+ # Radial cxtmenu node menu: "Delete node" (confirmed in-canvas before
1492
+ # this fires). Removal cascades incident edges and is undoable.
1493
+ from loosecanvas.turn_logic import remove_user_node
1494
+
1495
+ node_id = str(raw.get("id", ""))
1496
+ if node_id:
1497
+ try:
1498
+ node_result = await remove_user_node(sid, node_id)
1499
+ return (
1500
+ {"kind": "none", "id": ""},
1501
+ node_result.inspector_update,
1502
+ node_result.renderer_patch.model_dump(mode="json"),
1503
+ gr.update(visible=False),
1504
+ )
1505
+ except KeyError:
1506
+ pass
1507
+ return {"kind": "none", "id": ""}, None, {}, gr.update(visible=False)
1508
+
1509
+ if kind == "add_node" and sid:
1510
+ # Radial cxtmenu background menu: "Add node" β†’ mint a user_asserted
1511
+ # node on the open periphery and reveal it (no LLM round-trip).
1512
+ from loosecanvas.turn_logic import apply_user_node
1513
+
1514
+ label = str(raw.get("label", ""))
1515
+ if label:
1516
+ try:
1517
+ add_result = await apply_user_node(
1518
+ sid, label, str(raw.get("text", ""))
1519
+ )
1520
+ return (
1521
+ {"kind": "none", "id": ""},
1522
+ None,
1523
+ add_result.renderer_patch.model_dump(mode="json"),
1524
+ gr.update(visible=False),
1525
+ )
1526
+ except (KeyError, ValueError):
1527
+ pass
1528
+ return {"kind": "none", "id": ""}, None, {}, gr.update(visible=False)
1529
+
1530
  # Normal selection
1531
  selection: dict[str, Any] = {"kind": kind, "id": str(raw.get("id", ""))}
1532
  # T3-03: update focus history for node selections
 
1546
  # real class object so the event-data parameter is detected.
1547
  on_select.__annotations__["evt"] = gr.SelectData
1548
 
1549
+ # Open-world entry: always seed the canvas on page load via the zero-LLM
1550
+ # sync path. No fixture picker, no manual load/build buttons β€” the user
1551
+ # shapes the map by chatting. Seeding IS the entry now (no DEMO_SEED gate);
1552
+ # on_seed falls back to annotated_graph if the baked seed isn't present.
1553
+ demo.load(on_open, inputs=[], outputs=[canvas, session_id, onboarding])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1554
 
1555
  turn_inputs = [msg, session_id, current_selection, chatbot]
1556
+ # turn_outputs (6) is shared with on_undo/on_redo. on_turn needs two MORE
1557
+ # outputs β€” session_id (the magic build mints a new session) and onboarding
1558
+ # (retire the banner once a map exists) β€” so it gets its own wider list while
1559
+ # the time-travel handlers keep the 6-wide one untouched.
1560
  turn_outputs = [canvas, inspector, chatbot, msg, status_md, send_btn]
1561
+ turn_outputs_full = turn_outputs + [session_id, onboarding]
1562
+ msg.submit(on_turn, turn_inputs, turn_outputs_full)
1563
+ send_btn.click(on_turn, turn_inputs, turn_outputs_full)
1564
+
1565
+ # NB: these are wired to ``turn_outputs`` (6 components, incl. send_btn). EVERY
1566
+ # return must supply all 6 values or Gradio raises "didn't return enough output
1567
+ # values" the moment the button is pressed β€” the live time-travel bug the unit
1568
+ # tests (which call undo_turn/redo_turn directly) never exercised. The 6th slot
1569
+ # is send_btn; keep Send enabled after a time-travel action, like on_turn does.
1570
  async def on_undo(
1571
  sid: str, history: list[Any]
1572
+ ) -> tuple[Any, Any, list[Any], str, str, Any]:
1573
  if not sid:
1574
+ return (
1575
+ gr.update(),
1576
+ gr.update(),
1577
+ history,
1578
+ "",
1579
+ "No session",
1580
+ gr.update(interactive=True),
1581
+ )
1582
  result = await undo_turn(sid)
1583
  return (
1584
  result.renderer_patch.model_dump(mode="json"),
 
1586
  history,
1587
  "",
1588
  result.status,
1589
+ gr.update(interactive=True),
1590
  )
1591
 
1592
  async def on_redo(
1593
  sid: str, history: list[Any]
1594
+ ) -> tuple[Any, Any, list[Any], str, str, Any]:
1595
  if not sid:
1596
+ return (
1597
+ gr.update(),
1598
+ gr.update(),
1599
+ history,
1600
+ "",
1601
+ "No session",
1602
+ gr.update(interactive=True),
1603
+ )
1604
  result = await redo_turn(sid)
1605
  return (
1606
  result.renderer_patch.model_dump(mode="json"),
 
1608
  history,
1609
  "",
1610
  result.status,
1611
+ gr.update(interactive=True),
1612
  )
1613
 
1614
  undo_btn.click(on_undo, inputs=[session_id, chatbot], outputs=turn_outputs)
 
1626
 
1627
  cluster_btn.click(on_cluster, inputs=[session_id], outputs=[canvas])
1628
 
1629
+ async def on_find_connection(sid: str) -> Any:
1630
+ """C14/C15: surface a surprising cross-cluster link as a model proposal
1631
+ (amber, awaiting review). No-op (no canvas change) if none is found."""
1632
+ if not sid:
1633
+ return {}
1634
+ patch = await find_connection_session(sid)
1635
+ return {} if patch is None else patch.model_dump(mode="json")
1636
+
1637
+ find_conn_btn.click(on_find_connection, inputs=[session_id], outputs=[canvas])
1638
+
1639
+ async def on_export(sid: str) -> Any:
1640
+ """Export the map as JSON. Trust gate: ``export_session`` drops any
1641
+ model_inferred claim you haven't accepted, so the downloaded file is
1642
+ exactly the knowledge you've vouched for β€” the review loop made provable.
1643
+ """
1644
+ if not sid:
1645
+ return gr.update()
1646
+ record = session_store.get(sid)
1647
+ if record is None:
1648
+ return gr.update()
1649
+ import json
1650
+ import tempfile
1651
+
1652
+ from loosecanvas.exporter import export_session
1653
+
1654
+ data = await export_session(record.graph, session_id=sid)
1655
+ out_path = Path(tempfile.gettempdir()) / f"loosecanvas-map-{sid[:8]}.json"
1656
+ out_path.write_text(
1657
+ json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
1658
+ )
1659
+ return str(out_path)
1660
+
1661
+ export_btn.click(on_export, inputs=[session_id], outputs=[export_btn])
1662
+
1663
+ # Wired to ``turn_outputs + [search_results_display]`` (7 components). The 6th
1664
+ # slot is send_btn (keep Send enabled), the 7th is the search results β€” every
1665
+ # return must supply all 7 (see the on_undo note for why the unit tests missed
1666
+ # the original 6-value returns that errored live).
1667
  async def on_back(
1668
  sid: str, history: list[Any]
1669
+ ) -> tuple[dict[str, Any] | None, Any, list[Any], str, str, Any, list[Any]]:
1670
  """T3-03: navigate back in focus history via pop_focus."""
1671
  if not sid:
1672
+ return None, None, history, "", "No session", gr.update(), []
1673
  record = session_store.get(sid)
1674
  if record is None:
1675
+ return None, None, history, "", "No session", gr.update(), []
1676
  prev = pop_focus(record)
1677
  if prev is None:
1678
+ return (
1679
+ gr.update(),
1680
+ gr.update(),
1681
+ history,
1682
+ "",
1683
+ "no_back_history",
1684
+ gr.update(),
1685
+ [],
1686
+ )
1687
  record.patch_id += 1
1688
  patch = RendererPatch(
1689
  patch_id=record.patch_id,
 
1697
  ],
1698
  )
1699
  insp = inspector_for_selection(sid, {"kind": "node", "id": prev})
1700
+ return (
1701
+ patch.model_dump(mode="json"),
1702
+ insp,
1703
+ history,
1704
+ "",
1705
+ "back",
1706
+ gr.update(interactive=True),
1707
+ [],
1708
+ )
1709
 
1710
  search_box.submit(
1711
  on_search,
src/loosecanvas/reducer.py CHANGED
@@ -126,6 +126,11 @@ def reduce_turn(
126
  graph_patch.upsert_claims.append(
127
  _user_overlay_claim(action, "summary", action.note)
128
  )
 
 
 
 
 
129
  elif action.type is SceneActionType.create_node:
130
  node = _model_node(action)
131
  graph_patch.upsert_nodes.append(node)
@@ -134,10 +139,13 @@ def reduce_turn(
134
  if node.id not in scene_patch.add_visible_node_ids:
135
  scene_patch.add_visible_node_ids.append(node.id)
136
  elif action.type is SceneActionType.create_edge:
137
- edge = _user_edge(action.source_id, action.target_id, action.label)
138
- claim = _user_edge_claim(
139
- edge.id, action.source_id, action.target_id, action.label
140
- )
 
 
 
141
  graph_patch.upsert_edges.append(edge)
142
  graph_patch.upsert_claims.append(claim)
143
  # Reveal the edge if both endpoints are visible (incl. same-batch new nodes).
@@ -306,6 +314,24 @@ def _user_overlay_claim(action: SceneAction, claim_type: str, text: str) -> Clai
306
  )
307
 
308
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  def _model_node(action: SceneAction) -> Node:
310
  """A model_inferred concept node proposed by the agent (the model is proposing)."""
311
  return Node(
@@ -321,7 +347,8 @@ def _model_node_claim(node_id: str, label: str) -> Claim:
321
  """Governing claim for an agent-created concept node (unverified + pending review).
322
 
323
  Trust stays unflattened: the model proposing a node is ``model_inferred`` /
324
- ``unverified`` / ``pending`` β€” distinct from a user_asserted create_edge.
 
325
  """
326
  return Claim(
327
  id=_stable_id("claim", "concept_node", node_id, label),
@@ -334,6 +361,36 @@ def _model_node_claim(node_id: str, label: str) -> Claim:
334
  )
335
 
336
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
  def _user_edge(source_id: str, target_id: str, label: str) -> Edge:
338
  """A user_asserted edge (accepted-by-construction)."""
339
  return Edge(
 
126
  graph_patch.upsert_claims.append(
127
  _user_overlay_claim(action, "summary", action.note)
128
  )
129
+ elif action.type is SceneActionType.update_edge:
130
+ # Relabel an existing edge via a user_asserted overlay claim β€” never mutate
131
+ # edge.label in place. effective_edge_label resolves the overlay for both
132
+ # rendering and export, exactly like update_node does for node labels.
133
+ graph_patch.upsert_claims.append(_user_edge_label_claim(action))
134
  elif action.type is SceneActionType.create_node:
135
  node = _model_node(action)
136
  graph_patch.upsert_nodes.append(node)
 
139
  if node.id not in scene_patch.add_visible_node_ids:
140
  scene_patch.add_visible_node_ids.append(node.id)
141
  elif action.type is SceneActionType.create_edge:
142
+ # The AGENT is proposing a relationship β†’ model_inferred + pending, exactly
143
+ # like create_node. (Direct user-UI connects go through apply_user_edge,
144
+ # which stays user_asserted.) Stamping an agent edge user_asserted/accepted
145
+ # flattened trust β€” the model-invented relationship passed as user-vouched
146
+ # on export (plan/06 forbids this collapse).
147
+ edge = _model_edge(action.source_id, action.target_id, action.label)
148
+ claim = _model_edge_claim(edge.id, action.label)
149
  graph_patch.upsert_edges.append(edge)
150
  graph_patch.upsert_claims.append(claim)
151
  # Reveal the edge if both endpoints are visible (incl. same-batch new nodes).
 
314
  )
315
 
316
 
317
+ def _user_edge_label_claim(action: SceneAction) -> Claim:
318
+ """A user_asserted overlay claim relabeling an existing edge (edge.label preserved).
319
+
320
+ The ``"useredit"`` discriminator in the stable id keeps this distinct from a
321
+ model-inferred ``propose_edge_label`` claim on the same edge with the same text,
322
+ so a user edit and a model proposal never collide on a single claim id.
323
+ """
324
+ return Claim(
325
+ id=_stable_id("claim", "label", "useredit", action.edge_id, action.label),
326
+ claim_type="label",
327
+ target_id=action.edge_id,
328
+ text=action.label,
329
+ origin=_USER_ORIGIN,
330
+ support_state="unverified",
331
+ review_state="accepted",
332
+ )
333
+
334
+
335
  def _model_node(action: SceneAction) -> Node:
336
  """A model_inferred concept node proposed by the agent (the model is proposing)."""
337
  return Node(
 
347
  """Governing claim for an agent-created concept node (unverified + pending review).
348
 
349
  Trust stays unflattened: the model proposing a node is ``model_inferred`` /
350
+ ``unverified`` / ``pending`` β€” like an agent-proposed edge (see _model_edge_claim)
351
+ and unlike a direct user_asserted edge (apply_user_edge).
352
  """
353
  return Claim(
354
  id=_stable_id("claim", "concept_node", node_id, label),
 
361
  )
362
 
363
 
364
+ def _model_edge(source_id: str, target_id: str, label: str) -> Edge:
365
+ """A model_inferred edge proposed by the agent (the model proposes a relationship)."""
366
+ return Edge(
367
+ id=_stable_id("modeledge", source_id, target_id, label),
368
+ source=source_id,
369
+ target=target_id,
370
+ type="related",
371
+ label=label,
372
+ properties={"origin": _MODEL_ORIGIN},
373
+ )
374
+
375
+
376
+ def _model_edge_claim(edge_id: str, label: str) -> Claim:
377
+ """Governing claim for an agent-created edge: model_inferred / unverified / pending.
378
+
379
+ Keeps trust unflattened (plan/06) β€” the model proposing a relationship is a
380
+ reviewable proposal awaiting the user's decision, NOT a user-vouched fact.
381
+ Mirrors :func:`_model_node_claim`.
382
+ """
383
+ return Claim(
384
+ id=_stable_id("claim", "relationship", edge_id, label),
385
+ claim_type="relationship",
386
+ target_id=edge_id,
387
+ text=label,
388
+ origin=_MODEL_ORIGIN,
389
+ support_state="unverified",
390
+ review_state="pending",
391
+ )
392
+
393
+
394
  def _user_edge(source_id: str, target_id: str, label: str) -> Edge:
395
  """A user_asserted edge (accepted-by-construction)."""
396
  return Edge(
src/loosecanvas/scene_curator.py CHANGED
@@ -77,6 +77,53 @@ def compute_node_clusters(repo: LooseGraphRepository) -> dict[str, int]:
77
  return mapping
78
 
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  class SceneCurator:
81
  """Ranks a ``LooseGraph`` into an initial scene and suggested pathways (M12)."""
82
 
@@ -86,9 +133,18 @@ class SceneCurator:
86
  # ── Public API ────────────────────────────────────────────────────────────
87
 
88
  def curate_initial_scene(
89
- self, max_visible: int = 12, max_fog: int = 25
90
  ) -> tuple[SceneState, list[SuggestedPathway]]:
91
- """Return ``(scene, pathways)`` curated from the repository's whole graph."""
 
 
 
 
 
 
 
 
 
92
  graph = self.repo.to_networkx()
93
  if len(graph) == 0:
94
  return SceneState(scene_id=_new_scene_id()), []
@@ -96,7 +152,7 @@ class SceneCurator:
96
  undirected = graph.to_undirected()
97
  composite, pagerank = self._score(graph, undirected)
98
 
99
- visible = self._select_visible(undirected, composite, max_visible)
100
  fog = self._select_fog(undirected, visible, composite, max_fog)
101
  positions = self._layout(graph, visible)
102
 
@@ -201,9 +257,20 @@ class SceneCurator:
201
  # ── Visible selection ─────────────────────────────────────────────────────
202
 
203
  def _select_visible(
204
- self, undirected: nx.Graph, composite: dict[str, float], max_visible: int
 
 
 
 
205
  ) -> list[str]:
206
- """Force eligible articulation points first, then fill by composite score."""
 
 
 
 
 
 
 
207
  visible: list[str] = []
208
 
209
  articulation = [
@@ -229,6 +296,17 @@ class SceneCurator:
229
  if node_id not in visible:
230
  visible.append(node_id)
231
 
 
 
 
 
 
 
 
 
 
 
 
232
  return visible[:max_visible]
233
 
234
  # ── Fog selection ─────────────────────────────────────────────────────────
 
77
  return mapping
78
 
79
 
80
+ def find_bridge_candidate(
81
+ repo: LooseGraphRepository,
82
+ clusters: dict[str, int],
83
+ visible_ids: list[str],
84
+ ) -> tuple[str, str] | None:
85
+ """Pick a SURPRISING cross-cluster link to propose ("find a hidden connection").
86
+
87
+ Returns ``(src, tgt)`` for two **visible** nodes that sit in *different*
88
+ communities, are **not already directly connected**, and form the most
89
+ structurally interesting bridge β€” high combined betweenness endpoints joined by
90
+ a short path (a near-miss structural hole). Returns ``None`` when no such pair
91
+ exists (single cluster, every cross pair already linked, or fewer than two
92
+ visible nodes) so the caller can no-op gracefully.
93
+
94
+ Deterministic: betweenness uses a fixed pivot ``seed`` so the same graph always
95
+ surfaces the same connection. Endpoints are restricted to visible nodes because
96
+ the validator requires both endpoints visible for a hypothesis edge.
97
+ """
98
+ undirected = repo.to_networkx().to_undirected()
99
+ if undirected.number_of_nodes() < 2:
100
+ return None
101
+ btw = nx.betweenness_centrality(
102
+ undirected,
103
+ k=min(_BETWEENNESS_PIVOTS, undirected.number_of_nodes()),
104
+ seed=42,
105
+ )
106
+ visible = [n for n in visible_ids if n in undirected]
107
+ best: tuple[float, str, str] | None = None
108
+ for i, a in enumerate(visible):
109
+ for b in visible[i + 1 :]:
110
+ if clusters.get(a) == clusters.get(b):
111
+ continue # same community β†’ not a cross-cluster surprise
112
+ if undirected.has_edge(a, b):
113
+ continue # already directly linked β†’ not hidden
114
+ try:
115
+ dist = nx.shortest_path_length(undirected, a, b)
116
+ except nx.NetworkXNoPath:
117
+ continue
118
+ # Prefer high-betweenness endpoints joined by a short bridge.
119
+ score = (btw.get(a, 0.0) + btw.get(b, 0.0)) - 0.01 * dist
120
+ if best is None or score > best[0]:
121
+ best = (score, a, b)
122
+ if best is None:
123
+ return None
124
+ return best[1], best[2]
125
+
126
+
127
  class SceneCurator:
128
  """Ranks a ``LooseGraph`` into an initial scene and suggested pathways (M12)."""
129
 
 
133
  # ── Public API ────────────────────────────────────────────────────────────
134
 
135
  def curate_initial_scene(
136
+ self, max_visible: int = 12, max_fog: int = 25, min_visible: int = 8
137
  ) -> tuple[SceneState, list[SuggestedPathway]]:
138
+ """Return ``(scene, pathways)`` curated from the repository's whole graph.
139
+
140
+ ``min_visible`` sets a floor: if fewer than ``min_visible`` nodes are
141
+ selected by the scoring pass, eligible nodes (passing ``_is_visible_excluded``)
142
+ are promoted in stable composite-score order until ``min(min_visible,
143
+ eligible_count)`` are visible. The floor is capped at ``max_visible``.
144
+ """
145
+ assert (
146
+ min_visible <= max_visible
147
+ ), f"min_visible ({min_visible}) must not exceed max_visible ({max_visible})"
148
  graph = self.repo.to_networkx()
149
  if len(graph) == 0:
150
  return SceneState(scene_id=_new_scene_id()), []
 
152
  undirected = graph.to_undirected()
153
  composite, pagerank = self._score(graph, undirected)
154
 
155
+ visible = self._select_visible(undirected, composite, max_visible, min_visible)
156
  fog = self._select_fog(undirected, visible, composite, max_fog)
157
  positions = self._layout(graph, visible)
158
 
 
257
  # ── Visible selection ─────────────────────────────────────────────────────
258
 
259
  def _select_visible(
260
+ self,
261
+ undirected: nx.Graph,
262
+ composite: dict[str, float],
263
+ max_visible: int,
264
+ min_visible: int = 0,
265
  ) -> list[str]:
266
+ """Force eligible articulation points first, then fill by composite score.
267
+
268
+ After the normal scoring pass, if ``len(visible) < min_visible`` and there are
269
+ still unused eligible nodes, those are promoted in stable composite-score order
270
+ (ties broken by node id) until ``min(min_visible, eligible_count)`` are visible.
271
+ Excluded nodes (``_is_visible_excluded``) are never promoted regardless of the
272
+ floor. The result never exceeds ``max_visible``.
273
+ """
274
  visible: list[str] = []
275
 
276
  articulation = [
 
296
  if node_id not in visible:
297
  visible.append(node_id)
298
 
299
+ # min_visible floor: promote remaining eligible nodes if we are under the floor.
300
+ if min_visible > 0 and len(visible) < min_visible:
301
+ visible_set = set(visible)
302
+ # eligible is already sorted stably; iterate in that order to keep determinism.
303
+ for node_id in eligible:
304
+ if len(visible) >= min(min_visible, len(eligible)):
305
+ break
306
+ if node_id not in visible_set:
307
+ visible.append(node_id)
308
+ visible_set.add(node_id)
309
+
310
  return visible[:max_visible]
311
 
312
  # ── Fog selection ─────────────────────────────────────────────────────────
src/loosecanvas/turn_logic.py CHANGED
@@ -142,6 +142,18 @@ def effective_node_summary(node: Node, claims: Iterable[Claim]) -> str:
142
  return overlay.text if overlay is not None else ""
143
 
144
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  def _latest_user_claim(
146
  claims: Iterable[Claim], node_id: str, *, claim_type: str
147
  ) -> Claim | None:
@@ -442,7 +454,10 @@ def _restore(record: SessionRecord, snap: GraphSnapshot, *, status: str) -> Turn
442
  nid: effective_node_label(n, record.graph.claims.values())
443
  for nid, n in record.graph.nodes.items()
444
  }
445
- edge_label_map = {eid: e.label for eid, e in record.graph.edges.items()}
 
 
 
446
  edge_kind_map = {eid: e.kind for eid, e in record.graph.edges.items()}
447
  operations = diff_scene(
448
  prev_scene,
@@ -637,7 +652,10 @@ async def load_session(
637
  nid: effective_node_label(n, repo.claims.values())
638
  for nid, n in repo.nodes.items()
639
  }
640
- edge_label_map = {eid: e.label for eid, e in repo.edges.items()}
 
 
 
641
  edge_kind_map = {eid: e.kind for eid, e in repo.edges.items()}
642
  ops = diff_scene(
643
  SceneState(),
@@ -713,7 +731,10 @@ async def load_fixture_session(
713
  nid: effective_node_label(n, repo.claims.values())
714
  for nid, n in repo.nodes.items()
715
  }
716
- edge_label_map = {eid: e.label for eid, e in repo.edges.items()}
 
 
 
717
  edge_kind_map = {eid: e.kind for eid, e in repo.edges.items()}
718
  ops = diff_scene(
719
  SceneState(),
@@ -789,7 +810,10 @@ async def load_text_session(
789
  nid: effective_node_label(n, repo.claims.values())
790
  for nid, n in repo.nodes.items()
791
  }
792
- edge_label_map = {eid: e.label for eid, e in repo.edges.items()}
 
 
 
793
  edge_kind_map = {eid: e.kind for eid, e in repo.edges.items()}
794
  ops = diff_scene(
795
  SceneState(),
@@ -851,24 +875,16 @@ async def stream_text_session(
851
  is_error=True,
852
  )
853
  return
854
- record = session_store[sid]
855
- n_nodes = len(record.graph.nodes)
856
- n_edges = len(record.graph.edges)
 
857
  yield TextBuildProgress(
858
  is_partial=False,
859
  status="",
860
  canvas_patch=snapshot.model_dump(mode="json"),
861
  session_id=sid,
862
- chat_message=[
863
- {
864
- "role": "assistant",
865
- "content": (
866
- f"Built a graph from your text: {n_nodes} concepts, "
867
- f"{n_edges} relations. Everything here is model-inferred β€” "
868
- "ask me to refine it."
869
- ),
870
- }
871
- ],
872
  )
873
 
874
 
@@ -927,7 +943,10 @@ async def load_repo_session(
927
  nid: effective_node_label(n, repo.claims.values())
928
  for nid, n in repo.nodes.items()
929
  }
930
- edge_label_map = {eid: e.label for eid, e in repo.edges.items()}
 
 
 
931
  edge_kind_map = {eid: e.kind for eid, e in repo.edges.items()}
932
  ops = diff_scene(
933
  SceneState(),
@@ -1199,6 +1218,73 @@ async def apply_user_edit(
1199
  )
1200
 
1201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1202
  async def apply_user_edge(
1203
  session_id: str,
1204
  source_id: str,
@@ -1310,6 +1396,144 @@ async def remove_user_edge(session_id: str, edge_id: str) -> TurnResult:
1310
  )
1311
 
1312
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1313
  # ── Intent classification (pure; routes navigate vs. expand) ──────────────────
1314
 
1315
  _EXPAND_INTENT_RE = re.compile(
@@ -1770,11 +1994,15 @@ def _finalize_turn(
1770
  status: str,
1771
  warnings: list[str],
1772
  selection: dict[str, Any],
 
1773
  ) -> TurnResult:
1774
  """Replenish fog, lay out reveals, diff to a RendererPatch, and persist.
1775
 
1776
  Shared by ``_navigate_turn`` and ``_expand_turn`` so both paths produce
1777
- identical render/camera/lineage behaviour.
 
 
 
1778
  """
1779
  if revealed_ids:
1780
  replenished = record.graph.fog_replenish(new_scene, max_fog=_MAX_FOG)
@@ -1791,7 +2019,10 @@ def _finalize_turn(
1791
  nid: effective_node_label(n, record.graph.claims.values())
1792
  for nid, n in record.graph.nodes.items()
1793
  }
1794
- edge_label_map = {eid: e.label for eid, e in record.graph.edges.items()}
 
 
 
1795
  edge_kind_map = {eid: e.kind for eid, e in record.graph.edges.items()}
1796
  operations = diff_scene(
1797
  prev_scene,
@@ -1805,6 +2036,42 @@ def _finalize_turn(
1805
  )
1806
  if operations:
1807
  new_scene = new_scene.model_copy(update={"scene_id": str(uuid.uuid4())})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1808
  # After any reveal, re-fit the camera so newly-placed nodes are in view.
1809
  if revealed_ids and new_scene.visible_node_ids:
1810
  operations.append(
@@ -2008,22 +2275,9 @@ def _aggregate_trust(governing: list[Claim]) -> _TrustSummary:
2008
  }
2009
 
2010
 
2011
- def _inspector_update(
2012
- record: SessionRecord, selection: dict[str, Any]
2013
- ) -> InspectorPayload | None:
2014
- """Inspector payload: ``None`` unless a known node is selected."""
2015
- if selection.get("kind") != "node":
2016
- return None
2017
- node_id = selection.get("id")
2018
- if not node_id:
2019
- return None
2020
- node = record.graph.get_node(node_id)
2021
- if node is None:
2022
- return None
2023
- effective = effective_node_label(node, record.graph.claims.values())
2024
- # --- governing claims: all claims targeting this node ---
2025
- governing = [c for c in record.graph.claims.values() if c.target_id == node_id]
2026
- governing_claims: list[_ClaimSummary] = [
2027
  {
2028
  "id": c.id,
2029
  "claim_type": c.claim_type,
@@ -2032,20 +2286,56 @@ def _inspector_update(
2032
  "support_state": c.support_state,
2033
  "review_state": c.review_state,
2034
  }
2035
- for c in governing
2036
  ]
2037
- # --- trust aggregation: pick the most informative governing claim ---
2038
- trust = _aggregate_trust(governing)
2039
- return {
2040
- "selected_id": node.id,
2041
- "label": effective,
2042
- "original_label": node.label,
2043
- "kind": node.kind,
2044
- "edited": effective != node.label,
2045
- "trust": trust,
2046
- "governing_claims": governing_claims,
2047
- "evidence_count": len(node.evidence_refs),
2048
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2049
 
2050
 
2051
  def inspector_for_selection(
@@ -2086,19 +2376,66 @@ class ReviewClaimResult:
2086
  renderer_patch: RendererPatch # proper patch for Gradio β†’ CytoscapeCanvas
2087
 
2088
 
2089
- def _review_canvas_hint_dict(claim: Any) -> dict[str, Any]:
2090
- """Produce the canvas-hint dict for a just-reviewed claim."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2091
  if claim.review_state == "accepted":
2092
- return {"action": "clear_pending", "target_id": claim.target_id}
 
 
2093
  if claim.review_state == "rejected":
 
 
 
 
 
 
 
2094
  return {"action": "remove", "target_id": claim.target_id}
2095
  return {"action": "none", "target_id": claim.target_id}
2096
 
2097
 
2098
  def _canvas_hint_to_renderer_patch(
2099
- record: SessionRecord, hint: dict[str, Any]
 
 
2100
  ) -> RendererPatch:
2101
- """Convert a canvas-hint dict to a proper RendererPatch for the Gradio layer."""
 
 
 
 
 
 
 
2102
  ops: list[RendererCommand] = []
2103
  action = hint.get("action", "none")
2104
  target_id = hint.get("target_id", "")
@@ -2107,9 +2444,22 @@ def _canvas_hint_to_renderer_patch(
2107
  RendererCommand(
2108
  op="remove_class",
2109
  selector=f"#{target_id}",
2110
- data={"class": "pending-review"},
 
 
 
 
2111
  )
2112
  )
 
 
 
 
 
 
 
 
 
2113
  elif action == "remove":
2114
  ops.append(RendererCommand(op="remove_element", selector=f"#{target_id}"))
2115
  record.patch_id += 1
@@ -2140,11 +2490,17 @@ def do_review_claim(
2140
  record.graph.upsert_claims([updated])
2141
  record.graph_version += 1
2142
  # T3-01: emit MapEvent(review, before=claim.review_state, after=updated.review_state)
2143
- hint = _review_canvas_hint_dict(updated)
2144
- patch = _canvas_hint_to_renderer_patch(record, hint)
2145
- insp = inspector_for_selection(
2146
- session_id, {"kind": "node", "id": updated.target_id}
 
 
2147
  )
 
 
 
 
2148
  return ReviewClaimResult(
2149
  review_state=updated.review_state,
2150
  graph_version=record.graph_version,
@@ -2196,6 +2552,44 @@ def review_node_claim(
2196
  return do_review_claim(session_id, preferred[0].id, state)
2197
 
2198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2199
  def focus_node(session_id: str, node_id: str) -> RendererPatch | None:
2200
  """Build a transient *focus* patch for a node: dim everything except the node
2201
  and its currently-visible neighbours, then animate the camera to the node.
@@ -2292,7 +2686,10 @@ def expand_node(
2292
  nid: effective_node_label(n, record.graph.claims.values())
2293
  for nid, n in record.graph.nodes.items()
2294
  }
2295
- edge_label_map = {eid: e.label for eid, e in record.graph.edges.items()}
 
 
 
2296
  edge_kind_map = {eid: e.kind for eid, e in record.graph.edges.items()}
2297
  ops = diff_scene(
2298
  old_scene,
@@ -2323,6 +2720,145 @@ def expand_node(
2323
  return patch
2324
 
2325
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2326
  # ── Agent-turn pipeline (called by agent_tools.finalize_agent_turn) ───────────
2327
 
2328
  # The agent composes a turn from many individually-validated tool calls; the legacy
@@ -2362,6 +2898,13 @@ def apply_agent_actions(
2362
  )
2363
  record.graph.apply_graph_patch(graph_patch)
2364
  new_scene = record.graph.apply_scene_patch(record.scene, scene_patch)
 
 
 
 
 
 
 
2365
  return _finalize_turn(
2366
  record,
2367
  prev_scene=record.scene,
@@ -2373,4 +2916,5 @@ def apply_agent_actions(
2373
  status=_status(result),
2374
  warnings=result.rejection_reasons,
2375
  selection=selection,
 
2376
  )
 
142
  return overlay.text if overlay is not None else ""
143
 
144
 
145
+ def effective_edge_label(edge: Edge, claims: Iterable[Claim]) -> str:
146
+ """Displayed edge label: newest user_asserted label claim, else edge.label.
147
+
148
+ The edge analogue of :func:`effective_node_label` β€” a user relabel (from the
149
+ agent's ``update_edge`` tool or the inline edge-edit UI) is stored as a
150
+ user_asserted overlay claim targeting the edge id; the raw ``edge.label`` is
151
+ preserved underneath so provenance is never destroyed.
152
+ """
153
+ overlay = _latest_user_claim(claims, edge.id, claim_type="label")
154
+ return overlay.text if overlay is not None else edge.label
155
+
156
+
157
  def _latest_user_claim(
158
  claims: Iterable[Claim], node_id: str, *, claim_type: str
159
  ) -> Claim | None:
 
454
  nid: effective_node_label(n, record.graph.claims.values())
455
  for nid, n in record.graph.nodes.items()
456
  }
457
+ edge_label_map = {
458
+ eid: effective_edge_label(e, record.graph.claims.values())
459
+ for eid, e in record.graph.edges.items()
460
+ }
461
  edge_kind_map = {eid: e.kind for eid, e in record.graph.edges.items()}
462
  operations = diff_scene(
463
  prev_scene,
 
652
  nid: effective_node_label(n, repo.claims.values())
653
  for nid, n in repo.nodes.items()
654
  }
655
+ edge_label_map = {
656
+ eid: effective_edge_label(e, repo.claims.values())
657
+ for eid, e in repo.edges.items()
658
+ }
659
  edge_kind_map = {eid: e.kind for eid, e in repo.edges.items()}
660
  ops = diff_scene(
661
  SceneState(),
 
731
  nid: effective_node_label(n, repo.claims.values())
732
  for nid, n in repo.nodes.items()
733
  }
734
+ edge_label_map = {
735
+ eid: effective_edge_label(e, repo.claims.values())
736
+ for eid, e in repo.edges.items()
737
+ }
738
  edge_kind_map = {eid: e.kind for eid, e in repo.edges.items()}
739
  ops = diff_scene(
740
  SceneState(),
 
810
  nid: effective_node_label(n, repo.claims.values())
811
  for nid, n in repo.nodes.items()
812
  }
813
+ edge_label_map = {
814
+ eid: effective_edge_label(e, repo.claims.values())
815
+ for eid, e in repo.edges.items()
816
+ }
817
  edge_kind_map = {eid: e.kind for eid, e in repo.edges.items()}
818
  ops = diff_scene(
819
  SceneState(),
 
875
  is_error=True,
876
  )
877
  return
878
+ # Success carries the snapshot + new session id but NO chat message: the
879
+ # conversational opening line is streamed separately by main.py via
880
+ # agent_harness.stream_build_opener, so turn one greets the user in the same
881
+ # warm agent voice as every later turn instead of a canned count template.
882
  yield TextBuildProgress(
883
  is_partial=False,
884
  status="",
885
  canvas_patch=snapshot.model_dump(mode="json"),
886
  session_id=sid,
887
+ chat_message=[],
 
 
 
 
 
 
 
 
 
888
  )
889
 
890
 
 
943
  nid: effective_node_label(n, repo.claims.values())
944
  for nid, n in repo.nodes.items()
945
  }
946
+ edge_label_map = {
947
+ eid: effective_edge_label(e, repo.claims.values())
948
+ for eid, e in repo.edges.items()
949
+ }
950
  edge_kind_map = {eid: e.kind for eid, e in repo.edges.items()}
951
  ops = diff_scene(
952
  SceneState(),
 
1218
  )
1219
 
1220
 
1221
+ async def apply_user_edge_edit(
1222
+ session_id: str,
1223
+ edge_id: str,
1224
+ *,
1225
+ label: str,
1226
+ ) -> TurnResult:
1227
+ """Apply a user-asserted label overlay on an EDGE directly (no LLM round-trip).
1228
+
1229
+ Mirror of :func:`apply_user_edit` for edges: validates the edge exists, builds a
1230
+ ``user_asserted`` label :class:`Claim` overlay (raw ``edge.label`` is preserved β€”
1231
+ only the effective label is updated), applies the claim, and emits an
1232
+ ``update_data`` :class:`RendererCommand` carrying the new effective label so the
1233
+ canvas repaints immediately without a full diff. Commits an undo snapshot like its
1234
+ node sibling.
1235
+
1236
+ Raises ``KeyError`` for an unknown session or a missing edge id.
1237
+ """
1238
+ record = session_store.get(session_id)
1239
+ if record is None:
1240
+ raise KeyError(session_id)
1241
+ edge = record.graph.get_edge(edge_id)
1242
+ if edge is None:
1243
+ raise KeyError(f"edge not found: {edge_id}")
1244
+ async with _get_lock(session_id):
1245
+ claim = Claim(
1246
+ id=_stable_id("claim", "label", "useredit", edge_id, label),
1247
+ claim_type="label",
1248
+ target_id=edge_id,
1249
+ text=label,
1250
+ origin="user_asserted",
1251
+ support_state="unverified",
1252
+ review_state="accepted",
1253
+ )
1254
+ graph_patch = GraphPatch(upsert_claims=[claim])
1255
+ scene_patch = ScenePatch()
1256
+ record.graph.apply_graph_patch(graph_patch)
1257
+ # Emit explicit update_data op so the canvas edge label refreshes immediately.
1258
+ effective = effective_edge_label(edge, record.graph.claims.values())
1259
+ extra_ops: list[RendererCommand] = []
1260
+ if edge_id in record.scene.visible_edge_ids:
1261
+ extra_ops.append(
1262
+ RendererCommand(
1263
+ op="update_data",
1264
+ data={"id": edge_id, "data": {"label": effective}},
1265
+ )
1266
+ )
1267
+ record.patch_id += 1
1268
+ patch = RendererPatch(
1269
+ patch_id=record.patch_id,
1270
+ is_snapshot=False,
1271
+ scene_id=record.render_scene_id,
1272
+ operations=extra_ops,
1273
+ )
1274
+ record.graph_version += 1
1275
+ record.last_seen = time.time()
1276
+ _commit_snapshot(record)
1277
+ selection: dict[str, Any] = {"kind": "edge", "id": edge_id}
1278
+ return TurnResult(
1279
+ renderer_patch=patch,
1280
+ inspector_update=_inspector_update(record, selection),
1281
+ assistant_message=f"Updated edge {edge_id}.",
1282
+ scene_patch=scene_patch,
1283
+ status="success",
1284
+ warnings=[],
1285
+ )
1286
+
1287
+
1288
  async def apply_user_edge(
1289
  session_id: str,
1290
  source_id: str,
 
1396
  )
1397
 
1398
 
1399
+ async def remove_user_node(session_id: str, node_id: str) -> TurnResult:
1400
+ """Remove a visible node directly (user-trusted, no LLM) β€” the radial menu's
1401
+ "Delete node" command (confirmed in-canvas before it reaches here).
1402
+
1403
+ Mirrors :func:`remove_user_edge`: the user *proposes* the deletion, the
1404
+ validator + reducer still *decide*. The validator's ``would_empty_scene`` guard
1405
+ keeps the last visible node, and the reducer cascades incident visible edges and
1406
+ emits ``actor="user"`` β€” both free. Serialised under the per-session lock. An
1407
+ unknown / non-visible node degrades to a graceful failure (no mutation); an
1408
+ unknown session raises ``KeyError``.
1409
+ """
1410
+ record = session_store.get(session_id)
1411
+ if record is None:
1412
+ raise KeyError(session_id)
1413
+ selection: dict[str, Any] = {"kind": "none", "id": ""}
1414
+ async with _get_lock(session_id):
1415
+ if node_id not in record.scene.visible_node_ids:
1416
+ return _graceful_failure(record, selection, ["node not visible"])
1417
+ plan = ScenePlan(
1418
+ actions=[SceneAction(type=SceneActionType.remove_node, target_id=node_id)],
1419
+ summary=f"Removing node {node_id}.",
1420
+ )
1421
+ val_result = validate_sceneplan(plan, record.graph, record.scene)
1422
+ if not val_result.valid_actions:
1423
+ return _graceful_failure(record, selection, val_result.rejection_reasons)
1424
+ graph_patch, scene_patch, _events = reduce_turn(
1425
+ val_result.valid_actions, record.graph, record.scene
1426
+ )
1427
+ record.graph.apply_graph_patch(graph_patch)
1428
+ new_scene = record.graph.apply_scene_patch(record.scene, scene_patch)
1429
+ return _finalize_turn(
1430
+ record,
1431
+ prev_scene=record.scene,
1432
+ new_scene=new_scene,
1433
+ revealed_ids=[],
1434
+ graph_mutated=True,
1435
+ assistant_message="Removed node.",
1436
+ scene_patch=scene_patch,
1437
+ status="success",
1438
+ warnings=[],
1439
+ selection=selection,
1440
+ )
1441
+
1442
+
1443
+ def _place_user_node(scene: SceneState, node_id: str) -> SceneState:
1444
+ """Return a copy of ``scene`` with ``node_id`` placed on the open periphery.
1445
+
1446
+ A user-added node is an orphan (no edges yet), so it has no positioned visible
1447
+ neighbour β€” :func:`_assign_reveal_positions` would drop it on the cluster
1448
+ centroid (on top of the existing graph). Instead we place it deterministically
1449
+ to the right of the rightmost positioned node, at the vertical centroid; if no
1450
+ positions exist yet, a fixed default. ``_assign_reveal_positions`` then skips it
1451
+ because the entry already exists.
1452
+ """
1453
+ positions = dict(scene.node_positions)
1454
+ if positions:
1455
+ new_x = max(x for x, _y in positions.values()) + 200.0
1456
+ new_y = sum(y for _x, y in positions.values()) / len(positions)
1457
+ else:
1458
+ new_x, new_y = 400.0, 400.0
1459
+ positions[node_id] = (new_x, new_y)
1460
+ return scene.model_copy(update={"node_positions": positions})
1461
+
1462
+
1463
+ async def apply_user_node(session_id: str, label: str, summary: str = "") -> TurnResult:
1464
+ """Create a user_asserted node directly (no LLM round-trip) β€” the radial menu's
1465
+ "Add node" command.
1466
+
1467
+ Mirrors :func:`apply_user_edge`: mints a unique node id, builds the node + its
1468
+ ``user_asserted`` label (and optional summary) claim(s), reveals it, places it on
1469
+ the open periphery, and finalizes so the renderer diff + camera re-fit fire.
1470
+ Empty label raises ``ValueError``; unknown session raises ``KeyError``.
1471
+ """
1472
+ record = session_store.get(session_id)
1473
+ if record is None:
1474
+ raise KeyError(session_id)
1475
+ label = label.strip()
1476
+ if not label:
1477
+ raise ValueError("apply_user_node requires a non-empty label")
1478
+ async with _get_lock(session_id):
1479
+ # Deterministic, collision-free id: append a numeric suffix until unique.
1480
+ node_id = _stable_id("usernode", label)
1481
+ if node_id in record.graph.nodes:
1482
+ suffix = 2
1483
+ while f"{node_id}-{suffix}" in record.graph.nodes:
1484
+ suffix += 1
1485
+ node_id = f"{node_id}-{suffix}"
1486
+ node = Node(
1487
+ id=node_id,
1488
+ kind="concept",
1489
+ label=label,
1490
+ properties={"origin": "user_asserted"},
1491
+ )
1492
+ claims: list[Claim] = [
1493
+ Claim(
1494
+ id=_stable_id("claim", "label", node_id, label),
1495
+ claim_type="label",
1496
+ target_id=node_id,
1497
+ text=label,
1498
+ origin="user_asserted",
1499
+ support_state="unverified",
1500
+ review_state="accepted",
1501
+ )
1502
+ ]
1503
+ if summary:
1504
+ claims.append(
1505
+ Claim(
1506
+ id=_stable_id("claim", "summary", node_id, summary),
1507
+ claim_type="summary",
1508
+ target_id=node_id,
1509
+ text=summary,
1510
+ origin="user_asserted",
1511
+ support_state="unverified",
1512
+ review_state="accepted",
1513
+ )
1514
+ )
1515
+ graph_patch = GraphPatch(upsert_nodes=[node], upsert_claims=claims)
1516
+ scene_patch = ScenePatch(add_visible_node_ids=[node_id])
1517
+ record.graph.apply_graph_patch(graph_patch)
1518
+ new_scene = record.graph.apply_scene_patch(record.scene, scene_patch)
1519
+ # An orphan node has no positioned neighbour β€” place it on the periphery so
1520
+ # _assign_reveal_positions doesn't drop it on the centroid (on the graph).
1521
+ new_scene = _place_user_node(new_scene, node_id)
1522
+ selection: dict[str, Any] = {"kind": "node", "id": node_id}
1523
+ return _finalize_turn(
1524
+ record,
1525
+ prev_scene=record.scene,
1526
+ new_scene=new_scene,
1527
+ revealed_ids=[node_id],
1528
+ graph_mutated=True,
1529
+ assistant_message=f"Added {label}.",
1530
+ scene_patch=scene_patch,
1531
+ status="success",
1532
+ warnings=[],
1533
+ selection=selection,
1534
+ )
1535
+
1536
+
1537
  # ── Intent classification (pure; routes navigate vs. expand) ──────────────────
1538
 
1539
  _EXPAND_INTENT_RE = re.compile(
 
1994
  status: str,
1995
  warnings: list[str],
1996
  selection: dict[str, Any],
1997
+ relabeled_ids: list[str] | None = None,
1998
  ) -> TurnResult:
1999
  """Replenish fog, lay out reveals, diff to a RendererPatch, and persist.
2000
 
2001
  Shared by ``_navigate_turn`` and ``_expand_turn`` so both paths produce
2002
+ identical render/camera/lineage behaviour. ``relabeled_ids`` lists node/edge ids
2003
+ whose overlay label changed this turn: diff_scene only writes labels onto
2004
+ newly-added elements, so a pure relabel of a persisting element needs an explicit
2005
+ ``update_data`` op (the mechanism apply_user_edit already uses for inline edits).
2006
  """
2007
  if revealed_ids:
2008
  replenished = record.graph.fog_replenish(new_scene, max_fog=_MAX_FOG)
 
2019
  nid: effective_node_label(n, record.graph.claims.values())
2020
  for nid, n in record.graph.nodes.items()
2021
  }
2022
+ edge_label_map = {
2023
+ eid: effective_edge_label(e, record.graph.claims.values())
2024
+ for eid, e in record.graph.edges.items()
2025
+ }
2026
  edge_kind_map = {eid: e.kind for eid, e in record.graph.edges.items()}
2027
  operations = diff_scene(
2028
  prev_scene,
 
2036
  )
2037
  if operations:
2038
  new_scene = new_scene.model_copy(update={"scene_id": str(uuid.uuid4())})
2039
+ # Explicit label refresh for elements relabeled THIS turn that persist (visible in
2040
+ # both scenes): diff_scene only writes labels onto newly-added elements, so a pure
2041
+ # overlay relabel of a still-visible node/edge needs an update_data op of its own.
2042
+ for rid in relabeled_ids or []:
2043
+ edge = record.graph.get_edge(rid)
2044
+ if edge is not None:
2045
+ if rid in new_scene.visible_edge_ids:
2046
+ operations.append(
2047
+ RendererCommand(
2048
+ op="update_data",
2049
+ data={
2050
+ "id": rid,
2051
+ "data": {
2052
+ "label": effective_edge_label(
2053
+ edge, record.graph.claims.values()
2054
+ )
2055
+ },
2056
+ },
2057
+ )
2058
+ )
2059
+ continue
2060
+ node = record.graph.get_node(rid)
2061
+ if node is not None and rid in new_scene.visible_node_ids:
2062
+ operations.append(
2063
+ RendererCommand(
2064
+ op="update_data",
2065
+ data={
2066
+ "id": rid,
2067
+ "data": {
2068
+ "label": effective_node_label(
2069
+ node, record.graph.claims.values()
2070
+ )
2071
+ },
2072
+ },
2073
+ )
2074
+ )
2075
  # After any reveal, re-fit the camera so newly-placed nodes are in view.
2076
  if revealed_ids and new_scene.visible_node_ids:
2077
  operations.append(
 
2275
  }
2276
 
2277
 
2278
+ def _claim_summaries(claims: list[Any]) -> list[_ClaimSummary]:
2279
+ """Project full Claim objects to the inspector's claim-summary shape."""
2280
+ return [
 
 
 
 
 
 
 
 
 
 
 
 
 
2281
  {
2282
  "id": c.id,
2283
  "claim_type": c.claim_type,
 
2286
  "support_state": c.support_state,
2287
  "review_state": c.review_state,
2288
  }
2289
+ for c in claims
2290
  ]
2291
+
2292
+
2293
+ def _inspector_update(
2294
+ record: SessionRecord, selection: dict[str, Any]
2295
+ ) -> InspectorPayload | None:
2296
+ """Inspector payload for a selected NODE or EDGE; ``None`` otherwise.
2297
+
2298
+ Edges expose their governing claims too, so a model-inferred *proposed* edge
2299
+ (e.g. a "find a hidden connection" result) is reviewable via the same
2300
+ Accept/Reject affordance as nodes β€” the review loop applies to relationships,
2301
+ not just concepts.
2302
+ """
2303
+ kind = selection.get("kind")
2304
+ sel_id = selection.get("id")
2305
+ if not sel_id:
2306
+ return None
2307
+ if kind == "node":
2308
+ node = record.graph.get_node(sel_id)
2309
+ if node is None:
2310
+ return None
2311
+ effective = effective_node_label(node, record.graph.claims.values())
2312
+ governing = [c for c in record.graph.claims.values() if c.target_id == sel_id]
2313
+ return {
2314
+ "selected_id": node.id,
2315
+ "label": effective,
2316
+ "original_label": node.label,
2317
+ "kind": node.kind,
2318
+ "edited": effective != node.label,
2319
+ "trust": _aggregate_trust(governing),
2320
+ "governing_claims": _claim_summaries(governing),
2321
+ "evidence_count": len(node.evidence_refs),
2322
+ }
2323
+ if kind == "edge":
2324
+ edge = record.graph.edges.get(sel_id)
2325
+ if edge is None:
2326
+ return None
2327
+ governing = [c for c in record.graph.claims.values() if c.target_id == sel_id]
2328
+ return {
2329
+ "selected_id": edge.id,
2330
+ "label": edge.label or edge.kind,
2331
+ "original_label": edge.label,
2332
+ "kind": edge.kind,
2333
+ "edited": False,
2334
+ "trust": _aggregate_trust(governing),
2335
+ "governing_claims": _claim_summaries(governing),
2336
+ "evidence_count": 0,
2337
+ }
2338
+ return None
2339
 
2340
 
2341
  def inspector_for_selection(
 
2376
  renderer_patch: RendererPatch # proper patch for Gradio β†’ CytoscapeCanvas
2377
 
2378
 
2379
+ def _target_fully_reviewed(graph: LooseGraphRepository, target_id: str) -> bool:
2380
+ """True iff no model_inferred claim on ``target_id`` is still awaiting review.
2381
+
2382
+ The amber ``review-pending`` affordance covers a node/edge as a whole, but a
2383
+ single target may govern several ``model_inferred`` claims (seed nodes carry
2384
+ 2–3). The amber may only clear once EVERY model_inferred claim on the target
2385
+ has been reviewed (``review_state != "pending"`` β€” i.e. accepted or rejected);
2386
+ while any remain pending the affordance must stay up. Non-model_inferred claims
2387
+ (user_asserted / deterministic) never drive the amber, so they're ignored.
2388
+ """
2389
+ return not any(
2390
+ c.target_id == target_id
2391
+ and c.origin == "model_inferred"
2392
+ and c.review_state == "pending"
2393
+ for c in graph.claims.values()
2394
+ )
2395
+
2396
+
2397
+ def _review_canvas_hint_dict(claim: Any, graph: LooseGraphRepository) -> dict[str, Any]:
2398
+ """Produce the canvas-hint dict for a just-reviewed claim.
2399
+
2400
+ ``clear_pending`` (which drops the amber awaiting-review border) fires when:
2401
+ - the target has NO remaining pending model_inferred claims after accept; or
2402
+ - the claim's target is a NODE that was just rejected (nodes are durable entities
2403
+ β€” rejecting a governing claim must NOT remove the node from canvas; the amber
2404
+ badge is simply cleared, matching the accept path's badge-clear behaviour).
2405
+
2406
+ For EDGE claims, rejection keeps the ``remove`` action β€” a rejected proposed
2407
+ relationship revokes the edge from canvas (the intended behaviour, locked by
2408
+ ``test_edge_claim_review.py::test_reject_edge_claim_still_removes_edge``).
2409
+ """
2410
  if claim.review_state == "accepted":
2411
+ if _target_fully_reviewed(graph, claim.target_id):
2412
+ return {"action": "clear_pending", "target_id": claim.target_id}
2413
+ return {"action": "none", "target_id": claim.target_id}
2414
  if claim.review_state == "rejected":
2415
+ # Distinguish node vs edge: nodes are retained on canvas; edges are revoked.
2416
+ if claim.target_id in graph.nodes:
2417
+ # Node claim rejection β€” keep the node; only clear the amber badge.
2418
+ if _target_fully_reviewed(graph, claim.target_id):
2419
+ return {"action": "clear_pending", "target_id": claim.target_id}
2420
+ return {"action": "none", "target_id": claim.target_id}
2421
+ # Edge claim rejection β€” remove the proposed relationship from canvas.
2422
  return {"action": "remove", "target_id": claim.target_id}
2423
  return {"action": "none", "target_id": claim.target_id}
2424
 
2425
 
2426
  def _canvas_hint_to_renderer_patch(
2427
+ record: SessionRecord,
2428
+ hint: dict[str, Any],
2429
+ review_state: str = "",
2430
  ) -> RendererPatch:
2431
+ """Convert a canvas-hint dict to a proper RendererPatch for the Gradio layer.
2432
+
2433
+ ``review_state`` is the snake_case value of the just-reviewed claim after
2434
+ transition (e.g. ``"accepted"`` or ``"rejected"``). When the action is
2435
+ ``"clear_pending"`` an ``update_data`` op is also emitted so the element's
2436
+ Cytoscape ``data('review')`` field is refreshed β€” without it the hover tooltip
2437
+ still says "pending review" even after the amber badge is cleared.
2438
+ """
2439
  ops: list[RendererCommand] = []
2440
  action = hint.get("action", "none")
2441
  target_id = hint.get("target_id", "")
 
2444
  RendererCommand(
2445
  op="remove_class",
2446
  selector=f"#{target_id}",
2447
+ # Must match the stylesheet selector `.review-pending` (renderer.ts) β€”
2448
+ # the class is hyphenated from the snake_case review_state "pending".
2449
+ # A prior typo ("pending-review") meant Accept never cleared the amber
2450
+ # awaiting-review border. Keep this string and the stylesheet in sync.
2451
+ data={"class": "review-pending"},
2452
  )
2453
  )
2454
+ # Also refresh the element's data('review') so the hover tooltip reflects the
2455
+ # new state rather than the stale "pending" value set at add_element time.
2456
+ if review_state:
2457
+ ops.append(
2458
+ RendererCommand(
2459
+ op="update_data",
2460
+ data={"id": target_id, "data": {"review": review_state}},
2461
+ )
2462
+ )
2463
  elif action == "remove":
2464
  ops.append(RendererCommand(op="remove_element", selector=f"#{target_id}"))
2465
  record.patch_id += 1
 
2490
  record.graph.upsert_claims([updated])
2491
  record.graph_version += 1
2492
  # T3-01: emit MapEvent(review, before=claim.review_state, after=updated.review_state)
2493
+ # Pass the live graph (already carrying the upserted ``updated`` claim) so the
2494
+ # hint only clears the amber when the WHOLE target is reviewed, not on the first
2495
+ # of several model_inferred claims.
2496
+ hint = _review_canvas_hint_dict(updated, record.graph)
2497
+ patch = _canvas_hint_to_renderer_patch(
2498
+ record, hint, review_state=updated.review_state
2499
  )
2500
+ # Infer kind dynamically: hardcoding "node" was wrong for edge claims and
2501
+ # produced an inspector for the wrong element type. Determine from the graph.
2502
+ _kind = "edge" if updated.target_id in record.graph.edges else "node"
2503
+ insp = inspector_for_selection(session_id, {"kind": _kind, "id": updated.target_id})
2504
  return ReviewClaimResult(
2505
  review_state=updated.review_state,
2506
  graph_version=record.graph_version,
 
2552
  return do_review_claim(session_id, preferred[0].id, state)
2553
 
2554
 
2555
+ def review_edge_claim(
2556
+ session_id: str,
2557
+ edge_id: str,
2558
+ state: str,
2559
+ ) -> ReviewClaimResult | None:
2560
+ """Transition the review_state of the most meaningful governing claim of an EDGE.
2561
+
2562
+ Mirror of :func:`review_node_claim` β€” the radial context menu can fire Accept/
2563
+ Reject on an EDGE as well as a node. Resolves the edge's governing reviewable
2564
+ claim using the same priority logic (skip ``open_question``, prefer
2565
+ ``model_inferred`` whose ``review_state`` gates the export), then delegates to
2566
+ :func:`do_review_claim`.
2567
+
2568
+ Returns ``None`` when the edge has no reviewable governing claims β€” a graceful
2569
+ no-op. Propagates ``InvalidTransitionError`` on an illegal transition and
2570
+ ``KeyError`` for an unknown session/claim; the caller (``on_select``) maps these
2571
+ to its own idiom.
2572
+ """
2573
+ record = session_store.get(session_id)
2574
+ if record is None or edge_id not in record.graph.edges:
2575
+ return None
2576
+ insp = inspector_for_selection(session_id, {"kind": "edge", "id": edge_id})
2577
+ governing = (insp.get("governing_claims") if insp is not None else None) or []
2578
+ if not governing:
2579
+ return None
2580
+ resolved = [
2581
+ record.graph.claims[c["id"]]
2582
+ for c in governing
2583
+ if c.get("id") in record.graph.claims
2584
+ ]
2585
+ reviewable = [c for c in resolved if c.claim_type != "open_question"] or resolved
2586
+ if not reviewable:
2587
+ return None
2588
+ actionable = [c for c in reviewable if c.review_state != "accepted"] or reviewable
2589
+ preferred = [c for c in actionable if c.origin == "model_inferred"] or actionable
2590
+ return do_review_claim(session_id, preferred[0].id, state)
2591
+
2592
+
2593
  def focus_node(session_id: str, node_id: str) -> RendererPatch | None:
2594
  """Build a transient *focus* patch for a node: dim everything except the node
2595
  and its currently-visible neighbours, then animate the camera to the node.
 
2686
  nid: effective_node_label(n, record.graph.claims.values())
2687
  for nid, n in record.graph.nodes.items()
2688
  }
2689
+ edge_label_map = {
2690
+ eid: effective_edge_label(e, record.graph.claims.values())
2691
+ for eid, e in record.graph.edges.items()
2692
+ }
2693
  edge_kind_map = {eid: e.kind for eid, e in record.graph.edges.items()}
2694
  ops = diff_scene(
2695
  old_scene,
 
2720
  return patch
2721
 
2722
 
2723
+ # Max outstanding (still-pending) model-inferred hypothesis edges per session.
2724
+ # "Find a hidden connection" proposes a fresh cross-cluster bridge each click; as
2725
+ # accepted/rejected ones drop out of the pending set the budget refills, so this
2726
+ # caps how many AMBER awaiting-review hypotheses can pile up at once β€” it does not
2727
+ # cap the lifetime total.
2728
+ _MAX_PENDING_HYPOTHESES = 4
2729
+
2730
+
2731
+ def _pending_hypothesis_count(graph: LooseGraphRepository) -> int:
2732
+ """Count outstanding model-inferred hypothesis edges still awaiting review.
2733
+
2734
+ A hypothesis edge is governed by a ``model_inferred`` / ``review_state="pending"``
2735
+ relationship claim whose ``target_id`` is a ``hypedge`` edge id (the prefix
2736
+ minted by :func:`find_connection_session`).
2737
+ """
2738
+ return sum(
2739
+ 1
2740
+ for c in graph.claims.values()
2741
+ if c.origin == "model_inferred"
2742
+ and c.review_state == "pending"
2743
+ and c.target_id.startswith("hypedge")
2744
+ and c.target_id in graph.edges
2745
+ )
2746
+
2747
+
2748
+ async def find_connection_session(session_id: str) -> RendererPatch | None:
2749
+ """ "Find a hidden connection": surface a SURPRISING cross-cluster link as a
2750
+ model-inferred *proposal* (amber, awaiting review) β€” never auto-applied.
2751
+
2752
+ Picks two visible nodes in different communities that aren't already linked but
2753
+ sit on a short structural bridge (``scene_curator.find_bridge_candidate``), then
2754
+ proposes a ``model_inferred`` / ``review_state="pending"`` hypothesis edge between
2755
+ them, flashes it, and pans the camera to it. The edge carries a governing pending
2756
+ Claim so it renders as the amber awaiting-review affordance and is reviewable /
2757
+ redacted-on-export exactly like any other model proposal (trust model intact).
2758
+
2759
+ Mirrors :func:`expand_node`'s self-contained diff path (not the LLM pipeline).
2760
+ Returns ``None`` for an unknown session, when there's no cross-cluster candidate
2761
+ (single cluster / all pairs already linked), or when there are already
2762
+ ``_MAX_PENDING_HYPOTHESES`` outstanding pending hypotheses β€” all graceful no-ops.
2763
+ """
2764
+ record = session_store.get(session_id)
2765
+ if record is None:
2766
+ return None
2767
+ async with _get_lock(session_id):
2768
+ # Cap outstanding amber hypotheses: each click adds a NEW cross-cluster pair
2769
+ # (the bridge finder skips existing-edge pairs, so a fresh pair never collides
2770
+ # β€” the old ``edge_id in record.graph.edges`` guard was dead). Without this cap
2771
+ # repeated clicks flood the canvas with unreviewed proposals. Review some first
2772
+ # to refill the budget.
2773
+ if _pending_hypothesis_count(record.graph) >= _MAX_PENDING_HYPOTHESES:
2774
+ return None
2775
+
2776
+ from loosecanvas.scene_curator import (
2777
+ compute_node_clusters,
2778
+ find_bridge_candidate,
2779
+ )
2780
+
2781
+ clusters = compute_node_clusters(record.graph)
2782
+ pair = find_bridge_candidate(
2783
+ record.graph, clusters, list(record.scene.visible_node_ids)
2784
+ )
2785
+ if pair is None:
2786
+ return None
2787
+ src, tgt = pair
2788
+ label = "might relate to"
2789
+ edge_id = _stable_id("hypedge", src, tgt, label)
2790
+
2791
+ # The model proposes a hypothesis edge; the governing pending Claim is what
2792
+ # makes it render amber + reviewable (edge.properties alone don't drive the
2793
+ # trust map). Origin is frozen model_inferred β€” review never upgrades it.
2794
+ edge = Edge(
2795
+ id=edge_id,
2796
+ source=src,
2797
+ target=tgt,
2798
+ type="hypothesis",
2799
+ label=label,
2800
+ properties={"origin": "model_inferred"},
2801
+ )
2802
+ edge_claim = Claim(
2803
+ id=_stable_id("claim", "relationship", edge_id, label),
2804
+ claim_type="relationship",
2805
+ target_id=edge_id,
2806
+ text=label,
2807
+ origin="model_inferred",
2808
+ support_state="unverified",
2809
+ review_state="pending",
2810
+ )
2811
+ graph_patch = GraphPatch(upsert_edges=[edge], upsert_claims=[edge_claim])
2812
+ scene_patch = ScenePatch(add_visible_edge_ids=[edge_id])
2813
+
2814
+ old_scene = record.scene
2815
+ record.graph.apply_graph_patch(graph_patch)
2816
+ candidate = record.graph.apply_scene_patch(old_scene, scene_patch)
2817
+
2818
+ edge_map = {eid: (e.source, e.target) for eid, e in record.graph.edges.items()}
2819
+ node_map = {
2820
+ nid: effective_node_label(n, record.graph.claims.values())
2821
+ for nid, n in record.graph.nodes.items()
2822
+ }
2823
+ edge_label_map = {
2824
+ eid: effective_edge_label(e, record.graph.claims.values())
2825
+ for eid, e in record.graph.edges.items()
2826
+ }
2827
+ edge_kind_map = {eid: e.kind for eid, e in record.graph.edges.items()}
2828
+ ops = diff_scene(
2829
+ old_scene,
2830
+ candidate,
2831
+ edge_map=edge_map,
2832
+ node_map=node_map,
2833
+ edge_label_map=edge_label_map,
2834
+ edge_kind_map=edge_kind_map,
2835
+ node_trust_map=_build_node_trust_map(record.graph),
2836
+ edge_trust_map=_build_edge_trust_map(record.graph),
2837
+ )
2838
+ ops.append(
2839
+ RendererCommand(op="flash", data={"ids": [edge_id], "duration": 1400})
2840
+ )
2841
+ ops.append(
2842
+ RendererCommand(
2843
+ op="animate_camera",
2844
+ data={"fit_selector": f"#{edge_id}", "padding": 80},
2845
+ )
2846
+ )
2847
+
2848
+ record.scene = candidate # atomic: only after diff_scene succeeded
2849
+ record.graph_version += 1
2850
+ record.last_seen = time.time()
2851
+ record.patch_id += 1
2852
+ patch = RendererPatch(
2853
+ patch_id=record.patch_id,
2854
+ is_snapshot=False,
2855
+ scene_id=record.render_scene_id,
2856
+ operations=ops,
2857
+ )
2858
+ _commit_snapshot(record)
2859
+ return patch
2860
+
2861
+
2862
  # ── Agent-turn pipeline (called by agent_tools.finalize_agent_turn) ───────────
2863
 
2864
  # The agent composes a turn from many individually-validated tool calls; the legacy
 
2898
  )
2899
  record.graph.apply_graph_patch(graph_patch)
2900
  new_scene = record.graph.apply_scene_patch(record.scene, scene_patch)
2901
+ # Label overlays upserted this turn (update_edge / update_node) need an explicit
2902
+ # render refresh β€” diff_scene won't repaint a persisting element's label.
2903
+ relabeled_ids = [
2904
+ claim.target_id
2905
+ for claim in graph_patch.upsert_claims
2906
+ if claim.claim_type == "label"
2907
+ ]
2908
  return _finalize_turn(
2909
  record,
2910
  prev_scene=record.scene,
 
2916
  status=_status(result),
2917
  warnings=result.rejection_reasons,
2918
  selection=selection,
2919
+ relabeled_ids=relabeled_ids,
2920
  )
src/loosecanvas/validator.py CHANGED
@@ -244,6 +244,8 @@ def _missing_required_field(action: SceneAction) -> str | None:
244
  if not action.label and not action.note:
245
  return "missing_required_field:label_or_note"
246
  required = () # already checked above
 
 
247
  elif action.type == SceneActionType.create_edge:
248
  required = (
249
  ("source_id", action.source_id),
@@ -343,6 +345,13 @@ def _existence_and_scene_reason(
343
  return f"id_not_found:{action.target_id}"
344
  return None
345
 
 
 
 
 
 
 
 
346
  if action.type == SceneActionType.create_node:
347
  if graph.get_node(action.target_id) is not None or (
348
  action.target_id in pending_created
@@ -373,12 +382,18 @@ _USER_ASSERTED_ORIGIN = "user_asserted"
373
 
374
 
375
  def _has_duplicate_user_edge(action: SceneAction, graph: LooseGraphRepository) -> bool:
376
- """True if a user_asserted edge already exists for this source/target/label."""
 
 
 
 
 
 
 
 
377
  for edge in graph.edges.values():
378
  if edge.source != action.source_id or edge.target != action.target_id:
379
  continue
380
- if edge.properties.get("origin") != _USER_ASSERTED_ORIGIN:
381
- continue
382
  if _labels_similar(action.label, edge.label):
383
  return True
384
  return False
 
244
  if not action.label and not action.note:
245
  return "missing_required_field:label_or_note"
246
  required = () # already checked above
247
+ elif action.type == SceneActionType.update_edge:
248
+ required = (("edge_id", action.edge_id), ("label", action.label))
249
  elif action.type == SceneActionType.create_edge:
250
  required = (
251
  ("source_id", action.source_id),
 
345
  return f"id_not_found:{action.target_id}"
346
  return None
347
 
348
+ if action.type == SceneActionType.update_edge:
349
+ if graph.get_edge(action.edge_id) is None:
350
+ return f"id_not_found:{action.edge_id}"
351
+ if action.edge_id not in scene.visible_edge_ids:
352
+ return f"edge_not_visible:{action.edge_id}"
353
+ return None
354
+
355
  if action.type == SceneActionType.create_node:
356
  if graph.get_node(action.target_id) is not None or (
357
  action.target_id in pending_created
 
382
 
383
 
384
  def _has_duplicate_user_edge(action: SceneAction, graph: LooseGraphRepository) -> bool:
385
+ """True if ANY edge already connects source→target with a similar label.
386
+
387
+ A ``create_edge`` re-asserting an existing relationship is redundant regardless
388
+ of that edge's origin β€” including a model_inferred one (agent ``create_edge`` is
389
+ now model_inferred, so origin-gating this to user_asserted let the agent pile up
390
+ duplicate edges). ``propose_hypothesis`` keeps its own laxer dedup
391
+ (:func:`_has_duplicate_deterministic_edge`) so a hypothesis isn't blocked by a
392
+ prior model guess.
393
+ """
394
  for edge in graph.edges.values():
395
  if edge.source != action.source_id or edge.target != action.target_id:
396
  continue
 
 
397
  if _labels_similar(action.label, edge.label):
398
  return True
399
  return False
tests/test_agent_harness.py CHANGED
@@ -147,7 +147,11 @@ async def test_streaming_emits_tool_activity(live_session_id: str) -> None:
147
  )
148
  ]
149
  activity = [ev for ev in events if isinstance(ev, ToolActivityEvent)]
150
- assert any("reveal_node" in ev.message for ev in activity)
 
 
 
 
151
 
152
 
153
  async def test_source_text_seeded_once_at_front(live_session_id: str) -> None:
@@ -284,3 +288,22 @@ def test_compose_without_record_is_backward_compatible() -> None:
284
  "[The user currently has node 'n2' selected.]"
285
  )
286
  assert _compose_user_message("hi", {"kind": "none", "id": ""}) == "hi"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  )
148
  ]
149
  activity = [ev for ev in events if isinstance(ev, ToolActivityEvent)]
150
+ # Streamed activity is narrated in friendly present tense, not the raw tool name.
151
+ from loosecanvas.agent_harness import _friendly_tool
152
+
153
+ assert any(_friendly_tool("reveal_node") in ev.message for ev in activity)
154
+ assert all("reveal_node" not in ev.message for ev in activity)
155
 
156
 
157
  async def test_source_text_seeded_once_at_front(live_session_id: str) -> None:
 
288
  "[The user currently has node 'n2' selected.]"
289
  )
290
  assert _compose_user_message("hi", {"kind": "none", "id": ""}) == "hi"
291
+
292
+
293
+ # ── Slice (b): confidence-tier voice ─────────────────────────────────────────
294
+
295
+
296
+ def test_system_prompt_contains_confidence_tier_voice_cue() -> None:
297
+ """SYSTEM_PROMPT must contain the calibrate-voice-to-confidence block."""
298
+ assert "CALIBRATE YOUR VOICE TO YOUR CONFIDENCE" in SYSTEM_PROMPT
299
+ # Check all three tiers are present.
300
+ assert "user-asserted" in SYSTEM_PROMPT or "explicitly named" in SYSTEM_PROMPT
301
+ assert "model-inferred" in SYSTEM_PROMPT or "I notice" in SYSTEM_PROMPT
302
+ assert "provocateur" in SYSTEM_PROMPT or "but what if" in SYSTEM_PROMPT
303
+
304
+
305
+ def test_system_prompt_honors_conversational_tone_requests() -> None:
306
+ """SYSTEM_PROMPT must instruct the agent to honor user tone/style requests."""
307
+ # The sentence added in place of the old steering-lens machinery tells the
308
+ # agent to shift its voice when the user asks (e.g. "be more skeptical").
309
+ assert "skeptical" in SYSTEM_PROMPT
tests/test_agent_tools.py CHANGED
@@ -23,6 +23,7 @@ from loosecanvas.agent_tools import (
23
  remove_node,
24
  reveal_node,
25
  set_turn_context,
 
26
  )
27
  from loosecanvas.turn_logic import (
28
  TurnResult,
@@ -94,6 +95,34 @@ async def test_reveal_node_accept_path(live_session_id: str) -> None:
94
  assert len(ctx.activity) == 1
95
 
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  async def test_reveal_node_reject_path(live_session_id: str) -> None:
98
  set_turn_context(live_session_id)
99
  # Completely nonexistent node id β€” validator gives id_not_found
 
23
  remove_node,
24
  reveal_node,
25
  set_turn_context,
26
+ update_edge,
27
  )
28
  from loosecanvas.turn_logic import (
29
  TurnResult,
 
95
  assert len(ctx.activity) == 1
96
 
97
 
98
+ async def test_get_canvas_state_includes_visible_edges(live_session_id: str) -> None:
99
+ set_turn_context(live_session_id)
100
+ state = json.loads(get_canvas_state.invoke({}))
101
+ assert "visible_edges" in state, "agent must be able to see edges to relabel them"
102
+ for edge in state["visible_edges"]:
103
+ assert set(edge) >= {"id", "source", "target", "label"}
104
+ assert edge["id"] in state["visible_edge_ids"]
105
+
106
+
107
+ async def test_update_edge_accumulates_for_visible_edge(live_session_id: str) -> None:
108
+ set_turn_context(live_session_id)
109
+ state = json.loads(get_canvas_state.invoke({}))
110
+ if not state["visible_edges"]:
111
+ pytest.skip("fixture has no visible edge to relabel")
112
+ edge_id = state["visible_edges"][0]["id"]
113
+ result = json.loads(
114
+ update_edge.invoke({"edge_id": edge_id, "label": "clearer link"})
115
+ )
116
+ assert result["status"] == "accepted"
117
+ ctx = get_turn_context()
118
+ assert any(
119
+ a.type.value == "update_edge"
120
+ and a.edge_id == edge_id
121
+ and a.label == "clearer link"
122
+ for a in ctx.accumulated_actions
123
+ )
124
+
125
+
126
  async def test_reveal_node_reject_path(live_session_id: str) -> None:
127
  set_turn_context(live_session_id)
128
  # Completely nonexistent node id β€” validator gives id_not_found
tests/test_b2_cxtmenu_review.py CHANGED
@@ -25,6 +25,7 @@ from loosecanvas.turn_logic import (
25
  expand_node,
26
  focus_node,
27
  remove_user_edge,
 
28
  review_node_claim,
29
  )
30
 
@@ -333,3 +334,146 @@ async def test_remove_user_edge_unknown_session_raises() -> None:
333
  """An unknown session raises KeyError (the on_select branch catches it)."""
334
  with pytest.raises(KeyError):
335
  await remove_user_edge("no-such-session", "e1")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  expand_node,
26
  focus_node,
27
  remove_user_edge,
28
+ review_edge_claim,
29
  review_node_claim,
30
  )
31
 
 
334
  """An unknown session raises KeyError (the on_select branch catches it)."""
335
  with pytest.raises(KeyError):
336
  await remove_user_edge("no-such-session", "e1")
337
+
338
+
339
+ # ── TASK 1: reject a node claim must NOT remove the node ─────────────────────
340
+
341
+
342
+ def test_reject_node_claim_keeps_node_on_canvas() -> None:
343
+ """Bug fix: rejecting a governing model_inferred claim on a NODE must NOT emit
344
+ remove_element for the node id. The node stays on canvas; only the amber
345
+ 'awaiting review' badge is cleared (clear_pending action, same as accept).
346
+
347
+ The backend graph_repository retains the node β€” this test verifies that the
348
+ renderer patch also does NOT contain a remove_element op targeting the node id,
349
+ i.e. the canvas/backend desync is prevented.
350
+ """
351
+ claim = Claim(
352
+ id="c-node",
353
+ claim_type="label",
354
+ target_id="n1",
355
+ origin="model_inferred",
356
+ review_state="pending",
357
+ support_state="unverified",
358
+ text="test label",
359
+ )
360
+ sid = _make_session([claim])
361
+ result = turn_logic.do_review_claim(sid, "c-node", "rejected")
362
+ record = turn_logic.session_store[sid]
363
+
364
+ # Node still present in the graph (backend).
365
+ assert (
366
+ "n1" in record.graph.nodes
367
+ ), "node must NOT be removed from the graph on claim reject"
368
+
369
+ # Renderer patch must NOT contain a remove_element op targeting the node id.
370
+ remove_ops = [
371
+ o
372
+ for o in result.renderer_patch.operations
373
+ if o.op == "remove_element" and "n1" in (o.selector or "")
374
+ ]
375
+ assert (
376
+ not remove_ops
377
+ ), f"rejecting a node claim must not emit remove_element for the node; got: {remove_ops}"
378
+
379
+ # The canvas hint should clear the pending badge, not remove the element.
380
+ assert (
381
+ result.canvas_hint["action"] == "clear_pending"
382
+ ), f"expected clear_pending for rejected node claim, got {result.canvas_hint['action']!r}"
383
+ assert result.canvas_hint["target_id"] == "n1"
384
+
385
+
386
+ # ── TASK 2: review_edge_claim (wrapper parity with review_node_claim) ─────────
387
+
388
+
389
+ def _make_session_with_pending_edge_claim() -> str:
390
+ """Session with a model_inferred pending edge claim on e1 (n1β†’n2)."""
391
+ nodes = [
392
+ Node(id="n1", kind="concept", label="N1"),
393
+ Node(id="n2", kind="concept", label="N2"),
394
+ ]
395
+ edge = Edge(id="e1", source="n1", target="n2", kind="related_to", label="links")
396
+ claim = Claim(
397
+ id="c-edge",
398
+ claim_type="relationship",
399
+ target_id="e1",
400
+ origin="model_inferred",
401
+ review_state="pending",
402
+ support_state="unverified",
403
+ text="links",
404
+ )
405
+ repo = LooseGraphRepository.from_extraction(
406
+ nodes, [edge], [claim], SourceSnapshot()
407
+ )
408
+ scene = SceneState(
409
+ scene_id="s1",
410
+ visible_node_ids=["n1", "n2"],
411
+ visible_edge_ids=["e1"],
412
+ )
413
+ sid = str(uuid.uuid4())
414
+ now = time.time()
415
+ turn_logic.session_store[sid] = turn_logic.SessionRecord(
416
+ session_id=sid,
417
+ graph=repo,
418
+ scene=scene,
419
+ history=[],
420
+ graph_version=1,
421
+ created_at=now,
422
+ last_seen=now,
423
+ render_scene_id="s1",
424
+ )
425
+ return sid
426
+
427
+
428
+ def test_review_edge_claim_accept_transitions_claim() -> None:
429
+ """review_edge_claim accept β†’ claim transitions to accepted, result carries inspector."""
430
+ sid = _make_session_with_pending_edge_claim()
431
+ result = review_edge_claim(sid, "e1", "accepted")
432
+ assert isinstance(result, ReviewClaimResult)
433
+ assert result.review_state == "accepted"
434
+ assert (
435
+ turn_logic.session_store[sid].graph.claims["c-edge"].review_state == "accepted"
436
+ )
437
+
438
+
439
+ def test_review_edge_claim_reject_transitions_claim() -> None:
440
+ """review_edge_claim reject β†’ claim transitions to rejected."""
441
+ sid = _make_session_with_pending_edge_claim()
442
+ result = review_edge_claim(sid, "e1", "rejected")
443
+ assert isinstance(result, ReviewClaimResult)
444
+ assert result.review_state == "rejected"
445
+ assert (
446
+ turn_logic.session_store[sid].graph.claims["c-edge"].review_state == "rejected"
447
+ )
448
+
449
+
450
+ def test_review_edge_claim_returns_none_for_edge_without_pending_claims() -> None:
451
+ """An edge with no reviewable claims is a graceful no-op."""
452
+ nodes = [
453
+ Node(id="n1", kind="concept", label="N1"),
454
+ Node(id="n2", kind="concept", label="N2"),
455
+ ]
456
+ edge = Edge(id="e1", source="n1", target="n2", kind="related_to")
457
+ repo = LooseGraphRepository.from_extraction(nodes, [edge], [], SourceSnapshot())
458
+ scene = SceneState(
459
+ scene_id="s1", visible_node_ids=["n1", "n2"], visible_edge_ids=["e1"]
460
+ )
461
+ sid = str(uuid.uuid4())
462
+ now = time.time()
463
+ turn_logic.session_store[sid] = turn_logic.SessionRecord(
464
+ session_id=sid,
465
+ graph=repo,
466
+ scene=scene,
467
+ history=[],
468
+ graph_version=1,
469
+ created_at=now,
470
+ last_seen=now,
471
+ render_scene_id="s1",
472
+ )
473
+ assert review_edge_claim(sid, "e1", "accepted") is None
474
+
475
+
476
+ def test_review_edge_claim_returns_none_for_unknown_edge() -> None:
477
+ """An unknown edge_id is a graceful no-op."""
478
+ sid = _make_session_with_pending_edge_claim()
479
+ assert review_edge_claim(sid, "does_not_exist", "accepted") is None
tests/test_contracts.py CHANGED
@@ -258,8 +258,8 @@ def test_edgespec_position_roundtrip() -> None:
258
 
259
  def test_scene_action_type_has_ten_values() -> None:
260
  # T2-01 added remove_node/remove_edge (10 β†’ 12); T2-03 added update_node/create_edge (12 β†’ 14);
261
- # agent graph-growth added create_node (14 β†’ 15).
262
- assert len(list(SceneActionType)) == 15
263
 
264
 
265
  @pytest.mark.parametrize("action_type", list(SceneActionType))
@@ -295,6 +295,7 @@ def test_scene_action_type_values() -> None:
295
  "remove_edge",
296
  # T2-03 user-edit actions:
297
  "update_node",
 
298
  "create_edge",
299
  # Agent graph-growth action:
300
  "create_node",
 
258
 
259
  def test_scene_action_type_has_ten_values() -> None:
260
  # T2-01 added remove_node/remove_edge (10 β†’ 12); T2-03 added update_node/create_edge (12 β†’ 14);
261
+ # agent graph-growth added create_node (14 β†’ 15); edge editing added update_edge (15 β†’ 16).
262
+ assert len(list(SceneActionType)) == 16
263
 
264
 
265
  @pytest.mark.parametrize("action_type", list(SceneActionType))
 
295
  "remove_edge",
296
  # T2-03 user-edit actions:
297
  "update_node",
298
+ "update_edge",
299
  "create_edge",
300
  # Agent graph-growth action:
301
  "create_node",
tests/test_edge_claim_review.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Edge/claim-level review (#4 multidigraph).
2
+
3
+ A model-inferred *proposed* edge (a "find a hidden connection" hypothesis, or an
4
+ agent ``create_edge``) carries its own relationship claim and must be reviewable
5
+ with the same Accept/Reject affordance as a node β€” the review loop applies to
6
+ relationships, not just concepts. The Gradio review handlers are claim-id based and
7
+ kind-agnostic (``do_review_claim`` / ``on_review_claim``), and the edge inspector
8
+ exposes ``governing_claims``, so selecting an edge surfaces its pending claim and the
9
+ generic Accept/Reject buttons transition it.
10
+
11
+ These tests lock that end-to-end backend capability so a refactor can't silently
12
+ regress edge review back to node-only.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import time
19
+ import uuid
20
+
21
+ from loosecanvas import turn_logic
22
+ from loosecanvas.contracts import Claim, Edge, Node, SceneState, SourceSnapshot
23
+ from loosecanvas.graph_repository import LooseGraphRepository
24
+
25
+
26
+ def _session_with_model_edge() -> tuple[str, str, str]:
27
+ """A session whose single edge is a model_inferred PROPOSAL (pending review)."""
28
+ nodes = [
29
+ Node(id="n1", kind="concept", label="N1"),
30
+ Node(id="n2", kind="concept", label="N2"),
31
+ ]
32
+ edge = Edge(
33
+ id="e1",
34
+ source="n1",
35
+ target="n2",
36
+ kind="related",
37
+ label="links",
38
+ properties={"origin": "model_inferred"},
39
+ )
40
+ claim = Claim(
41
+ id="c-rel",
42
+ claim_type="relationship",
43
+ target_id="e1",
44
+ text="links",
45
+ origin="model_inferred",
46
+ support_state="unverified",
47
+ review_state="pending",
48
+ )
49
+ repo = LooseGraphRepository.from_extraction(
50
+ nodes, [edge], [claim], SourceSnapshot()
51
+ )
52
+ scene = SceneState(
53
+ scene_id="s1", visible_node_ids=["n1", "n2"], visible_edge_ids=["e1"]
54
+ )
55
+ sid = str(uuid.uuid4())
56
+ now = time.time()
57
+ turn_logic.session_store[sid] = turn_logic.SessionRecord(
58
+ session_id=sid,
59
+ graph=repo,
60
+ scene=scene,
61
+ history=[],
62
+ graph_version=1,
63
+ created_at=now,
64
+ last_seen=now,
65
+ render_scene_id="s1",
66
+ )
67
+ return sid, "e1", "c-rel"
68
+
69
+
70
+ def test_edge_inspector_surfaces_pending_claim() -> None:
71
+ sid, edge_id, claim_id = _session_with_model_edge()
72
+ try:
73
+ insp = turn_logic.inspector_for_selection(sid, {"kind": "edge", "id": edge_id})
74
+ assert insp is not None, "selecting an edge must yield an inspector payload"
75
+ claims = insp["governing_claims"]
76
+ actionable = [c for c in claims if c["review_state"] != "accepted"]
77
+ assert any(c["id"] == claim_id for c in actionable), (
78
+ "a pending model-inferred edge claim must surface as reviewable β€” this is "
79
+ "what the generic Accept/Reject buttons act on"
80
+ )
81
+ finally:
82
+ turn_logic.session_store.pop(sid, None)
83
+
84
+
85
+ def test_do_review_claim_accepts_edge_claim() -> None:
86
+ sid, edge_id, claim_id = _session_with_model_edge()
87
+ try:
88
+ result = turn_logic.do_review_claim(sid, claim_id, "accepted")
89
+ record = turn_logic.session_store[sid]
90
+ assert record.graph.claims[claim_id].review_state == "accepted"
91
+ # The review result targets the EDGE β€” proving review works at edge level.
92
+ assert result is not None
93
+ assert result.renderer_patch is not None
94
+ finally:
95
+ turn_logic.session_store.pop(sid, None)
96
+
97
+
98
+ def test_do_review_claim_rejects_edge_claim() -> None:
99
+ sid, edge_id, claim_id = _session_with_model_edge()
100
+ try:
101
+ turn_logic.do_review_claim(sid, claim_id, "rejected")
102
+ record = turn_logic.session_store[sid]
103
+ assert record.graph.claims[claim_id].review_state == "rejected"
104
+ finally:
105
+ turn_logic.session_store.pop(sid, None)
106
+
107
+
108
+ def test_reject_edge_claim_still_removes_edge() -> None:
109
+ """Regression guard: rejecting a model_inferred EDGE claim must still emit a
110
+ remove_element op for the edge β€” edge rejection removing the proposed
111
+ relationship from canvas is the intended behaviour (locked by this test).
112
+
113
+ This is the OPPOSITE of the node-claim fix: a rejected node claim must NOT
114
+ remove the node (the node is a durable entity); a rejected edge claim MUST
115
+ remove the edge (the proposed relationship is revoked). Both paths must be
116
+ preserved independently.
117
+ """
118
+ sid, edge_id, claim_id = _session_with_model_edge()
119
+ try:
120
+ result = turn_logic.do_review_claim(sid, claim_id, "rejected")
121
+ # Claim is marked rejected.
122
+ assert (
123
+ turn_logic.session_store[sid].graph.claims[claim_id].review_state
124
+ == "rejected"
125
+ )
126
+ # The renderer patch MUST contain a remove_element op for the edge.
127
+ remove_ops = [
128
+ o
129
+ for o in result.renderer_patch.operations
130
+ if o.op == "remove_element" and edge_id in (o.selector or "")
131
+ ]
132
+ assert remove_ops, (
133
+ "rejecting an edge claim must emit remove_element for the edge id; "
134
+ f"got ops: {[o.op for o in result.renderer_patch.operations]}"
135
+ )
136
+ # Canvas hint action must be 'remove' (not 'clear_pending').
137
+ assert result.canvas_hint["action"] == "remove"
138
+ assert result.canvas_hint["target_id"] == edge_id
139
+ finally:
140
+ turn_logic.session_store.pop(sid, None)
141
+
142
+
143
+ # --------------------------------------------------------------------------- #
144
+ # End-to-end: text-extracted edge (semantic_edge claim) review #
145
+ # --------------------------------------------------------------------------- #
146
+
147
+ _HAPPY_EDGE_JSON = json.dumps(
148
+ {
149
+ "concept_nodes": [
150
+ {
151
+ "id": "concept::alpha",
152
+ "label": "Alpha",
153
+ "summary": "First concept.",
154
+ "start_char": 0,
155
+ "end_char": 5,
156
+ },
157
+ {
158
+ "id": "concept::beta",
159
+ "label": "Beta",
160
+ "summary": "Second concept.",
161
+ "start_char": 7,
162
+ "end_char": 11,
163
+ },
164
+ ],
165
+ "semantic_edges": [
166
+ {
167
+ "source_id": "concept::alpha",
168
+ "target_id": "concept::beta",
169
+ "type": "explains",
170
+ "label": "introduces",
171
+ }
172
+ ],
173
+ }
174
+ )
175
+
176
+
177
+ async def test_text_extracted_edge_review_end_to_end() -> None:
178
+ """Build a session from text-extracted output; call review_edge_claim on the
179
+ text-extracted edge and assert the governing semantic_edge claim transitions to
180
+ 'accepted'. This locks the no-longer-no-op review path.
181
+ """
182
+ import httpx
183
+ from loosecanvas.extractors.text_graph_adapter import extract_graph_from_text
184
+ from loosecanvas.llm_client import LLMClient
185
+
186
+ def handler(_request: httpx.Request) -> httpx.Response:
187
+ return httpx.Response(
188
+ 200, json={"choices": [{"message": {"content": _HAPPY_EDGE_JSON}}]}
189
+ )
190
+
191
+ client = LLMClient(base_url="http://test", transport=httpx.MockTransport(handler))
192
+ graph = await extract_graph_from_text("Alpha explains Beta.", "test", client)
193
+
194
+ assert len(graph.edges) == 1, f"expected 1 edge, got {len(graph.edges)}"
195
+ edge = graph.edges[0]
196
+ # The semantic_edge claim must target the edge id (not the target node id).
197
+ semantic_claims = [c for c in graph.claims if c.claim_type == "semantic_edge"]
198
+ assert len(semantic_claims) == 1
199
+ governing_claim = semantic_claims[0]
200
+ assert (
201
+ governing_claim.target_id == edge.id
202
+ ), f"governing claim targets {governing_claim.target_id!r}, not edge id {edge.id!r}"
203
+ assert governing_claim.review_state == "pending"
204
+
205
+ # Build a live session via the real repository/session_store path.
206
+ repo = LooseGraphRepository.from_extraction(
207
+ graph.nodes, graph.edges, graph.claims, SourceSnapshot()
208
+ )
209
+ scene = SceneState(
210
+ scene_id="s1",
211
+ visible_node_ids=[n.id for n in graph.nodes],
212
+ visible_edge_ids=[edge.id],
213
+ )
214
+ sid = str(uuid.uuid4())
215
+ now = time.time()
216
+ turn_logic.session_store[sid] = turn_logic.SessionRecord(
217
+ session_id=sid,
218
+ graph=repo,
219
+ scene=scene,
220
+ history=[],
221
+ graph_version=1,
222
+ created_at=now,
223
+ last_seen=now,
224
+ render_scene_id="s1",
225
+ )
226
+ try:
227
+ result = turn_logic.do_review_claim(sid, governing_claim.id, "accepted")
228
+ record = turn_logic.session_store[sid]
229
+ assert (
230
+ record.graph.claims[governing_claim.id].review_state == "accepted"
231
+ ), "review_edge_claim was a no-op: governing semantic_edge claim not updated"
232
+ assert result is not None
233
+ assert result.renderer_patch is not None
234
+ # After accepting, the canvas hint must clear the amber (single-claim edge).
235
+ assert result.canvas_hint["action"] == "clear_pending"
236
+ assert result.canvas_hint["target_id"] == edge.id
237
+ finally:
238
+ turn_logic.session_store.pop(sid, None)
tests/test_exporter.py CHANGED
@@ -4,7 +4,17 @@ from __future__ import annotations
4
 
5
  import json
6
 
7
- from loosecanvas.contracts import Claim, Edge, MapEvent, Node, SuggestedPathway
 
 
 
 
 
 
 
 
 
 
8
  from loosecanvas.exporter import export_session
9
  from loosecanvas.graph_repository import LooseGraphRepository
10
 
@@ -29,7 +39,7 @@ def _repo() -> LooseGraphRepository:
29
  Node(id="n2", kind="function", label="bar"),
30
  ]
31
  )
32
- repo.upsert_edges([Edge(id="e1", source="n1", target="n2", kind="calls")])
33
  repo.upsert_claims(
34
  [
35
  Claim(id="c1", claim_type="fact", target_id="n1", origin="deterministic"),
@@ -55,6 +65,7 @@ def _repo() -> LooseGraphRepository:
55
  claim_type="open_question",
56
  target_id="n1",
57
  origin="model_inferred",
 
58
  text="Why does n1 exist?",
59
  ),
60
  ]
@@ -86,7 +97,11 @@ async def test_unreviewed_model_inferred_excluded() -> None:
86
  assert "c4" not in claim_ids
87
 
88
 
89
- async def test_open_question_routed_to_open_questions_with_text_as_note() -> None:
 
 
 
 
90
  export = await export_session(_repo(), session_id="s1")
91
  claim_ids = {c["id"] for c in export["claims"]}
92
  assert "c5" not in claim_ids
@@ -148,3 +163,307 @@ async def test_export_size_is_reasonable() -> None:
148
  events = [MapEvent(actor="user", action="noop", detail=str(i)) for i in range(200)]
149
  export = await export_session(repo, session_id="s1", events=events)
150
  assert len(json.dumps(export)) < 10_000_000
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  import json
6
 
7
+ from loosecanvas import reducer
8
+ from loosecanvas.contracts import (
9
+ Claim,
10
+ Edge,
11
+ MapEvent,
12
+ Node,
13
+ SceneAction,
14
+ SceneActionType,
15
+ SceneState,
16
+ SuggestedPathway,
17
+ )
18
  from loosecanvas.exporter import export_session
19
  from loosecanvas.graph_repository import LooseGraphRepository
20
 
 
39
  Node(id="n2", kind="function", label="bar"),
40
  ]
41
  )
42
+ repo.upsert_edges([Edge(id="e1", source="n1", target="n2", type="calls")])
43
  repo.upsert_claims(
44
  [
45
  Claim(id="c1", claim_type="fact", target_id="n1", origin="deterministic"),
 
65
  claim_type="open_question",
66
  target_id="n1",
67
  origin="model_inferred",
68
+ review_state="accepted", # reviewed: a trusted open question
69
  text="Why does n1 exist?",
70
  ),
71
  ]
 
97
  assert "c4" not in claim_ids
98
 
99
 
100
+ async def test_reviewed_open_question_routed_to_open_questions_with_text_as_note() -> (
101
+ None
102
+ ):
103
+ """A reviewed (accepted) open_question routes to ``open_questions`` mapping
104
+ ``text`` -> ``note``; it is never emitted into the ``claims`` array."""
105
  export = await export_session(_repo(), session_id="s1")
106
  claim_ids = {c["id"] for c in export["claims"]}
107
  assert "c5" not in claim_ids
 
163
  events = [MapEvent(actor="user", action="noop", detail=str(i)) for i in range(200)]
164
  export = await export_session(repo, session_id="s1", events=events)
165
  assert len(json.dumps(export)) < 10_000_000
166
+
167
+
168
+ # ── Topology-redaction tests (grounded on the REAL producers) ──────────────────
169
+ #
170
+ # These assert the trust gate at the ARTIFACT level: an unreviewed model_inferred
171
+ # NODE/EDGE must not leak as topology even though its governing pending claim is
172
+ # already dropped. State is built by the real producers (``reducer.reduce_turn`` for
173
+ # create_node, the ``find_connection_session`` edge/claim shape, ``_question_claim``)
174
+ # rather than a hand-built fixture, because the leak shipped green under fiction.
175
+
176
+
177
+ def _real_model_node_repo() -> LooseGraphRepository:
178
+ """Repo with a real model_inferred *pending* node from the create_node producer.
179
+
180
+ A trusted ``anchor`` node is seeded so cascade behaviour can be observed against a
181
+ node that should survive the redaction.
182
+ """
183
+ repo = LooseGraphRepository()
184
+ repo.upsert_nodes([Node(id="anchor", kind="concept", label="Anchor")])
185
+ scene = SceneState(scene_id="s", visible_node_ids=["anchor"])
186
+ action = SceneAction(
187
+ type=SceneActionType.create_node,
188
+ target_id="concept::proposed",
189
+ label="Proposed Concept",
190
+ note="a model guess",
191
+ )
192
+ graph_patch, _scene_patch, _events = reducer.reduce_turn([action], repo, scene)
193
+ repo.apply_graph_patch(graph_patch)
194
+ return repo
195
+
196
+
197
+ def _add_find_connection_edge(repo: LooseGraphRepository, src: str, tgt: str) -> str:
198
+ """Mint a model_inferred *pending* hypothesis edge exactly like find_connection does.
199
+
200
+ Mirrors ``turn_logic.find_connection_session``: an Edge stamped
201
+ ``properties["origin"]="model_inferred"`` plus a governing pending relationship Claim.
202
+ Returns the edge id.
203
+ """
204
+ label = "might relate to"
205
+ edge_id = reducer._stable_id("hypedge", src, tgt, label)
206
+ edge = Edge(
207
+ id=edge_id,
208
+ source=src,
209
+ target=tgt,
210
+ type="hypothesis",
211
+ label=label,
212
+ properties={"origin": "model_inferred"},
213
+ )
214
+ edge_claim = Claim(
215
+ id=reducer._stable_id("claim", "relationship", edge_id, label),
216
+ claim_type="relationship",
217
+ target_id=edge_id,
218
+ text=label,
219
+ origin="model_inferred",
220
+ support_state="unverified",
221
+ review_state="pending",
222
+ )
223
+ repo.upsert_edges([edge])
224
+ repo.upsert_claims([edge_claim])
225
+ return edge_id
226
+
227
+
228
+ async def test_unreviewed_model_node_is_redacted_from_topology() -> None:
229
+ """The create_node producer's pending node must NOT leak into exported nodes."""
230
+ repo = _real_model_node_repo()
231
+ export = await export_session(repo, session_id="s1")
232
+ node_ids = {n["id"] for n in export["nodes"]}
233
+ assert "concept::proposed" not in node_ids
234
+ assert "anchor" in node_ids # trusted node survives
235
+
236
+
237
+ async def test_accepted_model_node_appears_in_topology() -> None:
238
+ """Once the governing claim is accepted, the node ships."""
239
+ repo = _real_model_node_repo()
240
+ # Accept the governing claim (the only claim targeting the proposed node).
241
+ [claim] = [c for c in repo.claims.values() if c.target_id == "concept::proposed"]
242
+ repo.upsert_claims([claim.model_copy(update={"review_state": "accepted"})])
243
+ export = await export_session(repo, session_id="s1")
244
+ node_ids = {n["id"] for n in export["nodes"]}
245
+ assert "concept::proposed" in node_ids
246
+
247
+
248
+ async def test_unreviewed_model_edge_is_redacted_from_topology() -> None:
249
+ """A find_connection-style pending hypothesis edge must NOT leak into edges."""
250
+ repo = LooseGraphRepository()
251
+ repo.upsert_nodes(
252
+ [
253
+ Node(id="a", kind="concept", label="A"),
254
+ Node(id="b", kind="concept", label="B"),
255
+ ]
256
+ )
257
+ edge_id = _add_find_connection_edge(repo, "a", "b")
258
+ export = await export_session(repo, session_id="s1")
259
+ edge_ids = {e["id"] for e in export["edges"]}
260
+ assert edge_id not in edge_ids
261
+ # Both endpoints are trusted, so they still ship β€” only the edge is redacted.
262
+ assert {n["id"] for n in export["nodes"]} == {"a", "b"}
263
+
264
+
265
+ async def test_accepted_model_edge_appears_in_topology() -> None:
266
+ """Accepting the governing claim promotes the edge into the export."""
267
+ repo = LooseGraphRepository()
268
+ repo.upsert_nodes(
269
+ [
270
+ Node(id="a", kind="concept", label="A"),
271
+ Node(id="b", kind="concept", label="B"),
272
+ ]
273
+ )
274
+ edge_id = _add_find_connection_edge(repo, "a", "b")
275
+ [claim] = [c for c in repo.claims.values() if c.target_id == edge_id]
276
+ repo.upsert_claims([claim.model_copy(update={"review_state": "accepted"})])
277
+ export = await export_session(repo, session_id="s1")
278
+ edge_ids = {e["id"] for e in export["edges"]}
279
+ assert edge_id in edge_ids
280
+
281
+
282
+ async def test_model_edge_redacted_via_properties_when_no_governing_claim() -> None:
283
+ """Edge with NO governing claim falls back to ``properties['origin']`` for redaction.
284
+
285
+ An element that is not claim-backed (e.g. an agent-created edge not yet reviewed)
286
+ derives its trust from ``properties['origin']``; a ``model_inferred`` such edge must
287
+ still be redacted from export.
288
+ """
289
+ repo = LooseGraphRepository()
290
+ repo.upsert_nodes(
291
+ [
292
+ Node(id="a", kind="concept", label="A"),
293
+ Node(id="b", kind="concept", label="B"),
294
+ ]
295
+ )
296
+ repo.upsert_edges(
297
+ [
298
+ Edge(
299
+ id="a::rel::b",
300
+ source="a",
301
+ target="b",
302
+ type="prerequisite_of",
303
+ label="depends on",
304
+ properties={"origin": "model_inferred"},
305
+ )
306
+ ]
307
+ )
308
+ export = await export_session(repo, session_id="s1")
309
+ assert {e["id"] for e in export["edges"]} == set()
310
+
311
+
312
+ async def test_cascade_drops_edge_whose_endpoint_is_redacted() -> None:
313
+ """A user_asserted edge is dropped when one endpoint node is redacted."""
314
+ repo = _real_model_node_repo() # anchor (trusted) + concept::proposed (redacted)
315
+ # A user-accepted edge between the trusted anchor and the redacted proposed node.
316
+ repo.upsert_edges(
317
+ [
318
+ Edge(
319
+ id="anchor::rel::proposed",
320
+ source="anchor",
321
+ target="concept::proposed",
322
+ type="related",
323
+ label="relates",
324
+ properties={"origin": "user_asserted"},
325
+ )
326
+ ]
327
+ )
328
+ repo.upsert_claims(
329
+ [
330
+ Claim(
331
+ id="claim::rel::proposed",
332
+ claim_type="relationship",
333
+ target_id="anchor::rel::proposed",
334
+ origin="user_asserted",
335
+ support_state="unverified",
336
+ review_state="accepted",
337
+ )
338
+ ]
339
+ )
340
+ export = await export_session(repo, session_id="s1")
341
+ # Even though the edge itself is user_asserted/accepted, its target node was
342
+ # redacted, so the edge must cascade-drop (no dangling edge in the artifact).
343
+ assert {e["id"] for e in export["edges"]} == set()
344
+ assert "concept::proposed" not in {n["id"] for n in export["nodes"]}
345
+
346
+
347
+ async def test_unreviewed_open_question_does_not_export() -> None:
348
+ """``reducer._question_claim`` mints a model_inferred/pending open_question β€” it must
349
+ not leak into ``open_questions``."""
350
+ repo = LooseGraphRepository()
351
+ repo.upsert_nodes([Node(id="n1", kind="concept", label="N1")])
352
+ action = SceneAction(
353
+ type=SceneActionType.propose_question,
354
+ target_id="n1",
355
+ note="Is this even real?",
356
+ )
357
+ question_claim = reducer._question_claim(action)
358
+ assert question_claim.origin == "model_inferred"
359
+ assert question_claim.review_state == "pending"
360
+ repo.upsert_claims([question_claim])
361
+ export = await export_session(repo, session_id="s1")
362
+ assert export["open_questions"] == []
363
+
364
+
365
+ async def test_accepted_open_question_does_export() -> None:
366
+ """A reviewed (accepted) open_question still surfaces in ``open_questions``."""
367
+ repo = LooseGraphRepository()
368
+ repo.upsert_nodes([Node(id="n1", kind="concept", label="N1")])
369
+ action = SceneAction(
370
+ type=SceneActionType.propose_question,
371
+ target_id="n1",
372
+ note="Worth keeping?",
373
+ )
374
+ question_claim = reducer._question_claim(action).model_copy(
375
+ update={"review_state": "accepted"}
376
+ )
377
+ repo.upsert_claims([question_claim])
378
+ export = await export_session(repo, session_id="s1")
379
+ assert any(
380
+ q["target_id"] == "n1" and q["note"] == "Worth keeping?"
381
+ for q in export["open_questions"]
382
+ )
383
+
384
+
385
+ async def test_metadata_counts_reflect_filtered_topology() -> None:
386
+ """node_count/edge_count must match the FILTERED lists, not the raw store."""
387
+ repo = _real_model_node_repo() # anchor kept, concept::proposed redacted
388
+ _add_find_connection_edge(repo, "anchor", "concept::proposed") # pending edge
389
+ export = await export_session(repo, session_id="s1")
390
+ meta = export["metadata"]
391
+ assert meta["node_count"] == len(export["nodes"]) == 1 # only anchor
392
+ assert meta["edge_count"] == len(export["edges"]) == 0
393
+ assert meta["claim_count"] == len(export["claims"])
394
+
395
+
396
+ # ── Text-extracted semantic_edge claim β€” exporter trust gate ─────────────────
397
+
398
+
399
+ def _text_extracted_repo() -> tuple[LooseGraphRepository, str, str]:
400
+ """Build a repo mirroring a text-extracted graph with a semantic_edge claim.
401
+
402
+ Uses the corrected claim shape (target_id = edge_id, not target node id) so
403
+ the exporter's governing-claim lookup works as intended.
404
+
405
+ Returns (repo, edge_id, claim_id).
406
+ """
407
+ repo = LooseGraphRepository()
408
+ repo.upsert_nodes(
409
+ [
410
+ Node(id="concept::alpha", kind="concept", label="Alpha"),
411
+ Node(id="concept::beta", kind="concept", label="Beta"),
412
+ ]
413
+ )
414
+ edge_id = "concept::alpha::explains::concept::beta"
415
+ repo.upsert_edges(
416
+ [
417
+ Edge(
418
+ id=edge_id,
419
+ source="concept::alpha",
420
+ target="concept::beta",
421
+ type="explains",
422
+ label="introduces",
423
+ properties={"origin": "model_inferred", "support_state": "unverified"},
424
+ )
425
+ ]
426
+ )
427
+ # Governing claim with the CORRECTED target_id = edge_id (not target node id).
428
+ claim_id = "claim::semantic::concept::alpha->concept::beta::explains"
429
+ repo.upsert_claims(
430
+ [
431
+ Claim(
432
+ id=claim_id,
433
+ claim_type="semantic_edge",
434
+ source_id="concept::alpha",
435
+ target_id=edge_id,
436
+ text="explains",
437
+ origin="model_inferred",
438
+ support_state="unverified",
439
+ review_state="pending",
440
+ )
441
+ ]
442
+ )
443
+ return repo, edge_id, claim_id
444
+
445
+
446
+ async def test_text_extracted_pending_edge_excluded_from_export() -> None:
447
+ """A text-extracted model_inferred edge is EXCLUDED while its governing claim
448
+ is pending β€” the exporter's trust gate applies to semantic_edge claims just as
449
+ it does to relationship claims from find_connection."""
450
+ repo, edge_id, _claim_id = _text_extracted_repo()
451
+ export = await export_session(repo, session_id="s1")
452
+ edge_ids = {e["id"] for e in export["edges"]}
453
+ assert (
454
+ edge_id not in edge_ids
455
+ ), "pending model_inferred text-extracted edge must be excluded from export"
456
+
457
+
458
+ async def test_text_extracted_accepted_edge_included_in_export() -> None:
459
+ """After the governing semantic_edge claim is accepted, the text-extracted edge
460
+ IS included in the export β€” the trust gate opens correctly."""
461
+ repo, edge_id, claim_id = _text_extracted_repo()
462
+ # Accept the governing claim.
463
+ claim = repo.claims[claim_id]
464
+ repo.upsert_claims([claim.model_copy(update={"review_state": "accepted"})])
465
+ export = await export_session(repo, session_id="s1")
466
+ edge_ids = {e["id"] for e in export["edges"]}
467
+ assert (
468
+ edge_id in edge_ids
469
+ ), "accepted model_inferred text-extracted edge must appear in export"
tests/test_find_connection.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the on-demand "find a hidden connection" beat (C14/C15).
2
+
3
+ Runs against the REAL baked seed fixture (seed_local_ai.json: 3 communities with
4
+ RAG as the articulation hub) so the cross-cluster candidate selection is exercised
5
+ on a genuine graph, not a hand-built one.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from loosecanvas import turn_logic
13
+ from loosecanvas.scene_curator import compute_node_clusters, find_bridge_candidate
14
+ from loosecanvas.turn_logic import (
15
+ do_review_claim,
16
+ find_connection_session,
17
+ inspector_for_selection,
18
+ load_fixture_session,
19
+ )
20
+
21
+ _SEED = Path(__file__).resolve().parents[1] / "fixtures" / "seed_local_ai.json"
22
+
23
+
24
+ async def test_find_connection_proposes_amber_cross_cluster_edge() -> None:
25
+ """The button proposes ONE model_inferred/pending edge across two clusters,
26
+ rendered with the trust hints that drive the amber awaiting-review styling."""
27
+ sid, _ = await load_fixture_session(fixture_path=_SEED)
28
+ record = turn_logic.session_store[sid]
29
+ clusters = compute_node_clusters(record.graph)
30
+ before_edges = set(record.graph.edges)
31
+
32
+ patch = await find_connection_session(sid)
33
+ assert patch is not None, "seed has 3 clusters β†’ a cross-cluster candidate exists"
34
+
35
+ new_edges = set(record.graph.edges) - before_edges
36
+ assert len(new_edges) == 1, "exactly one connection proposed per press"
37
+ edge_id = next(iter(new_edges))
38
+ edge = record.graph.edges[edge_id]
39
+
40
+ # Genuinely cross-cluster (the "surprise").
41
+ assert clusters.get(edge.source) != clusters.get(edge.target)
42
+
43
+ # A model proposal awaiting review β€” origin frozen model_inferred, review pending.
44
+ assert edge.properties.get("origin") == "model_inferred"
45
+ governing = [c for c in record.graph.claims.values() if c.target_id == edge_id]
46
+ assert governing, "the proposed edge must carry a governing claim"
47
+ assert governing[0].origin == "model_inferred"
48
+ assert governing[0].review_state == "pending"
49
+
50
+ # The patch actually renders it: add the edge + flash + pan to it, with the
51
+ # trust hints that make the frontend draw it amber/awaiting-review.
52
+ ops = patch.operations
53
+ add_ops = [o for o in ops if o.op == "add_element" and o.data.get("id") == edge_id]
54
+ assert add_ops, "patch must add the proposed edge element"
55
+ assert add_ops[0].data.get("origin") == "model_inferred"
56
+ assert add_ops[0].data.get("review") == "pending"
57
+ assert any(o.op == "flash" for o in ops)
58
+ assert any(o.op == "animate_camera" for o in ops)
59
+
60
+
61
+ async def test_find_connection_proposes_a_different_pair_each_press() -> None:
62
+ """Two presses never re-propose the same edge (the first is now linked, so it's
63
+ excluded); the second is a different connection or a graceful None."""
64
+ sid, _ = await load_fixture_session(fixture_path=_SEED)
65
+ first = await find_connection_session(sid)
66
+ assert first is not None
67
+ second = await find_connection_session(sid)
68
+ if second is not None:
69
+ first_edge = next(
70
+ o.data["id"] for o in first.operations if o.op == "add_element"
71
+ )
72
+ second_edge = next(
73
+ o.data["id"] for o in second.operations if o.op == "add_element"
74
+ )
75
+ assert first_edge != second_edge
76
+
77
+
78
+ async def test_found_connection_edge_is_reviewable_end_to_end() -> None:
79
+ """The 'you decide' half: selecting the proposed EDGE exposes its governing
80
+ model_inferred/pending claim, and accepting it clears the amber (remove_class
81
+ review-pending on the edge id). Edges are reviewable just like nodes."""
82
+ sid, _ = await load_fixture_session(fixture_path=_SEED)
83
+ record = turn_logic.session_store[sid]
84
+ before = set(record.graph.edges)
85
+ patch = await find_connection_session(sid)
86
+ assert patch is not None
87
+ edge_id = next(iter(set(record.graph.edges) - before))
88
+
89
+ # Selecting the EDGE surfaces its governing claim (previously edges returned None).
90
+ insp = inspector_for_selection(sid, {"kind": "edge", "id": edge_id})
91
+ assert insp is not None, "edge selection must yield an inspector payload"
92
+ gov = insp.get("governing_claims") or []
93
+ assert gov, "the proposed edge must expose a governing claim to review"
94
+ claim = gov[0]
95
+ assert claim["origin"] == "model_inferred"
96
+ assert claim["review_state"] == "pending"
97
+
98
+ # Accepting it clears the amber awaiting-review affordance on the edge.
99
+ result = do_review_claim(sid, claim["id"], "accepted")
100
+ assert result.review_state == "accepted"
101
+ remove_ops = [
102
+ op
103
+ for op in result.renderer_patch.operations
104
+ if op.op == "remove_class" and op.data.get("class") == "review-pending"
105
+ ]
106
+ assert any(
107
+ op.selector == f"#{edge_id}" for op in remove_ops
108
+ ), "accepting the edge must clear review-pending on the edge id"
109
+
110
+
111
+ def test_find_bridge_candidate_none_when_too_small() -> None:
112
+ """Fewer than two visible nodes β†’ no cross-cluster pair β†’ graceful None."""
113
+ from loosecanvas.contracts import Node, SourceSnapshot
114
+ from loosecanvas.graph_repository import LooseGraphRepository
115
+
116
+ repo = LooseGraphRepository.from_extraction(
117
+ [Node(id="n1", kind="concept", label="A")], [], [], SourceSnapshot()
118
+ )
119
+ clusters = compute_node_clusters(repo)
120
+ assert find_bridge_candidate(repo, clusters, ["n1"]) is None
121
+
122
+
123
+ # ── BUG 2: repeated clicks must not flood the graph with hypothesis edges ──────
124
+
125
+
126
+ def _pending_hypedge_claims(record: turn_logic.SessionRecord) -> list[str]:
127
+ """Ids of outstanding model_inferred / pending claims governing hypedge edges."""
128
+ return [
129
+ c.id
130
+ for c in record.graph.claims.values()
131
+ if c.origin == "model_inferred"
132
+ and c.review_state == "pending"
133
+ and c.target_id.startswith("hypedge")
134
+ and c.target_id in record.graph.edges
135
+ ]
136
+
137
+
138
+ async def test_find_connection_caps_outstanding_pending_hypotheses() -> None:
139
+ """BUG 2 regression. Loop find_connection_session until it returns None: it must
140
+ stop at the per-session cap, not flood (the dead ``edge_id in edges`` guard let
141
+ ~23 amber proposals pile up on this real fixture).
142
+
143
+ Grounded on the REAL producer (load_fixture_session over seed_local_ai), so the
144
+ actual bridge-finder + cluster computation are exercised β€” not a hand-built graph.
145
+ """
146
+ from loosecanvas.turn_logic import _MAX_PENDING_HYPOTHESES
147
+
148
+ sid, _ = await load_fixture_session(fixture_path=_SEED)
149
+ record = turn_logic.session_store[sid]
150
+
151
+ successes = 0
152
+ for _ in range(40): # loop well past the cap; correct impl no-ops once hit
153
+ patch = await find_connection_session(sid)
154
+ if patch is None:
155
+ break
156
+ successes += 1
157
+ else: # pragma: no cover - only reached if the cap never engages (the bug)
158
+ raise AssertionError(
159
+ "find_connection_session never returned None in 40 calls β€” "
160
+ "the pending-hypothesis cap is not engaging (flood bug)"
161
+ )
162
+
163
+ assert successes == _MAX_PENDING_HYPOTHESES, (
164
+ f"expected exactly {_MAX_PENDING_HYPOTHESES} proposals before the cap "
165
+ f"no-ops, got {successes} (pre-fix this fixture yielded ~23)"
166
+ )
167
+ assert len(_pending_hypedge_claims(record)) == _MAX_PENDING_HYPOTHESES
168
+
169
+
170
+ async def test_reviewing_a_hypothesis_refills_the_budget() -> None:
171
+ """The cap is on OUTSTANDING pending hypotheses, not lifetime total: after the
172
+ cap is hit, accepting one frees a slot so a new proposal can land."""
173
+ from loosecanvas.turn_logic import _MAX_PENDING_HYPOTHESES
174
+
175
+ sid, _ = await load_fixture_session(fixture_path=_SEED)
176
+ record = turn_logic.session_store[sid]
177
+
178
+ for _ in range(_MAX_PENDING_HYPOTHESES):
179
+ assert await find_connection_session(sid) is not None
180
+ assert await find_connection_session(sid) is None, "cap should be engaged"
181
+
182
+ pending = _pending_hypedge_claims(record)
183
+ assert len(pending) == _MAX_PENDING_HYPOTHESES
184
+ do_review_claim(sid, pending[0], "accepted")
185
+ assert len(_pending_hypedge_claims(record)) == _MAX_PENDING_HYPOTHESES - 1
186
+
187
+ # A genuinely new cross-cluster pair exists (pre-fix probe found ~23), so freeing
188
+ # a slot lets a fresh proposal through.
189
+ assert await find_connection_session(sid) is not None
tests/test_fixture_adapter.py CHANGED
@@ -121,3 +121,34 @@ def test_annotated_graph_loads() -> None:
121
  assert isinstance(scene, SceneState)
122
  # The annotated variant adds notes / hypotheses / open questions on top of the base.
123
  assert len(graph.claims) > 3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  assert isinstance(scene, SceneState)
122
  # The annotated variant adds notes / hypotheses / open questions on top of the base.
123
  assert len(graph.claims) > 3
124
+
125
+
126
+ def test_load_fixture_from_dict_missing_graph_raises_value_error() -> None:
127
+ """A partial/corrupt fixture (no ``graph`` key) must raise ``ValueError`` β€” NOT a
128
+ bare ``KeyError`` that bubbles up as an opaque 500 on /api/load_graph."""
129
+ data = {
130
+ "scene": {
131
+ "scene_id": "s0",
132
+ "visible_node_ids": [],
133
+ "visible_edge_ids": [],
134
+ "fogged_node_ids": [],
135
+ "node_positions": {},
136
+ },
137
+ }
138
+ with pytest.raises(ValueError, match="fixture missing graph/scene"):
139
+ load_fixture_from_dict(data)
140
+
141
+
142
+ def test_load_fixture_from_dict_missing_scene_raises_value_error() -> None:
143
+ """A partial fixture (no ``scene`` key) must raise ``ValueError`` (mapped to 400),
144
+ not ``KeyError`` (opaque 500)."""
145
+ data = {
146
+ "graph": {
147
+ "nodes": [{"id": "n1", "kind": "concept", "label": "Concept One"}],
148
+ "edges": [],
149
+ "claims": [],
150
+ "metadata": {"schema_version": "0.1.0", "node_count": 1, "edge_count": 0},
151
+ },
152
+ }
153
+ with pytest.raises(ValueError, match="fixture missing graph/scene"):
154
+ load_fixture_from_dict(data)
tests/test_graph_repository.py CHANGED
@@ -19,11 +19,14 @@ from loosecanvas.contracts import (
19
  Edge,
20
  GraphPatch,
21
  Node,
 
 
22
  ScenePatch,
23
  SceneState,
24
  SourceSnapshot,
25
  )
26
  from loosecanvas.graph_repository import LooseGraphRepository
 
27
 
28
  # ── Fixtures ──────────────────────────────────────────────────────────────────
29
 
@@ -167,6 +170,53 @@ def test_apply_scene_patch_idempotent() -> None:
167
  assert once == twice
168
 
169
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  # ── fog_replenish ─────────────────────────────────────────────────────────────
171
 
172
 
 
19
  Edge,
20
  GraphPatch,
21
  Node,
22
+ SceneAction,
23
+ SceneActionType,
24
  ScenePatch,
25
  SceneState,
26
  SourceSnapshot,
27
  )
28
  from loosecanvas.graph_repository import LooseGraphRepository
29
+ from loosecanvas.reducer import reduce_turn
30
 
31
  # ── Fixtures ──────────────────────────────────────────────────────────────────
32
 
 
170
  assert once == twice
171
 
172
 
173
+ # ── apply_scene_patch: carry forward cluster colours + search highlight ────────
174
+
175
+
176
+ def test_apply_scene_patch_benign_turn_preserves_clusters_and_highlight() -> None:
177
+ """Grounded on the REAL producer: a benign turn (a ``reveal``) reduces to a
178
+ ``ScenePatch`` that does NOT touch highlights, so apply must carry the existing
179
+ ``node_cluster_map`` and ``highlighted_ids`` forward.
180
+
181
+ Regression for the bug where the next turn after 'Colour clusters' / a search wiped
182
+ both β€” diff_scene then emitted ``remove_class cluster-N`` and removed the highlight.
183
+ """
184
+ repo = _populated() # nodes a,b,c,d; edges a-b, b-c, a-c
185
+ # A scene that already has cluster colours (from 'Colour clusters') and a search
186
+ # highlight, plus a fogged node "d" that the benign turn will reveal.
187
+ scene = SceneState(
188
+ scene_id="s1",
189
+ visible_node_ids=["a", "b"],
190
+ fogged_node_ids=["d"],
191
+ highlighted_ids=["a"], # e.g. a prior search highlight
192
+ node_cluster_map={"a": 0, "b": 1}, # e.g. 'Colour clusters'
193
+ )
194
+ # Build the patch via the real reducer with a benign reveal β€” NOT a hand-built patch.
195
+ reveal = SceneAction(type=SceneActionType.reveal, target_id="d")
196
+ _graph_patch, scene_patch, _events = reduce_turn([reveal], repo, scene)
197
+ # Ground the premise: the real producer leaves highlights unset on a benign turn.
198
+ assert scene_patch.set_highlighted_ids == []
199
+
200
+ result = repo.apply_scene_patch(scene, scene_patch)
201
+
202
+ # The benign reveal happened ...
203
+ assert "d" in result.visible_node_ids
204
+ # ... and BOTH the cluster colours and the search highlight survived.
205
+ assert result.node_cluster_map == {"a": 0, "b": 1}
206
+ assert result.highlighted_ids == ["a"]
207
+
208
+
209
+ def test_apply_scene_patch_explicit_highlight_still_overrides() -> None:
210
+ """An explicit (non-empty) ``set_highlighted_ids`` must still replace the old set."""
211
+ repo = LooseGraphRepository()
212
+ scene = SceneState(highlighted_ids=["a"], node_cluster_map={"a": 0})
213
+ patch = ScenePatch(set_highlighted_ids=["b", "c"])
214
+ result = repo.apply_scene_patch(scene, patch)
215
+ assert result.highlighted_ids == ["b", "c"]
216
+ # node_cluster_map is still carried forward (the patch has no cluster slot).
217
+ assert result.node_cluster_map == {"a": 0}
218
+
219
+
220
  # ── fog_replenish ─────────────────────────────────────────────────────────────
221
 
222
 
tests/test_magic_build_opener.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Magic-build turn-one opener: turn one greets in the agent's voice, not a canned
2
+ count template.
3
+
4
+ The first Send builds the graph via the deterministic extractor (which mints the
5
+ session but does NOT run the conversational agent). To make turn one feel like every
6
+ later turn, ``main.py`` streams ``agent_harness.stream_build_opener`` into the chat
7
+ right after the graph paints. These tests pin that contract:
8
+
9
+ * ``stream_text_session`` success no longer carries a canned chat message (the voice
10
+ moved to the streamed opener).
11
+ * ``stream_build_opener`` streams the model's own tokens, hands the model the concepts
12
+ that were actually extracted (topic-aware), and falls back to a warm, concept-naming
13
+ line β€” never silent, never a bare count β€” if the model call fails.
14
+
15
+ Real ``SessionRecord`` / ``LooseGraphRepository`` objects are used (not MagicMocks) so
16
+ the asserted shapes match the real producer; only the LLM is faked.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import time
22
+ from collections.abc import AsyncIterator
23
+ from typing import Any
24
+ from unittest.mock import AsyncMock, patch
25
+
26
+ from loosecanvas import turn_logic
27
+ from loosecanvas.agent_harness import stream_build_opener
28
+ from loosecanvas.contracts import Node, SceneState, SourceSnapshot
29
+ from loosecanvas.graph_repository import LooseGraphRepository
30
+ from loosecanvas.llm_client import LLMClient
31
+ from loosecanvas.turn_logic import RendererPatch, SessionRecord
32
+
33
+
34
+ def _record_with(sid: str, labels: list[str]) -> SessionRecord:
35
+ nodes = [
36
+ Node(id=f"n{i}", kind="concept", label=lbl) for i, lbl in enumerate(labels)
37
+ ]
38
+ repo = LooseGraphRepository.from_extraction(nodes, [], [], SourceSnapshot())
39
+ return SessionRecord(
40
+ session_id=sid,
41
+ graph=repo,
42
+ scene=SceneState(scene_id="s"),
43
+ history=[],
44
+ graph_version=1,
45
+ created_at=time.time(),
46
+ last_seen=time.time(),
47
+ render_scene_id="s",
48
+ patch_id=0,
49
+ )
50
+
51
+
52
+ class _FakeChunk:
53
+ def __init__(self, content: str) -> None:
54
+ self.content = content
55
+
56
+
57
+ class _FakeModel:
58
+ """Captures the prompt messages and streams the given chunks back as content."""
59
+
60
+ def __init__(self, chunks: list[str]) -> None:
61
+ self._chunks = chunks
62
+ self.seen: Any = None
63
+
64
+ async def astream(self, messages: Any) -> AsyncIterator[_FakeChunk]:
65
+ self.seen = messages
66
+ for chunk in self._chunks:
67
+ yield _FakeChunk(chunk)
68
+
69
+
70
+ class _BoomModel:
71
+ async def astream(self, messages: Any) -> AsyncIterator[_FakeChunk]:
72
+ raise RuntimeError("model down")
73
+ yield _FakeChunk("") # pragma: no cover β€” makes this a generator
74
+
75
+
76
+ class _MidStreamBoomModel:
77
+ """Streams one good chunk, then dies β€” exercises the no-double-post guard."""
78
+
79
+ async def astream(self, messages: Any) -> AsyncIterator[_FakeChunk]:
80
+ yield _FakeChunk("Nice start β€” ")
81
+ raise RuntimeError("model died mid-stream")
82
+
83
+
84
+ # ── stream_text_session: the canned turn-one template is gone ──
85
+
86
+
87
+ async def test_stream_text_session_success_has_no_chat_message() -> None:
88
+ sid = "opener-no-canned"
89
+ patch_obj = RendererPatch(patch_id=0, is_snapshot=True, scene_id="s", operations=[])
90
+ with patch(
91
+ "loosecanvas.turn_logic.load_text_session", new_callable=AsyncMock
92
+ ) as mock_load:
93
+ mock_load.return_value = (sid, patch_obj)
94
+ turn_logic.session_store[sid] = _record_with(sid, ["Alpha", "Beta"])
95
+ try:
96
+ events = [
97
+ e async for e in turn_logic.stream_text_session("t", "t", LLMClient())
98
+ ]
99
+ finally:
100
+ turn_logic.session_store.pop(sid, None)
101
+
102
+ final = next(e for e in events if not e.is_partial)
103
+ assert not final.is_error
104
+ assert final.session_id == sid
105
+ assert final.canvas_patch == patch_obj.model_dump(mode="json")
106
+ assert (
107
+ final.chat_message == []
108
+ ), "turn-one voice now streams via the opener, not a canned count template"
109
+
110
+
111
+ # ── stream_build_opener: streams the model's own voice, topic-aware ──
112
+
113
+
114
+ async def test_opener_streams_model_tokens() -> None:
115
+ sid = "opener-ok"
116
+ turn_logic.session_store[sid] = _record_with(sid, ["Alpha", "Beta"])
117
+ fake = _FakeModel(["Nice β€” ", "I see Alpha", " and Beta here."])
118
+ try:
119
+ with patch("loosecanvas.agent_harness.make_model", return_value=fake):
120
+ chunks = [c async for c in stream_build_opener(sid)]
121
+ finally:
122
+ turn_logic.session_store.pop(sid, None)
123
+
124
+ assert chunks == ["Nice β€” ", "I see Alpha", " and Beta here."]
125
+ # The model was handed the concepts that were actually extracted (topic-aware).
126
+ assert fake.seen is not None
127
+ human_msg = fake.seen[-1]
128
+ assert "Alpha" in human_msg[1] and "Beta" in human_msg[1]
129
+
130
+
131
+ async def test_opener_falls_back_when_model_fails() -> None:
132
+ sid = "opener-fail"
133
+ turn_logic.session_store[sid] = _record_with(sid, ["Alpha", "Beta"])
134
+ try:
135
+ with patch("loosecanvas.agent_harness.make_model", return_value=_BoomModel()):
136
+ chunks = [c async for c in stream_build_opener(sid)]
137
+ finally:
138
+ turn_logic.session_store.pop(sid, None)
139
+
140
+ text = "".join(chunks)
141
+ assert text, "opener must never land silent, even when the model call fails"
142
+ assert "Alpha" in text, "fallback must still name a real extracted concept"
143
+ # Not a bare count: it should read like a sentence, not 'N concepts, M relations'.
144
+ assert "first pass" in text.lower()
145
+
146
+
147
+ async def test_opener_does_not_double_post_on_midstream_failure() -> None:
148
+ # If the model dies AFTER emitting real tokens, we keep what streamed and do
149
+ # NOT also append the static fallback on top of it.
150
+ sid = "opener-midboom"
151
+ turn_logic.session_store[sid] = _record_with(sid, ["Alpha"])
152
+ try:
153
+ with patch(
154
+ "loosecanvas.agent_harness.make_model", return_value=_MidStreamBoomModel()
155
+ ):
156
+ chunks = [c async for c in stream_build_opener(sid)]
157
+ finally:
158
+ turn_logic.session_store.pop(sid, None)
159
+
160
+ text = "".join(chunks)
161
+ assert text == "Nice start β€” ", "must keep streamed tokens, not append a fallback"
162
+ assert "first pass" not in text.lower(), "fallback must not double-post"
163
+
164
+
165
+ async def test_opener_handles_missing_session_gracefully() -> None:
166
+ # A session that vanished (evicted) must not crash the opener β€” it falls back.
167
+ fake = _FakeModel(["hello"])
168
+ with patch("loosecanvas.agent_harness.make_model", return_value=fake):
169
+ chunks = [c async for c in stream_build_opener("does-not-exist")]
170
+ assert "".join(chunks) == "hello"
171
+ # Even with no record, the model was still handed a (generic) opener prompt.
172
+ assert fake.seen is not None
tests/test_p1_03_claim_review_consumer.py CHANGED
@@ -68,6 +68,41 @@ async def test_do_review_claim_returns_renderer_patch() -> None:
68
  assert isinstance(result.renderer_patch, RendererPatch)
69
 
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  async def test_illegal_transition_raises_invalid_transition_error() -> None:
72
  """SC-C: do_review_claim raises InvalidTransitionError (not HTTPException) for 409 cases."""
73
  claim = Claim(
 
68
  assert isinstance(result.renderer_patch, RendererPatch)
69
 
70
 
71
+ async def test_accept_clears_the_review_pending_class_with_the_real_selector() -> None:
72
+ """Lynchpin regression: accepting a claim must emit remove_class for the SAME
73
+ class the stylesheet uses (`review-pending`, hyphenated from review_state
74
+ "pending"). A prior typo emitted `pending-review`, so the amber awaiting-review
75
+ border never cleared on Accept. Pin the exact payload β€” the older SC-B test only
76
+ checked the patch existed, which is how the bug shipped green.
77
+ """
78
+ claim = Claim(
79
+ id="c1",
80
+ claim_type="label",
81
+ target_id="n1",
82
+ origin="model_inferred",
83
+ review_state="pending",
84
+ support_state="unverified",
85
+ text="test",
86
+ )
87
+ sid = _make_session([claim])
88
+ result = do_review_claim(sid, "c1", "accepted")
89
+ remove_class_ops = [
90
+ op for op in result.renderer_patch.operations if op.op == "remove_class"
91
+ ]
92
+ assert (
93
+ remove_class_ops
94
+ ), "accept must emit a remove_class op to clear the amber border"
95
+ classes = {op.data.get("class") for op in remove_class_ops}
96
+ assert (
97
+ "review-pending" in classes
98
+ ), f"accept must remove the stylesheet class 'review-pending', got {classes}"
99
+ assert (
100
+ "pending-review" not in classes
101
+ ), "regression: the old buggy class name is back"
102
+ # Targets the reviewed node by id.
103
+ assert any(op.selector == "#n1" for op in remove_class_ops)
104
+
105
+
106
  async def test_illegal_transition_raises_invalid_transition_error() -> None:
107
  """SC-C: do_review_claim raises InvalidTransitionError (not HTTPException) for 409 cases."""
108
  claim = Claim(
tests/test_renderer_contract_parity.py CHANGED
@@ -10,8 +10,10 @@ unknown ops). This test makes that drift fail loudly.
10
  We DERIVE the produced op-set from source (rather than hardcoding it) so adding
11
  a NEW producer with a NEW op trips this test until ``types.ts`` is updated. We
12
  assert SUBSET, not equality: the TS union legitimately carries union-only extras
13
- (``flash`` is the unimplemented M18 highlight op; ``select`` is a frontend→
14
- backend event op, never produced by the backend renderer adapter).
 
 
15
  """
16
 
17
  from __future__ import annotations
@@ -25,9 +27,10 @@ SRC_DIR = REPO_ROOT / "src" / "loosecanvas"
25
  TYPES_TS = REPO_ROOT / "cytoscapecanvas" / "frontend" / "types.ts"
26
 
27
  # Union-only extras the TS side declares but the backend never produces:
28
- # flash β€” unimplemented M18 highlight op (planned, no producer yet)
29
  # select β€” a frontendβ†’backend event op, not a backend-emitted render command
30
- KNOWN_UNION_ONLY = {"flash", "select"}
 
 
31
 
32
  # op="..." in Python sources (the RendererCommand constructor argument).
33
  _PY_OP_RE = re.compile(r'op="([a-z_]+)"')
 
10
  We DERIVE the produced op-set from source (rather than hardcoding it) so adding
11
  a NEW producer with a NEW op trips this test until ``types.ts`` is updated. We
12
  assert SUBSET, not equality: the TS union legitimately carries union-only extras
13
+ (``select`` is a frontend→backend event op, never produced by the backend renderer
14
+ adapter). NB: ``flash`` USED to be union-only but now has a backend producer
15
+ (``turn_logic.find_connection_session`` flashes a freshly-proposed hidden-connection
16
+ edge), so it is no longer in the allow-list.
17
  """
18
 
19
  from __future__ import annotations
 
27
  TYPES_TS = REPO_ROOT / "cytoscapecanvas" / "frontend" / "types.ts"
28
 
29
  # Union-only extras the TS side declares but the backend never produces:
 
30
  # select β€” a frontendβ†’backend event op, not a backend-emitted render command
31
+ # (flash WAS here, but find_connection_session now emits op="flash", so it's
32
+ # produced by the backend and no longer union-only.)
33
+ KNOWN_UNION_ONLY = {"select"}
34
 
35
  # op="..." in Python sources (the RendererCommand constructor argument).
36
  _PY_OP_RE = re.compile(r'op="([a-z_]+)"')
tests/test_review_agent_tool.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the review_element agent tool (Task A).
2
+
3
+ Covers:
4
+ - review_element on a model_inferred pending NODE claim
5
+ - review_element on a model_inferred pending EDGE claim (edge variant)
6
+ - finalize_agent_turn with EMPTY accumulated_actions but non-empty review_patches
7
+ returns a TurnResult (not None) carrying the review ops
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import time
14
+ import uuid
15
+ from collections.abc import Iterator
16
+
17
+ import pytest
18
+ from loosecanvas import turn_logic
19
+ from loosecanvas.agent_tools import (
20
+ _current_turn,
21
+ finalize_agent_turn,
22
+ get_turn_context,
23
+ review_element,
24
+ set_turn_context,
25
+ )
26
+ from loosecanvas.contracts import Claim, Edge, Node, SceneState, SourceSnapshot
27
+ from loosecanvas.graph_repository import LooseGraphRepository
28
+ from loosecanvas.turn_logic import (
29
+ RendererPatch,
30
+ TurnResult,
31
+ session_locks,
32
+ session_store,
33
+ )
34
+
35
+ # ── Session isolation ─────────────────────────────────────────────────────────
36
+
37
+
38
+ @pytest.fixture(autouse=True)
39
+ def _isolate_sessions() -> Iterator[None]:
40
+ session_store.clear()
41
+ session_locks.clear()
42
+ yield
43
+ session_store.clear()
44
+ session_locks.clear()
45
+
46
+
47
+ @pytest.fixture(autouse=True)
48
+ def _reset_agent_context() -> Iterator[None]:
49
+ _current_turn.set(None)
50
+ yield
51
+ _current_turn.set(None)
52
+
53
+
54
+ # ── Session helpers ───────────────────────────────────────────────────────────
55
+
56
+
57
+ def _make_session(claims: list[Claim] | None = None) -> str:
58
+ """Create a minimal session with node n1 in session_store; return session_id."""
59
+ node = Node(id="n1", kind="concept", label="Test Node")
60
+ repo = LooseGraphRepository.from_extraction(
61
+ [node], [], claims or [], SourceSnapshot()
62
+ )
63
+ scene = SceneState(scene_id="s1", visible_node_ids=["n1"])
64
+ sid = str(uuid.uuid4())
65
+ now = time.time()
66
+ turn_logic.session_store[sid] = turn_logic.SessionRecord(
67
+ session_id=sid,
68
+ graph=repo,
69
+ scene=scene,
70
+ history=[],
71
+ graph_version=1,
72
+ created_at=now,
73
+ last_seen=now,
74
+ render_scene_id="s1",
75
+ )
76
+ return sid
77
+
78
+
79
+ def _pending_claim() -> Claim:
80
+ return Claim(
81
+ id="c1",
82
+ claim_type="label",
83
+ target_id="n1",
84
+ origin="model_inferred",
85
+ review_state="pending",
86
+ support_state="unverified",
87
+ text="test",
88
+ )
89
+
90
+
91
+ def _make_session_with_pending_edge_claim() -> str:
92
+ """Session with n1, n2, and a model_inferred pending edge claim on e1."""
93
+ nodes = [
94
+ Node(id="n1", kind="concept", label="N1"),
95
+ Node(id="n2", kind="concept", label="N2"),
96
+ ]
97
+ edge = Edge(id="e1", source="n1", target="n2", kind="related_to", label="links")
98
+ claim = Claim(
99
+ id="c-edge",
100
+ claim_type="relationship",
101
+ target_id="e1",
102
+ origin="model_inferred",
103
+ review_state="pending",
104
+ support_state="unverified",
105
+ text="links",
106
+ )
107
+ repo = LooseGraphRepository.from_extraction(
108
+ nodes, [edge], [claim], SourceSnapshot()
109
+ )
110
+ scene = SceneState(
111
+ scene_id="s1",
112
+ visible_node_ids=["n1", "n2"],
113
+ visible_edge_ids=["e1"],
114
+ )
115
+ sid = str(uuid.uuid4())
116
+ now = time.time()
117
+ turn_logic.session_store[sid] = turn_logic.SessionRecord(
118
+ session_id=sid,
119
+ graph=repo,
120
+ scene=scene,
121
+ history=[],
122
+ graph_version=1,
123
+ created_at=now,
124
+ last_seen=now,
125
+ render_scene_id="s1",
126
+ )
127
+ return sid
128
+
129
+
130
+ # ── review_element: node tests ────────────────────────────────────────────────
131
+
132
+
133
+ def test_review_element_node_accept_returns_ok_and_transitions_claim() -> None:
134
+ """review_element('n1', 'accepted') returns status=ok and flips the claim."""
135
+ sid = _make_session([_pending_claim()])
136
+ set_turn_context(sid)
137
+
138
+ raw = review_element.invoke({"target_id": "n1", "action": "accepted"})
139
+ data = json.loads(raw)
140
+
141
+ assert data["status"] == "ok", f"expected ok, got: {data}"
142
+ assert data["target_id"] == "n1"
143
+ assert data["review_state"] == "accepted"
144
+ # The stored claim actually transitioned.
145
+ assert turn_logic.session_store[sid].graph.claims["c1"].review_state == "accepted"
146
+
147
+
148
+ def test_review_element_node_accept_appends_review_patch() -> None:
149
+ """Accepted review appends a RendererPatch to ctx.review_patches."""
150
+ sid = _make_session([_pending_claim()])
151
+ set_turn_context(sid)
152
+
153
+ review_element.invoke({"target_id": "n1", "action": "accepted"})
154
+ ctx = get_turn_context()
155
+
156
+ assert len(ctx.review_patches) == 1
157
+ patch = ctx.review_patches[0]
158
+ assert isinstance(patch, RendererPatch)
159
+ # Should carry a remove_class (clear_pending) op.
160
+ ops = [o.op for o in patch.operations]
161
+ assert "remove_class" in ops or "clear_pending" in ops or len(patch.operations) > 0
162
+
163
+
164
+ def test_review_element_node_reject_returns_ok_and_transitions_claim() -> None:
165
+ """review_element('n1', 'rejected') returns status=ok with rejected state."""
166
+ sid = _make_session([_pending_claim()])
167
+ set_turn_context(sid)
168
+
169
+ raw = review_element.invoke({"target_id": "n1", "action": "rejected"})
170
+ data = json.loads(raw)
171
+
172
+ assert data["status"] == "ok"
173
+ assert data["review_state"] == "rejected"
174
+ assert turn_logic.session_store[sid].graph.claims["c1"].review_state == "rejected"
175
+
176
+
177
+ def test_review_element_unknown_id_returns_not_found() -> None:
178
+ """An unknown id returns status=not_found gracefully."""
179
+ sid = _make_session([_pending_claim()])
180
+ set_turn_context(sid)
181
+
182
+ raw = review_element.invoke({"target_id": "does_not_exist", "action": "accepted"})
183
+ data = json.loads(raw)
184
+
185
+ assert data["status"] == "not_found"
186
+
187
+
188
+ def test_review_element_invalid_action_returns_error() -> None:
189
+ """An invalid action value returns status=error."""
190
+ sid = _make_session([_pending_claim()])
191
+ set_turn_context(sid)
192
+
193
+ raw = review_element.invoke({"target_id": "n1", "action": "approve"})
194
+ data = json.loads(raw)
195
+
196
+ assert data["status"] == "error"
197
+
198
+
199
+ def test_review_element_no_pending_claims_returns_not_reviewable() -> None:
200
+ """A node without reviewable pending claims returns not_reviewable."""
201
+ sid = _make_session([]) # no claims
202
+ set_turn_context(sid)
203
+
204
+ raw = review_element.invoke({"target_id": "n1", "action": "accepted"})
205
+ data = json.loads(raw)
206
+
207
+ assert data["status"] == "not_reviewable"
208
+
209
+
210
+ # ── review_element: edge tests ────────────────────────────────────────────────
211
+
212
+
213
+ def test_review_element_edge_accept_transitions_claim() -> None:
214
+ """review_element on an edge id transitions the edge claim."""
215
+ sid = _make_session_with_pending_edge_claim()
216
+ set_turn_context(sid)
217
+
218
+ raw = review_element.invoke({"target_id": "e1", "action": "accepted"})
219
+ data = json.loads(raw)
220
+
221
+ assert data["status"] == "ok"
222
+ assert data["review_state"] == "accepted"
223
+ assert (
224
+ turn_logic.session_store[sid].graph.claims["c-edge"].review_state == "accepted"
225
+ )
226
+
227
+
228
+ def test_review_element_edge_appends_review_patch() -> None:
229
+ """Edge review appends a RendererPatch to ctx.review_patches."""
230
+ sid = _make_session_with_pending_edge_claim()
231
+ set_turn_context(sid)
232
+
233
+ review_element.invoke({"target_id": "e1", "action": "accepted"})
234
+ ctx = get_turn_context()
235
+
236
+ assert len(ctx.review_patches) == 1
237
+
238
+
239
+ # ── finalize_agent_turn: review-only (empty accumulated_actions) ──────────────
240
+
241
+
242
+ def test_finalize_with_review_patches_only_returns_turn_result() -> None:
243
+ """finalize_agent_turn with no SceneActions but non-empty review_patches must
244
+ return a TurnResult (not None) whose renderer_patch carries the review ops."""
245
+ sid = _make_session([_pending_claim()])
246
+ set_turn_context(sid)
247
+
248
+ # Invoke review_element β€” this adds a RendererPatch to ctx.review_patches without
249
+ # touching accumulated_actions.
250
+ review_element.invoke({"target_id": "n1", "action": "accepted"})
251
+ ctx = get_turn_context()
252
+ assert ctx.accumulated_actions == []
253
+ assert len(ctx.review_patches) == 1
254
+
255
+ result = finalize_agent_turn(sid, {}, "I accepted that for you.")
256
+
257
+ assert result is not None, "review-only finalize must return TurnResult, not None"
258
+ assert isinstance(result, TurnResult)
259
+ # The renderer patch must contain the review ops.
260
+ assert result.renderer_patch is not None
261
+ assert len(result.renderer_patch.operations) > 0
262
+
263
+
264
+ def test_finalize_with_no_actions_no_reviews_returns_none() -> None:
265
+ """Pure-speech turn (no actions, no reviews) still returns None."""
266
+ sid = _make_session([_pending_claim()])
267
+ set_turn_context(sid)
268
+
269
+ result = finalize_agent_turn(sid, {}, "Just talking.")
270
+
271
+ assert result is None
tests/test_review_amber_clear.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BUG 1 regression: the amber 'review-pending' affordance must NOT clear after
2
+ accepting just ONE of a node's several model_inferred claims.
3
+
4
+ A target (node/edge) may govern 2-3 model_inferred claims. The amber awaiting-review
5
+ border covers the WHOLE target, so it may only clear once EVERY model_inferred claim
6
+ on it has been reviewed. The original ``_review_canvas_hint_dict`` emitted
7
+ ``clear_pending`` on ANY accept, so the amber vanished while sibling claims were still
8
+ pending β€” silently telling the user "reviewed" when it wasn't.
9
+
10
+ These tests are grounded on the REAL producer: ``load_fixture_session`` over the
11
+ canonical ``seed_local_ai`` fixture (whose node ``concept::local_language_model``
12
+ genuinely carries two model_inferred pending claims), not a hand-built fixture β€” the
13
+ prompt-assumed shape (two claims both ``claim_type`` matching) is NOT how the real
14
+ producer emits per-node claims (the second is a ``semantic_edge`` claim), and only a
15
+ real-shape test exercises that.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import time
21
+ import uuid
22
+ from pathlib import Path
23
+
24
+ from loosecanvas import turn_logic
25
+ from loosecanvas.contracts import Claim, Node, SceneState, SourceSnapshot
26
+ from loosecanvas.graph_repository import LooseGraphRepository
27
+ from loosecanvas.turn_logic import do_review_claim, load_fixture_session
28
+
29
+ _FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "seed_local_ai.json"
30
+
31
+
32
+ def _make_session(claims: list[Claim]) -> str:
33
+ """Minimal single-node session in turn_logic.session_store; return session_id."""
34
+ node = Node(id="n1", kind="concept", label="Test Node")
35
+ repo = LooseGraphRepository.from_extraction([node], [], claims, SourceSnapshot())
36
+ scene = SceneState(scene_id="s1", visible_node_ids=["n1"])
37
+ sid = str(uuid.uuid4())
38
+ now = time.time()
39
+ turn_logic.session_store[sid] = turn_logic.SessionRecord(
40
+ session_id=sid,
41
+ graph=repo,
42
+ scene=scene,
43
+ history=[],
44
+ graph_version=1,
45
+ created_at=now,
46
+ last_seen=now,
47
+ render_scene_id="s1",
48
+ )
49
+ return sid
50
+
51
+
52
+ def _remove_class_classes(result: turn_logic.ReviewClaimResult) -> set[str]:
53
+ return {
54
+ str(op.data.get("class"))
55
+ for op in result.renderer_patch.operations
56
+ if op.op == "remove_class"
57
+ }
58
+
59
+
60
+ async def test_amber_persists_until_all_node_claims_reviewed_real_fixture() -> None:
61
+ """REAL-producer grounding: the ``seed_local_ai`` node
62
+ ``concept::local_language_model`` carries exactly ONE model_inferred pending
63
+ claim (its ``concept_node`` claim) after the edge-claim target_id bug was fixed
64
+ (semantic_edge claims now correctly target edge ids, not the target node id).
65
+
66
+ This test verifies that accepting the single governing claim on a real-fixture
67
+ node fires ``clear_pending`` exactly once β€” the correct single-claim clear behavior
68
+ on a real-producer shape.
69
+
70
+ The multi-claim path (accept #1 leaves amber, accept #2 clears) is exercised by
71
+ the hand-built ``test_accept_one_of_two_leaves_amber_then_second_clears`` below,
72
+ which directly constructs a two-claim node session.
73
+ """
74
+ sid, _snapshot = await load_fixture_session(fixture_path=_FIXTURE)
75
+ record = turn_logic.session_store[sid]
76
+ target = "concept::local_language_model"
77
+ pending = [
78
+ c.id
79
+ for c in record.graph.claims.values()
80
+ if c.target_id == target
81
+ and c.origin == "model_inferred"
82
+ and c.review_state == "pending"
83
+ ]
84
+ assert len(pending) == 1, (
85
+ "fixture changed: after the edge-claim bug fix, "
86
+ f"concept::local_language_model should have exactly 1 model_inferred "
87
+ f"pending claim (the concept_node claim), got {len(pending)}"
88
+ )
89
+
90
+ result = do_review_claim(sid, pending[0], "accepted")
91
+ assert (
92
+ result.canvas_hint["action"] == "clear_pending"
93
+ ), "accepting the only model_inferred claim on the node must fire clear_pending"
94
+ assert "review-pending" in _remove_class_classes(result)
95
+
96
+
97
+ async def test_accept_one_of_two_leaves_amber_then_second_clears() -> None:
98
+ """Focused unit form of BUG 1 on a node governing two model_inferred pending
99
+ claims (both targeting the node): accept #1 -> no clear_pending (amber stays);
100
+ accept #2 -> clear_pending fires."""
101
+ c1 = Claim(
102
+ id="c1",
103
+ claim_type="label",
104
+ target_id="n1",
105
+ origin="model_inferred",
106
+ review_state="pending",
107
+ support_state="unverified",
108
+ text="first",
109
+ )
110
+ c2 = Claim(
111
+ id="c2",
112
+ claim_type="summary",
113
+ target_id="n1",
114
+ origin="model_inferred",
115
+ review_state="pending",
116
+ support_state="unverified",
117
+ text="second",
118
+ )
119
+ sid = _make_session([c1, c2])
120
+
121
+ r1 = do_review_claim(sid, "c1", "accepted")
122
+ assert r1.canvas_hint == {"action": "none", "target_id": "n1"}
123
+ assert "review-pending" not in _remove_class_classes(r1)
124
+
125
+ r2 = do_review_claim(sid, "c2", "accepted")
126
+ assert r2.canvas_hint == {"action": "clear_pending", "target_id": "n1"}
127
+ assert "review-pending" in _remove_class_classes(r2)
128
+
129
+
130
+ async def test_single_claim_node_still_clears_amber_on_accept() -> None:
131
+ """No regression for the simple case: a node with ONE model_inferred pending
132
+ claim clears the amber on accept (the old behavior, preserved)."""
133
+ c1 = Claim(
134
+ id="c1",
135
+ claim_type="label",
136
+ target_id="n1",
137
+ origin="model_inferred",
138
+ review_state="pending",
139
+ support_state="unverified",
140
+ text="only",
141
+ )
142
+ sid = _make_session([c1])
143
+ result = do_review_claim(sid, "c1", "accepted")
144
+ assert result.canvas_hint == {"action": "clear_pending", "target_id": "n1"}
145
+ assert "review-pending" in _remove_class_classes(result)
146
+
147
+
148
+ async def test_rejection_does_not_remove_node_when_sibling_claims_remain() -> None:
149
+ """Bug fix: rejecting a NODE claim must NOT remove the node from canvas, even when
150
+ sibling model_inferred claims remain pending. A rejected node claim clears the
151
+ amber badge only when all model_inferred claims are resolved (same gate as accept),
152
+ otherwise it leaves the badge up ('none') β€” the node stays on canvas either way.
153
+
154
+ This test previously asserted action=='remove' (the buggy behavior that caused
155
+ the canvas/backend desync). It is corrected to assert the fixed behavior:
156
+ rejecting c1 with c2 still pending β†’ action='none' (sibling pending β†’ badge stays);
157
+ the node is NOT removed.
158
+
159
+ For EDGE claims, rejection still emits action='remove' β€” that is a separate
160
+ code path locked by test_edge_claim_review.py::test_reject_edge_claim_still_removes_edge.
161
+ """
162
+ c1 = Claim(
163
+ id="c1",
164
+ claim_type="label",
165
+ target_id="n1",
166
+ origin="model_inferred",
167
+ review_state="pending",
168
+ support_state="unverified",
169
+ text="first",
170
+ )
171
+ c2 = Claim(
172
+ id="c2",
173
+ claim_type="summary",
174
+ target_id="n1",
175
+ origin="model_inferred",
176
+ review_state="pending",
177
+ support_state="unverified",
178
+ text="second",
179
+ )
180
+ sid = _make_session([c1, c2])
181
+ result = do_review_claim(sid, "c1", "rejected")
182
+ # c2 is still pending β†’ _target_fully_reviewed returns False β†’ badge stays up.
183
+ # The node must NOT be removed from canvas.
184
+ assert result.canvas_hint == {"action": "none", "target_id": "n1"}
185
+ # Node still in the graph (backend retained it).
186
+ assert "n1" in turn_logic.session_store[sid].graph.nodes
187
+
188
+
189
+ async def test_non_model_claims_do_not_hold_the_amber() -> None:
190
+ """Only model_inferred claims gate the amber. A node with one accepted
191
+ model_inferred claim plus a user_asserted pending-ish claim still clears β€”
192
+ user_asserted/deterministic claims never drive the awaiting-review affordance."""
193
+ mi = Claim(
194
+ id="mi",
195
+ claim_type="label",
196
+ target_id="n1",
197
+ origin="model_inferred",
198
+ review_state="pending",
199
+ support_state="unverified",
200
+ text="model",
201
+ )
202
+ user = Claim(
203
+ id="user",
204
+ claim_type="summary",
205
+ target_id="n1",
206
+ origin="user_asserted",
207
+ review_state="accepted",
208
+ support_state="unverified",
209
+ text="user",
210
+ )
211
+ sid = _make_session([mi, user])
212
+ result = do_review_claim(sid, "mi", "accepted")
213
+ # The only model_inferred claim is now accepted -> amber clears.
214
+ assert result.canvas_hint == {"action": "clear_pending", "target_id": "n1"}
tests/test_scene_curator.py CHANGED
@@ -170,3 +170,85 @@ def test_compute_node_clusters_empty_graph_returns_empty() -> None:
170
  from loosecanvas.scene_curator import compute_node_clusters
171
 
172
  assert compute_node_clusters(LooseGraphRepository()) == {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  from loosecanvas.scene_curator import compute_node_clusters
171
 
172
  assert compute_node_clusters(LooseGraphRepository()) == {}
173
+
174
+
175
+ # ── min_visible floor ──────────────────────────────────────────────────────────
176
+
177
+
178
+ def _sparse_repo() -> LooseGraphRepository:
179
+ """3 connected concept nodes + 5 isolated concept nodes + 2 excluded nodes.
180
+
181
+ With max_visible=20 the classic scoring would normally surface only the connected
182
+ nodes first (higher betweenness/pagerank), so a min_visible floor must promote the
183
+ isolated nodes to reach 8 visible.
184
+ """
185
+ nodes = [
186
+ # Connected triple
187
+ Node(id="a", kind="concept", label="alpha"),
188
+ Node(id="b", kind="concept", label="beta"),
189
+ Node(id="c", kind="concept", label="gamma"),
190
+ # Isolated concept nodes (low score, but eligible)
191
+ Node(id="d", kind="concept", label="delta"),
192
+ Node(id="e", kind="concept", label="epsilon"),
193
+ Node(id="f", kind="concept", label="zeta"),
194
+ Node(id="g", kind="concept", label="eta"),
195
+ Node(id="h", kind="concept", label="theta"),
196
+ # Excluded nodes (must NOT be promoted by min_visible)
197
+ Node(id="test_module", kind="module", label="test_module"),
198
+ Node(id="ext_dep", kind="external_dep", label="numpy"),
199
+ ]
200
+ edges = [
201
+ Edge(id="ab", source="a", target="b", kind="relates_to"),
202
+ Edge(id="bc", source="b", target="c", kind="relates_to"),
203
+ ]
204
+ return _repo(nodes, edges)
205
+
206
+
207
+ def test_min_visible_promotes_eligible_nodes_on_sparse_graph() -> None:
208
+ """A small graph with 8 eligible nodes should yield >= min(8, eligible) visible."""
209
+ repo = _sparse_repo()
210
+ # 8 eligible nodes (a,b,c,d,e,f,g,h), 2 excluded (test_module, ext_dep)
211
+ scene, _ = SceneCurator(repo).curate_initial_scene(max_visible=20, min_visible=8)
212
+
213
+ eligible_ids = {"a", "b", "c", "d", "e", "f", "g", "h"}
214
+ assert len(scene.visible_node_ids) >= 8
215
+ # All promoted nodes must still be eligible (not excluded)
216
+ for nid in scene.visible_node_ids:
217
+ assert nid in eligible_ids
218
+
219
+
220
+ def test_min_visible_never_promotes_excluded_nodes() -> None:
221
+ """Even when min_visible exceeds eligible count, excluded nodes must stay out."""
222
+ repo = _sparse_repo()
223
+ # 8 eligible nodes; requesting min_visible=12 exceeds eligible, so we cap at eligible
224
+ scene, _ = SceneCurator(repo).curate_initial_scene(max_visible=20, min_visible=12)
225
+
226
+ assert "test_module" not in scene.visible_node_ids
227
+ assert "ext_dep" not in scene.visible_node_ids
228
+ # Should have all 8 eligible nodes (capped at eligible_count=8, not min_visible=12)
229
+ assert len(scene.visible_node_ids) == 8
230
+
231
+
232
+ def test_min_visible_default_unchanged_for_small_graph() -> None:
233
+ """Default min_visible=8 still works; a graph with <8 eligible nodes is fine."""
234
+ # Only 3 eligible nodes: solo, pair_a, pair_b
235
+ nodes = [
236
+ Node(id="solo", kind="concept", label="Solo"),
237
+ Node(id="pair_a", kind="concept", label="PairA"),
238
+ Node(id="pair_b", kind="concept", label="PairB"),
239
+ ]
240
+ edges = [Edge(id="e1", source="pair_a", target="pair_b", kind="relates_to")]
241
+ repo = _repo(nodes, edges)
242
+ # min_visible=8, but only 3 eligible β†’ should surface all 3 without crash
243
+ scene, _ = SceneCurator(repo).curate_initial_scene(max_visible=20, min_visible=8)
244
+ assert len(scene.visible_node_ids) == 3
245
+ assert set(scene.visible_node_ids) == {"solo", "pair_a", "pair_b"}
246
+
247
+
248
+ def test_min_visible_guard_fires_when_exceeds_max_visible() -> None:
249
+ """assert min_visible <= max_visible must raise AssertionError."""
250
+ import pytest
251
+
252
+ repo = _sparse_repo()
253
+ with pytest.raises(AssertionError):
254
+ SceneCurator(repo).curate_initial_scene(max_visible=5, min_visible=8)
tests/test_search_no_match_reset.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression: a no-match search after a match must clear the prior highlight/dim.
2
+
3
+ QA found: search 'Ret' (highlights RAG, dims the rest), then a no-match term without
4
+ clearing β€” the previous highlight + dim stayed lit, so the graph looked like the old
5
+ match was still selected.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ import uuid
12
+
13
+ from loosecanvas import turn_logic
14
+ from loosecanvas.contracts import Node, SceneState, SourceSnapshot
15
+ from loosecanvas.graph_repository import LooseGraphRepository
16
+ from loosecanvas.main import on_search
17
+
18
+
19
+ def _make_session() -> str:
20
+ nodes = [
21
+ Node(id="n1", kind="concept", label="Alpha"),
22
+ Node(id="n2", kind="concept", label="Beta"),
23
+ ]
24
+ repo = LooseGraphRepository.from_extraction(nodes, [], [], SourceSnapshot())
25
+ scene = SceneState(scene_id="s1", visible_node_ids=["n1", "n2"])
26
+ sid = str(uuid.uuid4())
27
+ now = time.time()
28
+ turn_logic.session_store[sid] = turn_logic.SessionRecord(
29
+ session_id=sid,
30
+ graph=repo,
31
+ scene=scene,
32
+ history=[],
33
+ graph_version=1,
34
+ created_at=now,
35
+ last_seen=now,
36
+ render_scene_id="s1",
37
+ )
38
+ return sid
39
+
40
+
41
+ async def test_no_match_search_clears_prior_highlight_and_dim() -> None:
42
+ sid = _make_session()
43
+ record = turn_logic.session_store[sid]
44
+
45
+ # First search matches a visible node β†’ it highlights + dims the rest.
46
+ patch1, _ = await on_search("Alpha", sid)
47
+ assert record.scene.highlighted_ids, "a matching search must highlight something"
48
+
49
+ # A second, NO-MATCH search must clear the prior highlight/dim (not leave it lit).
50
+ patch2, matches2 = await on_search("zzz-no-such-node", sid)
51
+ assert record.scene.highlighted_ids == [], "stale highlight must be cleared"
52
+ ops = patch2["operations"]
53
+ assert any(o["op"] == "clear_dim" for o in ops), "no-match must lift the dim filter"
54
+ assert any(
55
+ o["op"] == "remove_class" and o.get("data", {}).get("class") == "highlighted"
56
+ for o in ops
57
+ ), "no-match must remove the prior 'highlighted' class"
tests/test_seed_resilience.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Seed-on-load resilience (on_seed / _seed_snapshot).
2
+
3
+ These are the bugs an adversarial QA + audit sweep surfaced:
4
+
5
+ 1. Every page load creates a NEW session. Once ``session_store`` hits ``_MAX_SESSIONS``
6
+ the alternate loaders raise ``OverflowError("session_cap")``. ``on_seed`` had no
7
+ try/except, so on a busy HF Space the canvas stayed permanently EMPTY with the
8
+ "Building your starter map…" banner stuck and an "Error / session_cap" toast leaking
9
+ the raw identifier. A fresh visitor must ALWAYS get a seeded canvas β€” the cap should
10
+ EVICT the least-recently-seen session to make room, not hard-reject.
11
+
12
+ 2. A corrupt/partial seed (bad JSON / missing ``scene`` key) got no fallback.
13
+
14
+ 3. ``on_seed`` only fell back when ``not path.exists()`` β€” any other failure crashed.
15
+
16
+ Grounded against the REAL producer (``load_fixture_session`` + the shipped fixtures),
17
+ not a hand-built fiction fixture.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import time
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ import pytest
27
+ from loosecanvas.contracts import SceneState, SourceSnapshot
28
+ from loosecanvas.graph_repository import LooseGraphRepository
29
+ from loosecanvas.main import _FIXTURES, _seed_snapshot
30
+ from loosecanvas.turn_logic import (
31
+ _MAX_SESSIONS,
32
+ RendererPatch,
33
+ SessionRecord,
34
+ session_store,
35
+ )
36
+
37
+
38
+ def _stuff_session(session_id: str, *, last_seen: float) -> None:
39
+ """Insert a minimal-but-REAL ``SessionRecord`` directly into ``session_store``.
40
+
41
+ Mirrors how ``load_fixture_session`` builds a record (a ``LooseGraphRepository``
42
+ plus a ``SceneState``), so the cap math sees the same shape the live path creates.
43
+ """
44
+ repo = LooseGraphRepository.from_extraction([], [], [], SourceSnapshot())
45
+ now = time.time()
46
+ session_store[session_id] = SessionRecord(
47
+ session_id=session_id,
48
+ graph=repo,
49
+ scene=SceneState(),
50
+ history=[],
51
+ graph_version=1,
52
+ created_at=now,
53
+ last_seen=last_seen,
54
+ patch_id=0,
55
+ )
56
+
57
+
58
+ @pytest.mark.asyncio
59
+ async def test_seed_succeeds_when_store_is_at_cap_via_lru_eviction() -> None:
60
+ """The lynchpin bug: with the store FULL of fresh (non-stale) sessions, a new
61
+ visitor must still get a SEEDED canvas. LRU eviction makes room; no empty canvas,
62
+ no leaked ``session_cap`` string."""
63
+ now = time.time()
64
+ # Fill to the cap with NON-stale sessions (well within the TTL) so the existing
65
+ # TTL-only ``_evict_stale_sessions`` can't help β€” only LRU eviction can.
66
+ for i in range(_MAX_SESSIONS):
67
+ _stuff_session(f"full-{i}", last_seen=now - i) # full-0 newest, last is oldest
68
+ assert len(session_store) == _MAX_SESSIONS
69
+
70
+ snapshot, sid, onboarding_msg = await _seed_snapshot()
71
+
72
+ # The renderer value is the RendererPatch serialized for the Gradio component value
73
+ # (``model_dump()`` dict), carrying a real, seeded snapshot.
74
+ assert isinstance(snapshot, dict)
75
+ assert snapshot["is_snapshot"] is True
76
+ assert snapshot[
77
+ "operations"
78
+ ], "expected a seeded (non-empty) canvas, not a blank one"
79
+ # A fresh session id was minted and lives in the store.
80
+ assert sid
81
+ assert sid in session_store
82
+ # The least-recently-seen session ("full-19") was evicted to make room.
83
+ assert f"full-{_MAX_SESSIONS - 1}" not in session_store
84
+ assert len(session_store) <= _MAX_SESSIONS
85
+ # The user-visible onboarding text must NEVER leak the raw cap identifier.
86
+ assert "session_cap" not in str(onboarding_msg)
87
+
88
+
89
+ @pytest.mark.asyncio
90
+ async def test_seed_falls_back_to_annotated_when_seed_file_missing(
91
+ monkeypatch: pytest.MonkeyPatch,
92
+ ) -> None:
93
+ """If the configured seed file does not exist, on_seed must fall back to the
94
+ shipped ``annotated_graph`` and still paint a canvas."""
95
+ monkeypatch.setitem(_FIXTURES, "seed_local_ai", Path("does-not-exist-on-disk.json"))
96
+
97
+ snapshot, sid, onboarding_msg = await _seed_snapshot()
98
+
99
+ assert isinstance(snapshot, dict)
100
+ assert snapshot["operations"], "annotated_graph fallback should seed a real canvas"
101
+ assert sid in session_store
102
+ assert "session_cap" not in str(onboarding_msg)
103
+
104
+
105
+ @pytest.mark.asyncio
106
+ async def test_seed_survives_corrupt_seed_and_corrupt_fallback(
107
+ monkeypatch: pytest.MonkeyPatch,
108
+ ) -> None:
109
+ """A corrupt/partial seed AND a corrupt fallback must never crash on_seed or leave
110
+ the 'Building…' banner stuck over an empty canvas: a valid (possibly empty)
111
+ snapshot + a FRIENDLY message is returned, with no leaked 'session_cap'/stacktrace.
112
+ """
113
+ bad: dict[str, dict[str, Any]] = {"graph": {}} # missing 'scene' -> ValueError path
114
+
115
+ async def boom(*args: Any, **kwargs: Any) -> tuple[str, RendererPatch]:
116
+ from loosecanvas.extractors.fixture_adapter import load_fixture_from_dict
117
+
118
+ # Exercise the real producer's failure mode (ValueError), not a synthetic one.
119
+ load_fixture_from_dict(bad)
120
+ raise AssertionError("unreachable")
121
+
122
+ # Every fixture load fails the same way the real producer would on a corrupt dict.
123
+ monkeypatch.setattr("loosecanvas.main.load_fixture_session", boom)
124
+ snapshot, sid, onboarding_msg = await _seed_snapshot()
125
+
126
+ # Never crash; always a valid (serialized) snapshot envelope.
127
+ assert isinstance(snapshot, dict)
128
+ assert snapshot["is_snapshot"] is True
129
+ # Friendly onboarding copy, no leaked internals.
130
+ assert "session_cap" not in str(onboarding_msg)
131
+ assert "Traceback" not in str(onboarding_msg)
132
+ assert str(onboarding_msg).strip(), "must show a friendly onboarding message"
tests/test_t1_03_polish.py CHANGED
@@ -38,12 +38,27 @@ def test_main_has_onboarding_markdown() -> None:
38
  assert "lc-onboarding" in content, "main.py should have lc-onboarding elem_classes"
39
 
40
 
41
- def test_main_build_from_text_accordion_open() -> None:
42
- """Build from text accordion is open=False (P0-03: collapsed by default; secondary entry)."""
 
 
 
 
43
  content = (ROOT / "src/loosecanvas/main.py").read_text(encoding="utf-8")
44
  assert (
45
- "open=False" in content
46
- ), "Build from text accordion should be open=False (P0-03)"
 
 
 
 
 
 
 
 
 
 
 
47
 
48
 
49
  def test_main_config_streaming_true() -> None:
 
38
  assert "lc-onboarding" in content, "main.py should have lc-onboarding elem_classes"
39
 
40
 
41
+ def test_main_open_world_entry() -> None:
42
+ """Chat-first magic-build entry (supersedes the seed-on-load graph): the canvas
43
+ opens EMPTY via on_open, the composer is prefilled with editable seed text, and the
44
+ first Send builds the graph from that text via stream_text_session. Export stays; the
45
+ fixture Dropdown / build Accordion remain gone.
46
+ """
47
  content = (ROOT / "src/loosecanvas/main.py").read_text(encoding="utf-8")
48
  assert (
49
+ "demo.load(on_open" in content
50
+ ), "page load wires the empty-canvas on_open entry"
51
+ assert "_SEED_TEXT" in content, "composer must be prefilled with editable seed text"
52
+ assert (
53
+ "stream_text_session" in content
54
+ ), "the first Send must build a graph from the seed text (magic build)"
55
+ assert (
56
+ "gr.DownloadButton" in content
57
+ ), "Export button (DownloadButton) must be present"
58
+ assert (
59
+ "gr.Accordion(" not in content
60
+ ), "open-world entry removes the build accordion"
61
+ assert "gr.Dropdown(" not in content, "open-world entry removes the fixture picker"
62
 
63
 
64
  def test_main_config_streaming_true() -> None:
tests/test_t2_01_removal.py CHANGED
@@ -61,7 +61,7 @@ def test_schema_has_remove_variants() -> None:
61
  assert "remove_edge" in schema_str
62
  assert "remove_node" in [e.value for e in SceneActionType]
63
  assert "remove_edge" in [e.value for e in SceneActionType]
64
- assert len(SceneActionType) == 15
65
 
66
 
67
  # ── SC2: Validator rejects malformed removals ─────────────────────────────────
 
61
  assert "remove_edge" in schema_str
62
  assert "remove_node" in [e.value for e in SceneActionType]
63
  assert "remove_edge" in [e.value for e in SceneActionType]
64
+ assert len(SceneActionType) == 16
65
 
66
 
67
  # ── SC2: Validator rejects malformed removals ─────────────────────────────────
tests/test_t2_02_claim_review.py CHANGED
@@ -209,7 +209,13 @@ async def test_response_shape_and_graph_version() -> None:
209
 
210
 
211
  async def test_reject_canvas_hint() -> None:
212
- """SC6 (rejected): canvas hint is action=remove for a rejected claim."""
 
 
 
 
 
 
213
  claim = Claim(
214
  id="c1",
215
  claim_type="label",
@@ -227,7 +233,9 @@ async def test_reject_canvas_hint() -> None:
227
  resp = await client.post(f"/api/claim/{sid}/c1/review?state=rejected")
228
  assert resp.status_code == 200
229
  canvas = resp.json()["canvas"]
230
- assert canvas["action"] == "remove"
 
 
231
 
232
 
233
  async def test_do_review_claim_round_trip_inspector_updated() -> None:
 
209
 
210
 
211
  async def test_reject_canvas_hint() -> None:
212
+ """SC6 (rejected): for a NODE claim, canvas hint is action=clear_pending (NOT
213
+ remove). Rejecting a claim on a node must keep the node on canvas β€” only the
214
+ amber 'awaiting review' badge is cleared. This test was previously encoding the
215
+ bug (action='remove'), and is corrected to assert the fixed behaviour.
216
+ (For EDGE claims, rejection still emits action='remove' β€” locked by
217
+ test_edge_claim_review.py::test_reject_edge_claim_still_removes_edge.)
218
+ """
219
  claim = Claim(
220
  id="c1",
221
  claim_type="label",
 
233
  resp = await client.post(f"/api/claim/{sid}/c1/review?state=rejected")
234
  assert resp.status_code == 200
235
  canvas = resp.json()["canvas"]
236
+ # Node claims: reject clears the badge but keeps the node (clear_pending).
237
+ assert canvas["action"] == "clear_pending"
238
+ assert canvas["target_id"] == "n1"
239
 
240
 
241
  async def test_do_review_claim_round_trip_inspector_updated() -> None:
tests/test_t2_03_edit_edges.py CHANGED
@@ -88,8 +88,9 @@ def test_schema_has_edit_variants() -> None:
88
 
89
  schema_str = str(sceneplan_validation_schema())
90
  assert "update_node" in schema_str
 
91
  assert "create_edge" in schema_str
92
- assert len(SceneActionType) == 15
93
 
94
 
95
  # ── SC1: Overlay without destroying original ──────────────────────────────────
@@ -197,9 +198,11 @@ def test_create_edge_valid() -> None:
197
  result = validate_sceneplan(ScenePlan(actions=[action]), graph, scene)
198
  assert len(result.valid_actions) == 1
199
  gp, sp, events = reduce_turn([action], graph, scene)
200
- assert any(e.properties.get("origin") == "user_asserted" for e in gp.upsert_edges)
 
 
201
  assert any(
202
- c.claim_type == "relationship" and c.origin == "user_asserted"
203
  for c in gp.upsert_claims
204
  )
205
  # Edge is surfaced since both endpoints are visible
@@ -221,7 +224,11 @@ def test_reducer_update_node_idempotent() -> None:
221
  # ── SC5: Trust renders correctly ─────────────────────────────────────────────
222
 
223
 
224
- def test_create_edge_claim_has_user_asserted_origin() -> None:
 
 
 
 
225
  graph = _graph("n1", "n2")
226
  scene = _scene(visible_nodes=["n1", "n2"])
227
  action = SceneAction(
@@ -229,8 +236,8 @@ def test_create_edge_claim_has_user_asserted_origin() -> None:
229
  )
230
  gp, sp, ev = reduce_turn([action], graph, scene)
231
  edge_claim = next(c for c in gp.upsert_claims if c.claim_type == "relationship")
232
- assert edge_claim.origin == "user_asserted"
233
- assert edge_claim.review_state == "accepted"
234
  assert edge_claim.support_state == "unverified"
235
 
236
 
@@ -317,6 +324,185 @@ def test_update_node_id_not_found() -> None:
317
  assert "id_not_found" in result.rejection_reasons[0]
318
 
319
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  # ── Duplicate user_asserted edge rejection ────────────────────────────────────
321
 
322
 
 
88
 
89
  schema_str = str(sceneplan_validation_schema())
90
  assert "update_node" in schema_str
91
+ assert "update_edge" in schema_str
92
  assert "create_edge" in schema_str
93
+ assert len(SceneActionType) == 16
94
 
95
 
96
  # ── SC1: Overlay without destroying original ──────────────────────────────────
 
198
  result = validate_sceneplan(ScenePlan(actions=[action]), graph, scene)
199
  assert len(result.valid_actions) == 1
200
  gp, sp, events = reduce_turn([action], graph, scene)
201
+ # Agent create_edge is model_inferred (the model PROPOSES a relationship), not
202
+ # user_asserted β€” keeps trust unflattened so it renders amber + redacts on export.
203
+ assert any(e.properties.get("origin") == "model_inferred" for e in gp.upsert_edges)
204
  assert any(
205
+ c.claim_type == "relationship" and c.origin == "model_inferred"
206
  for c in gp.upsert_claims
207
  )
208
  # Edge is surfaced since both endpoints are visible
 
224
  # ── SC5: Trust renders correctly ─────────────────────────────────────────────
225
 
226
 
227
+ def test_create_edge_claim_has_model_inferred_origin() -> None:
228
+ """Agent-proposed edges are model_inferred + pending (awaiting review), NOT
229
+ user_asserted/accepted β€” the model proposing a relationship must not pass as
230
+ user-vouched on export (plan/06 trust model). Direct user connects
231
+ (apply_user_edge) remain user_asserted/accepted."""
232
  graph = _graph("n1", "n2")
233
  scene = _scene(visible_nodes=["n1", "n2"])
234
  action = SceneAction(
 
236
  )
237
  gp, sp, ev = reduce_turn([action], graph, scene)
238
  edge_claim = next(c for c in gp.upsert_claims if c.claim_type == "relationship")
239
+ assert edge_claim.origin == "model_inferred"
240
+ assert edge_claim.review_state == "pending"
241
  assert edge_claim.support_state == "unverified"
242
 
243
 
 
324
  assert "id_not_found" in result.rejection_reasons[0]
325
 
326
 
327
+ # ── update_edge: relabel an existing edge (user_asserted overlay) ─────────────
328
+
329
+
330
+ def _edge_l(edge_id: str, src: str, tgt: str, label: str) -> Edge:
331
+ return Edge(id=edge_id, source=src, target=tgt, kind="relates_to", label=label)
332
+
333
+
334
+ def test_effective_edge_label_prefers_user_overlay() -> None:
335
+ from loosecanvas.contracts import Claim
336
+ from loosecanvas.turn_logic import effective_edge_label
337
+
338
+ edge = _edge_l("e1", "n1", "n2", "original")
339
+ # No claims β†’ the raw edge.label.
340
+ assert effective_edge_label(edge, []) == "original"
341
+ # A model_inferred label claim is a PROPOSAL β€” it does not change the displayed label.
342
+ model_claim = Claim(
343
+ id="c-model",
344
+ claim_type="label",
345
+ target_id="e1",
346
+ text="model guess",
347
+ origin="model_inferred",
348
+ )
349
+ assert effective_edge_label(edge, [model_claim]) == "original"
350
+ # A user_asserted label claim overrides the displayed label.
351
+ user_claim = Claim(
352
+ id="c-user",
353
+ claim_type="label",
354
+ target_id="e1",
355
+ text="clearer label",
356
+ origin="user_asserted",
357
+ review_state="accepted",
358
+ )
359
+ assert effective_edge_label(edge, [model_claim, user_claim]) == "clearer label"
360
+
361
+
362
+ def test_reducer_update_edge_user_overlay() -> None:
363
+ graph = _graph("n1", "n2", edges=[_edge_l("e1", "n1", "n2", "original")])
364
+ scene = _scene(visible_nodes=["n1", "n2"], visible_edges=["e1"])
365
+ action = SceneAction(
366
+ type=SceneActionType.update_edge, edge_id="e1", label="clearer"
367
+ )
368
+ gp, sp, events = reduce_turn([action], graph, scene)
369
+ claim = next(c for c in gp.upsert_claims if c.claim_type == "label")
370
+ assert claim.target_id == "e1"
371
+ assert claim.origin == "user_asserted"
372
+ assert claim.review_state == "accepted"
373
+ assert claim.text == "clearer"
374
+ # Original edge.label is preserved underneath the overlay.
375
+ graph.apply_graph_patch(gp)
376
+ assert graph.get_edge("e1").label == "original"
377
+ from loosecanvas.turn_logic import effective_edge_label
378
+
379
+ assert (
380
+ effective_edge_label(graph.get_edge("e1"), graph.claims.values()) == "clearer"
381
+ )
382
+
383
+
384
+ def test_validator_update_edge_missing_fields() -> None:
385
+ graph = _graph("n1", "n2", edges=[_edge_l("e1", "n1", "n2", "x")])
386
+ scene = _scene(visible_nodes=["n1", "n2"], visible_edges=["e1"])
387
+ r1 = validate_sceneplan(
388
+ ScenePlan(actions=[SceneAction(type=SceneActionType.update_edge, label="y")]),
389
+ graph,
390
+ scene,
391
+ )
392
+ assert "edge_id" in r1.rejection_reasons[0]
393
+ r2 = validate_sceneplan(
394
+ ScenePlan(
395
+ actions=[SceneAction(type=SceneActionType.update_edge, edge_id="e1")]
396
+ ),
397
+ graph,
398
+ scene,
399
+ )
400
+ assert "label" in r2.rejection_reasons[0]
401
+
402
+
403
+ def test_validator_update_edge_not_found_and_not_visible() -> None:
404
+ graph = _graph("n1", "n2", edges=[_edge_l("e1", "n1", "n2", "x")])
405
+ scene = _scene(visible_nodes=["n1", "n2"], visible_edges=["e1"])
406
+ r1 = validate_sceneplan(
407
+ ScenePlan(
408
+ actions=[
409
+ SceneAction(
410
+ type=SceneActionType.update_edge, edge_id="ghost", label="y"
411
+ )
412
+ ]
413
+ ),
414
+ graph,
415
+ scene,
416
+ )
417
+ assert "id_not_found" in r1.rejection_reasons[0]
418
+ scene_hidden = _scene(visible_nodes=["n1", "n2"], visible_edges=[])
419
+ r2 = validate_sceneplan(
420
+ ScenePlan(
421
+ actions=[
422
+ SceneAction(type=SceneActionType.update_edge, edge_id="e1", label="y")
423
+ ]
424
+ ),
425
+ graph,
426
+ scene_hidden,
427
+ )
428
+ assert "edge_not_visible" in r2.rejection_reasons[0]
429
+
430
+
431
+ def test_validator_update_edge_valid() -> None:
432
+ graph = _graph("n1", "n2", edges=[_edge_l("e1", "n1", "n2", "x")])
433
+ scene = _scene(visible_nodes=["n1", "n2"], visible_edges=["e1"])
434
+ result = validate_sceneplan(
435
+ ScenePlan(
436
+ actions=[
437
+ SceneAction(
438
+ type=SceneActionType.update_edge, edge_id="e1", label="clearer"
439
+ )
440
+ ]
441
+ ),
442
+ graph,
443
+ scene,
444
+ )
445
+ assert len(result.valid_actions) == 1
446
+
447
+
448
+ def _make_edge_session() -> str:
449
+ nodes = [
450
+ Node(id="n1", kind="concept", label="N1"),
451
+ Node(id="n2", kind="concept", label="N2"),
452
+ ]
453
+ edges = [_edge_l("e1", "n1", "n2", "original")]
454
+ repo = LooseGraphRepository.from_extraction(nodes, edges, [], SourceSnapshot())
455
+ scene = SceneState(
456
+ scene_id="s1", visible_node_ids=["n1", "n2"], visible_edge_ids=["e1"]
457
+ )
458
+ sid = str(uuid.uuid4())
459
+ now = time.time()
460
+ turn_logic.session_store[sid] = turn_logic.SessionRecord(
461
+ session_id=sid,
462
+ graph=repo,
463
+ scene=scene,
464
+ history=[],
465
+ graph_version=1,
466
+ created_at=now,
467
+ last_seen=now,
468
+ render_scene_id="s1",
469
+ )
470
+ return sid
471
+
472
+
473
+ def test_apply_agent_actions_update_edge_emits_update_data() -> None:
474
+ """The load-bearing render assertion: relabeling an already-visible edge must
475
+ emit an explicit update_data op carrying the new effective label (diff_scene
476
+ alone won't repaint a persisting element's label)."""
477
+ sid = _make_edge_session()
478
+ record = turn_logic.session_store[sid]
479
+ try:
480
+ action = SceneAction(
481
+ type=SceneActionType.update_edge, edge_id="e1", label="clearer link"
482
+ )
483
+ result = turn_logic.apply_agent_actions(
484
+ record, [action], {"kind": "edge", "id": "e1"}, "done"
485
+ )
486
+ ops = result.renderer_patch.operations
487
+ update_ops = [
488
+ o for o in ops if o.op == "update_data" and o.data.get("id") == "e1"
489
+ ]
490
+ assert len(update_ops) == 1, "relabeled visible edge must emit update_data"
491
+ assert update_ops[0].data["data"]["label"] == "clearer link"
492
+ # Effective label resolves to the overlay; raw edge.label preserved.
493
+ from loosecanvas.turn_logic import effective_edge_label
494
+
495
+ assert record.graph.get_edge("e1").label == "original"
496
+ assert (
497
+ effective_edge_label(
498
+ record.graph.get_edge("e1"), record.graph.claims.values()
499
+ )
500
+ == "clearer link"
501
+ )
502
+ finally:
503
+ turn_logic.session_store.pop(sid, None)
504
+
505
+
506
  # ── Duplicate user_asserted edge rejection ────────────────────────────────────
507
 
508
 
tests/test_t2_04_node_edits.py ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """T2-04 β€” user-initiated node edits (radial menu: add / remove node, edit summary).
2
+
3
+ These mirror the sibling edge-edit tests in ``tests/test_b2_cxtmenu_review.py``
4
+ (``remove_user_edge``). User edits are ``user_asserted`` (never ``model_inferred`` β€”
5
+ the trust model in plan/06 must not be flattened):
6
+
7
+ - ``remove_user_node`` routes through the authoritative validate→reduce→finalize
8
+ pipeline (same as the agent's remove-node turn); the reducer cascades incident
9
+ visible edges and the validator's ``would_empty_scene`` guard keeps the last node.
10
+ - ``apply_user_node`` mints a unique node + ``user_asserted`` label/summary claims,
11
+ reveals it, and places it on the open periphery (an orphan has no positioned
12
+ neighbour, so ``_assign_reveal_positions`` would otherwise drop it on the centroid).
13
+ - edit-summary reuses the existing ``apply_user_edit(summary=...)`` overlay path.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import time
19
+ import uuid
20
+
21
+ import pytest
22
+ from loosecanvas import turn_logic
23
+ from loosecanvas.contracts import Edge, Node, SceneState, SourceSnapshot
24
+ from loosecanvas.graph_repository import LooseGraphRepository
25
+ from loosecanvas.turn_logic import (
26
+ RendererPatch,
27
+ apply_user_edge_edit,
28
+ apply_user_edit,
29
+ apply_user_node,
30
+ effective_edge_label,
31
+ effective_node_summary,
32
+ remove_user_node,
33
+ )
34
+
35
+
36
+ def _make_session_with_visible_nodes() -> str:
37
+ """n1β€”n2β€”n3 with all three nodes visible and one visible edge (e1: n1β€”n2).
38
+
39
+ Three visible nodes so ``remove_node`` does not trip ``would_empty_scene``.
40
+ """
41
+ nodes = [
42
+ Node(id="n1", kind="concept", label="One"),
43
+ Node(id="n2", kind="concept", label="Two"),
44
+ Node(id="n3", kind="concept", label="Three"),
45
+ ]
46
+ edges = [Edge(id="e1", source="n1", target="n2", kind="related_to", label="rel")]
47
+ repo = LooseGraphRepository.from_extraction(nodes, edges, [], SourceSnapshot())
48
+ scene = SceneState(
49
+ scene_id="s1",
50
+ visible_node_ids=["n1", "n2", "n3"],
51
+ visible_edge_ids=["e1"],
52
+ node_positions={
53
+ "n1": (0.0, 0.0),
54
+ "n2": (100.0, 0.0),
55
+ "n3": (50.0, 100.0),
56
+ },
57
+ )
58
+ sid = str(uuid.uuid4())
59
+ now = time.time()
60
+ turn_logic.session_store[sid] = turn_logic.SessionRecord(
61
+ session_id=sid,
62
+ graph=repo,
63
+ scene=scene,
64
+ history=[],
65
+ graph_version=1,
66
+ created_at=now,
67
+ last_seen=now,
68
+ render_scene_id="s1",
69
+ )
70
+ return sid
71
+
72
+
73
+ def _make_session_single_node() -> str:
74
+ """A scene with exactly one visible node (to exercise ``would_empty_scene``)."""
75
+ node = Node(id="solo", kind="concept", label="Solo")
76
+ repo = LooseGraphRepository.from_extraction([node], [], [], SourceSnapshot())
77
+ scene = SceneState(
78
+ scene_id="s1",
79
+ visible_node_ids=["solo"],
80
+ node_positions={"solo": (0.0, 0.0)},
81
+ )
82
+ sid = str(uuid.uuid4())
83
+ now = time.time()
84
+ turn_logic.session_store[sid] = turn_logic.SessionRecord(
85
+ session_id=sid,
86
+ graph=repo,
87
+ scene=scene,
88
+ history=[],
89
+ graph_version=1,
90
+ created_at=now,
91
+ last_seen=now,
92
+ render_scene_id="s1",
93
+ )
94
+ return sid
95
+
96
+
97
+ # ── remove_user_node ─────────────────────────────────────────────────────────
98
+
99
+
100
+ async def test_remove_user_node_removes_visible_node() -> None:
101
+ """Delete-node removes a visible node from graph + scene via the authoritative
102
+ validate→reduce pipeline and emits a remove patch."""
103
+ sid = _make_session_with_visible_nodes()
104
+ assert "n3" in turn_logic.session_store[sid].graph.nodes # precondition
105
+ result = await remove_user_node(sid, "n3")
106
+ assert isinstance(result.renderer_patch, RendererPatch)
107
+ assert "n3" not in turn_logic.session_store[sid].graph.nodes
108
+ assert "n3" not in turn_logic.session_store[sid].scene.visible_node_ids
109
+ assert any(o.op == "remove_element" for o in result.renderer_patch.operations)
110
+
111
+
112
+ async def test_remove_user_node_cascades_incident_edges() -> None:
113
+ """Removing n1 cascades the incident visible edge e1 (n1β€”n2) out of the scene."""
114
+ sid = _make_session_with_visible_nodes()
115
+ assert "e1" in turn_logic.session_store[sid].scene.visible_edge_ids
116
+ result = await remove_user_node(sid, "n1")
117
+ assert "n1" not in turn_logic.session_store[sid].graph.nodes
118
+ assert "e1" not in turn_logic.session_store[sid].scene.visible_edge_ids
119
+ assert isinstance(result.renderer_patch, RendererPatch)
120
+
121
+
122
+ async def test_remove_user_node_graceful_on_unknown_node() -> None:
123
+ """An unknown / non-visible node degrades gracefully (no crash, no mutation)."""
124
+ sid = _make_session_with_visible_nodes()
125
+ result = await remove_user_node(sid, "no_such_node")
126
+ assert result is not None # a TurnResult (graceful failure), not an exception
127
+ assert "n1" in turn_logic.session_store[sid].graph.nodes # untouched
128
+ assert "n3" in turn_logic.session_store[sid].graph.nodes
129
+
130
+
131
+ async def test_remove_user_node_would_empty_scene_is_graceful() -> None:
132
+ """Removing the last visible node is rejected (``would_empty_scene``) gracefully."""
133
+ sid = _make_session_single_node()
134
+ result = await remove_user_node(sid, "solo")
135
+ assert result.status == "failed"
136
+ assert "solo" in turn_logic.session_store[sid].graph.nodes # not mutated
137
+ assert "solo" in turn_logic.session_store[sid].scene.visible_node_ids
138
+
139
+
140
+ async def test_remove_user_node_unknown_session_raises() -> None:
141
+ """An unknown session raises KeyError (the on_select branch catches it)."""
142
+ with pytest.raises(KeyError):
143
+ await remove_user_node("no-such-session", "n1")
144
+
145
+
146
+ # ── apply_user_node ──────────────────────────────────────────────────────────
147
+
148
+
149
+ async def test_apply_user_node_adds_node_to_graph_and_scene() -> None:
150
+ """Add-node mints a node into the graph and reveals it in the scene."""
151
+ sid = _make_session_with_visible_nodes()
152
+ result = await apply_user_node(sid, "Fresh Concept")
153
+ record = turn_logic.session_store[sid]
154
+ new_ids = [
155
+ nid
156
+ for nid in record.graph.nodes
157
+ if record.graph.nodes[nid].label == "Fresh Concept"
158
+ ]
159
+ assert len(new_ids) == 1
160
+ new_id = new_ids[0]
161
+ assert new_id in record.scene.visible_node_ids
162
+ assert isinstance(result.renderer_patch, RendererPatch)
163
+ assert any(o.op == "add_element" for o in result.renderer_patch.operations)
164
+
165
+
166
+ async def test_apply_user_node_label_claim_is_user_asserted() -> None:
167
+ """The new node's label claim is origin=user_asserted (trust not flattened)."""
168
+ sid = _make_session_with_visible_nodes()
169
+ await apply_user_node(sid, "Trust Me")
170
+ record = turn_logic.session_store[sid]
171
+ label_claims = [
172
+ c
173
+ for c in record.graph.claims.values()
174
+ if c.claim_type == "label" and c.text == "Trust Me"
175
+ ]
176
+ assert label_claims, "expected a label claim for the new node"
177
+ assert all(c.origin == "user_asserted" for c in label_claims)
178
+ assert all(c.review_state == "accepted" for c in label_claims)
179
+ assert all(c.support_state == "unverified" for c in label_claims)
180
+
181
+
182
+ async def test_apply_user_node_assigns_a_position() -> None:
183
+ """A position is assigned for the new (orphan) node so it is not dropped on the centroid."""
184
+ sid = _make_session_with_visible_nodes()
185
+ await apply_user_node(sid, "Floating")
186
+ record = turn_logic.session_store[sid]
187
+ new_id = next(
188
+ nid for nid in record.graph.nodes if record.graph.nodes[nid].label == "Floating"
189
+ )
190
+ assert new_id in record.scene.node_positions
191
+ new_x, _new_y = record.scene.node_positions[new_id]
192
+ # Periphery: to the right of the rightmost pre-existing node (max x was 100.0).
193
+ assert new_x > 100.0
194
+
195
+
196
+ async def test_apply_user_node_with_summary_creates_summary_claim() -> None:
197
+ """A summary= argument creates a user_asserted summary claim reflected in the effective summary."""
198
+ sid = _make_session_with_visible_nodes()
199
+ await apply_user_node(sid, "Documented", "A helpful summary.")
200
+ record = turn_logic.session_store[sid]
201
+ new_id = next(
202
+ nid
203
+ for nid in record.graph.nodes
204
+ if record.graph.nodes[nid].label == "Documented"
205
+ )
206
+ summary_claims = [
207
+ c
208
+ for c in record.graph.claims.values()
209
+ if c.claim_type == "summary" and c.target_id == new_id
210
+ ]
211
+ assert summary_claims
212
+ assert all(c.origin == "user_asserted" for c in summary_claims)
213
+ node = record.graph.get_node(new_id)
214
+ assert node is not None
215
+ assert (
216
+ effective_node_summary(node, record.graph.claims.values())
217
+ == "A helpful summary."
218
+ )
219
+
220
+
221
+ async def test_apply_user_node_empty_label_raises() -> None:
222
+ """An empty / whitespace label is rejected with ValueError."""
223
+ sid = _make_session_with_visible_nodes()
224
+ with pytest.raises(ValueError):
225
+ await apply_user_node(sid, " ")
226
+
227
+
228
+ async def test_apply_user_node_unique_id_on_collision() -> None:
229
+ """Two nodes with the same label get distinct ids (deterministic suffixing)."""
230
+ sid = _make_session_with_visible_nodes()
231
+ await apply_user_node(sid, "Dup")
232
+ await apply_user_node(sid, "Dup")
233
+ record = turn_logic.session_store[sid]
234
+ dup_ids = [
235
+ nid for nid in record.graph.nodes if record.graph.nodes[nid].label == "Dup"
236
+ ]
237
+ assert len(dup_ids) == 2
238
+ assert len(set(dup_ids)) == 2 # ids are distinct
239
+
240
+
241
+ async def test_apply_user_node_unknown_session_raises() -> None:
242
+ """An unknown session raises KeyError (the on_select branch catches it)."""
243
+ with pytest.raises(KeyError):
244
+ await apply_user_node("no-such-session", "Whatever")
245
+
246
+
247
+ # ── edit summary (reuses apply_user_edit(summary=...)) ───────────────────────
248
+
249
+
250
+ async def test_apply_user_edit_summary_adds_user_asserted_claim() -> None:
251
+ """Editing a node's summary adds a user_asserted summary claim; the effective
252
+ summary reflects it."""
253
+ sid = _make_session_with_visible_nodes()
254
+ result = await apply_user_edit(sid, "n2", summary="My own note.")
255
+ assert isinstance(result.renderer_patch, RendererPatch)
256
+ record = turn_logic.session_store[sid]
257
+ summary_claims = [
258
+ c
259
+ for c in record.graph.claims.values()
260
+ if c.claim_type == "summary" and c.target_id == "n2"
261
+ ]
262
+ assert summary_claims
263
+ assert all(c.origin == "user_asserted" for c in summary_claims)
264
+ node = record.graph.get_node("n2")
265
+ assert node is not None
266
+ assert effective_node_summary(node, record.graph.claims.values()) == "My own note."
267
+
268
+
269
+ # ── TASK 3: apply_user_edge_edit ─────────────────────────────────────────────
270
+
271
+
272
+ def _make_session_with_visible_edge_for_edit() -> str:
273
+ """Session with n1β€”n2 (e1, 'original label') both visible."""
274
+ from loosecanvas.contracts import Edge
275
+
276
+ nodes = [
277
+ Node(id="n1", kind="concept", label="N1"),
278
+ Node(id="n2", kind="concept", label="N2"),
279
+ ]
280
+ edges = [
281
+ Edge(id="e1", source="n1", target="n2", kind="related_to", label="original")
282
+ ]
283
+ repo = LooseGraphRepository.from_extraction(nodes, edges, [], SourceSnapshot())
284
+ scene = SceneState(
285
+ scene_id="s1",
286
+ visible_node_ids=["n1", "n2"],
287
+ visible_edge_ids=["e1"],
288
+ node_positions={"n1": (0.0, 0.0), "n2": (100.0, 0.0)},
289
+ )
290
+ sid = str(uuid.uuid4())
291
+ now = time.time()
292
+ turn_logic.session_store[sid] = turn_logic.SessionRecord(
293
+ session_id=sid,
294
+ graph=repo,
295
+ scene=scene,
296
+ history=[],
297
+ graph_version=1,
298
+ created_at=now,
299
+ last_seen=now,
300
+ render_scene_id="s1",
301
+ )
302
+ return sid
303
+
304
+
305
+ async def test_apply_user_edge_edit_effective_label_updated() -> None:
306
+ """After apply_user_edge_edit, effective_edge_label returns the new label."""
307
+ sid = _make_session_with_visible_edge_for_edit()
308
+ record = turn_logic.session_store[sid]
309
+ result = await apply_user_edge_edit(sid, "e1", label="clearer link")
310
+ assert result.status == "success"
311
+ edge = record.graph.get_edge("e1")
312
+ assert edge is not None
313
+ # Raw edge.label is preserved.
314
+ assert edge.label == "original"
315
+ # Effective label reflects the overlay.
316
+ assert effective_edge_label(edge, record.graph.claims.values()) == "clearer link"
317
+
318
+
319
+ async def test_apply_user_edge_edit_raw_label_unchanged() -> None:
320
+ """The raw edge.label is preserved underneath the user_asserted overlay."""
321
+ sid = _make_session_with_visible_edge_for_edit()
322
+ record = turn_logic.session_store[sid]
323
+ await apply_user_edge_edit(sid, "e1", label="new label")
324
+ edge = record.graph.get_edge("e1")
325
+ assert edge is not None
326
+ assert edge.label == "original"
327
+
328
+
329
+ async def test_apply_user_edge_edit_claim_is_user_asserted() -> None:
330
+ """The overlay claim has origin=user_asserted and review_state=accepted."""
331
+ sid = _make_session_with_visible_edge_for_edit()
332
+ record = turn_logic.session_store[sid]
333
+ await apply_user_edge_edit(sid, "e1", label="labeled")
334
+ label_claims = [
335
+ c
336
+ for c in record.graph.claims.values()
337
+ if c.claim_type == "label" and c.target_id == "e1"
338
+ ]
339
+ assert label_claims, "expected a label claim for the edge"
340
+ assert all(c.origin == "user_asserted" for c in label_claims)
341
+ assert all(c.review_state == "accepted" for c in label_claims)
342
+
343
+
344
+ async def test_apply_user_edge_edit_emits_update_data_op() -> None:
345
+ """The renderer patch carries an update_data op for the edge with the new label."""
346
+ sid = _make_session_with_visible_edge_for_edit()
347
+ result = await apply_user_edge_edit(sid, "e1", label="updated")
348
+ update_ops = [
349
+ o
350
+ for o in result.renderer_patch.operations
351
+ if o.op == "update_data" and o.data.get("id") == "e1"
352
+ ]
353
+ assert update_ops, "must emit update_data for visible edge"
354
+ assert update_ops[0].data["data"]["label"] == "updated"
355
+
356
+
357
+ async def test_apply_user_edge_edit_unknown_edge_raises() -> None:
358
+ """An unknown edge id raises KeyError."""
359
+ sid = _make_session_with_visible_edge_for_edit()
360
+ with pytest.raises(KeyError):
361
+ await apply_user_edge_edit(sid, "no_such_edge", label="x")
362
+
363
+
364
+ async def test_apply_user_edge_edit_unknown_session_raises() -> None:
365
+ """An unknown session raises KeyError."""
366
+ with pytest.raises(KeyError):
367
+ await apply_user_edge_edit("no-such-session", "e1", label="x")
tests/test_text_graph_adapter.py CHANGED
@@ -29,6 +29,19 @@ from loosecanvas.llm_client import LLMClient
29
 
30
  _TITLE = "Gradient Descent"
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  _HAPPY_JSON = json.dumps(
33
  {
34
  "concept_nodes": [
@@ -365,3 +378,64 @@ async def test_extract_from_file_reads_allowed_path(tmp_path: Path) -> None:
365
  )
366
  graph = await extract_graph_from_file(source, _TITLE, _mock_client(_HAPPY_JSON))
367
  assert len(graph.nodes) == 2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  _TITLE = "Gradient Descent"
31
 
32
+
33
+ def test_edge_schema_requires_label() -> None:
34
+ """Every extracted edge must carry a label (the grammar requires it) so the magic
35
+ build never produces bare, uninformative connections β€” the KG-quality contract."""
36
+ from loosecanvas.extractors.text_graph_adapter import TEXT_GRAPH_RESPONSE_FORMAT
37
+
38
+ edge_items = TEXT_GRAPH_RESPONSE_FORMAT["json_schema"]["schema"]["properties"][
39
+ "semantic_edges"
40
+ ]["items"]
41
+ assert "label" in edge_items["required"]
42
+ assert {"source_id", "target_id", "type"} <= set(edge_items["required"])
43
+
44
+
45
  _HAPPY_JSON = json.dumps(
46
  {
47
  "concept_nodes": [
 
378
  )
379
  graph = await extract_graph_from_file(source, _TITLE, _mock_client(_HAPPY_JSON))
380
  assert len(graph.nodes) == 2
381
+
382
+
383
+ # --------------------------------------------------------------------------- #
384
+ # BUG FIX: semantic_edge claim must target the edge id, not the target node #
385
+ # --------------------------------------------------------------------------- #
386
+
387
+
388
+ async def test_edge_claim_targets_edge_id() -> None:
389
+ """semantic_edge claims must have target_id == edge_id, NOT a node id.
390
+
391
+ The edge_id in _ingest_edge is computed as ``f"{src}::{kind}::{tgt}"``.
392
+ The bug had target_id=tgt (the target NODE id), which made _build_edge_trust_map
393
+ always miss β†’ edges showed fallback deterministic/pending, review was a no-op.
394
+ """
395
+ text = "Gradient descent\nis a loss function method used widely in practice."
396
+ graph = await extract_graph_from_text(text, _TITLE, _mock_client(_HAPPY_JSON))
397
+
398
+ edge_ids = {e.id for e in graph.edges}
399
+ node_ids = {n.id for n in graph.nodes}
400
+ semantic_edge_claims = [c for c in graph.claims if c.claim_type == "semantic_edge"]
401
+ assert semantic_edge_claims, "expected at least one semantic_edge claim"
402
+ for claim in semantic_edge_claims:
403
+ assert claim.target_id in edge_ids, (
404
+ f"semantic_edge claim {claim.id} has target_id={claim.target_id!r} "
405
+ f"which is NOT an edge id; edge ids: {edge_ids}"
406
+ )
407
+ assert claim.target_id not in node_ids, (
408
+ f"semantic_edge claim {claim.id} has target_id={claim.target_id!r} "
409
+ f"which is a node id β€” the bug has recurred"
410
+ )
411
+
412
+
413
+ async def test_text_session_edges_render_amber() -> None:
414
+ """From an extracted graph, every edge must be in the trust map as model_inferred/pending.
415
+
416
+ _build_edge_trust_map checks claim.target_id in graph.edges; with the bug
417
+ (target_id = node id) this always misses β†’ trust map is empty β†’ no amber.
418
+ After the fix (target_id = edge_id) every edge gets an entry.
419
+ """
420
+ from loosecanvas.contracts import SourceSnapshot
421
+ from loosecanvas.turn_logic import _build_edge_trust_map
422
+
423
+ text = "Gradient descent\nis a loss function method used widely in practice."
424
+ graph = await extract_graph_from_text(text, _TITLE, _mock_client(_HAPPY_JSON))
425
+
426
+ repo = LooseGraphRepository.from_extraction(
427
+ graph.nodes, graph.edges, graph.claims, SourceSnapshot()
428
+ )
429
+ trust_map = _build_edge_trust_map(repo)
430
+
431
+ for edge in graph.edges:
432
+ assert (
433
+ edge.id in trust_map
434
+ ), f"edge {edge.id!r} missing from trust map β€” amber affordance will not render"
435
+ entry = trust_map[edge.id]
436
+ assert (
437
+ entry["origin"] == "model_inferred"
438
+ ), f"edge {edge.id!r} trust origin={entry['origin']!r}, want 'model_inferred'"
439
+ assert (
440
+ entry["review"] == "pending"
441
+ ), f"edge {edge.id!r} review={entry['review']!r}, want 'pending'"
tests/test_time_travel_output_arity.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression guard: time-travel handlers return as many values as their wired outputs.
2
+
3
+ The undo/redo/back buttons errored live with "A function (on_undo) didn't return enough
4
+ output values (needed: 6, returned: 5)" the moment they were pressed, yet the whole suite
5
+ was green β€” because the unit tests call ``undo_turn``/``redo_turn`` directly and never
6
+ exercise the Gradio output-arity binding (``turn_outputs`` grew to include ``send_btn``
7
+ for ``on_turn`` but the time-travel handlers were not updated). This builds the REAL
8
+ Blocks app and asserts each handler returns exactly ``len(outputs)`` values, so an
9
+ output/arity drift fails the gate instead of only the live button.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ import uuid
16
+
17
+ import pytest
18
+ from loosecanvas import turn_logic
19
+ from loosecanvas.contracts import Node, SceneState, SourceSnapshot
20
+ from loosecanvas.graph_repository import LooseGraphRepository
21
+ from loosecanvas.main import build_app
22
+ from loosecanvas.turn_logic import SessionRecord, _commit_snapshot
23
+
24
+ TIME_TRAVEL = ("on_undo", "on_redo", "on_back")
25
+
26
+
27
+ def _handlers() -> dict[str, tuple[object, int]]:
28
+ """Map each time-travel handler name -> (fn, declared output count) from the app."""
29
+ demo, _app = build_app()
30
+ out: dict[str, tuple[object, int]] = {}
31
+ for block_fn in demo.fns.values():
32
+ fn = getattr(block_fn, "fn", None)
33
+ name = getattr(fn, "__name__", "")
34
+ if name in TIME_TRAVEL and name not in out:
35
+ out[name] = (fn, len(block_fn.outputs))
36
+ return out
37
+
38
+
39
+ @pytest.fixture(scope="module")
40
+ def handlers() -> dict[str, tuple[object, int]]:
41
+ h = _handlers()
42
+ missing = [n for n in TIME_TRAVEL if n not in h]
43
+ assert not missing, f"handlers not found in demo.fns: {missing}"
44
+ return h
45
+
46
+
47
+ def _session_with_undo_history() -> str:
48
+ """A session with two committed snapshots so undo_turn has a prior state to pop."""
49
+ nodes = [
50
+ Node(id="n1", kind="concept", label="One"),
51
+ Node(id="n2", kind="concept", label="Two"),
52
+ ]
53
+ repo = LooseGraphRepository.from_extraction(nodes, [], [], SourceSnapshot())
54
+ scene = SceneState(
55
+ scene_id="s1",
56
+ visible_node_ids=["n1", "n2"],
57
+ node_positions={"n1": (0.0, 0.0), "n2": (100.0, 0.0)},
58
+ )
59
+ sid = str(uuid.uuid4())
60
+ now = time.time()
61
+ record = SessionRecord(
62
+ session_id=sid,
63
+ graph=repo,
64
+ scene=scene,
65
+ history=[],
66
+ graph_version=1,
67
+ created_at=now,
68
+ last_seen=now,
69
+ render_scene_id="s1",
70
+ )
71
+ turn_logic.session_store[sid] = record
72
+ _commit_snapshot(record) # baseline (load) snapshot
73
+ record.graph_version = 2
74
+ _commit_snapshot(record) # a second state so undo can revert to the baseline
75
+ return sid
76
+
77
+
78
+ @pytest.mark.parametrize("name", TIME_TRAVEL)
79
+ async def test_no_session_return_matches_output_arity(handlers, name: str) -> None:
80
+ """The no-session early-return path supplies exactly len(outputs) values."""
81
+ fn, n_outputs = handlers[name]
82
+ result = await fn("", []) # type: ignore[operator]
83
+ assert isinstance(result, tuple)
84
+ assert (
85
+ len(result) == n_outputs
86
+ ), f"{name} returned {len(result)} values for {n_outputs} wired outputs"
87
+
88
+
89
+ async def test_on_undo_success_path_matches_output_arity(handlers) -> None:
90
+ """The success path (real session with undo history) also matches output arity."""
91
+ fn, n_outputs = handlers["on_undo"]
92
+ sid = _session_with_undo_history()
93
+ result = await fn(sid, []) # type: ignore[operator]
94
+ assert isinstance(result, tuple)
95
+ assert len(result) == n_outputs
tests/test_tool_narration_chat.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for workstream (a): live collapsible step-list inside the chat thread.
2
+
3
+ Covers:
4
+ * _tool_steplist_message shape (pending / done / empty steps).
5
+ * on_turn streaming: mid-turn chatbot output contains a metadata-titled pending
6
+ bubble; final chatbot output drops the pending bubble and appends tool_receipts.
7
+
8
+ The tests drive on_turn via a thin harness that replaces run_agent_turn_streaming
9
+ with a fake generator β€” no LLM, no session lookup required for the event-path under
10
+ test. The fake yields one ToolActivityEvent then a FinalEvent with one tool-activity
11
+ label so both branches are exercised in a single streaming run.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import AsyncGenerator
17
+ from typing import Any
18
+ from unittest.mock import patch
19
+
20
+ import pytest
21
+ from loosecanvas.agent_harness import FinalEvent, ToolActivityEvent
22
+ from loosecanvas.main import _tool_steplist_message
23
+
24
+ # ── unit tests for _tool_steplist_message ─────────────────────────────────────
25
+
26
+
27
+ def test_steplist_pending_shape() -> None:
28
+ """pending status produces a message with metadata title and status."""
29
+ msg = _tool_steplist_message(["step one", "step two"], "pending")
30
+ assert msg["role"] == "assistant"
31
+ assert msg["metadata"]["title"] == "Working…"
32
+ assert msg["metadata"]["status"] == "pending"
33
+ assert "- step one" in msg["content"]
34
+ assert "- step two" in msg["content"]
35
+
36
+
37
+ def test_steplist_done_shape() -> None:
38
+ """done status produces same structure but status='done'."""
39
+ msg = _tool_steplist_message(["finished"], "done")
40
+ assert msg["metadata"]["status"] == "done"
41
+ assert "- finished" in msg["content"]
42
+
43
+
44
+ def test_steplist_empty_steps_placeholder() -> None:
45
+ """Empty step list must not produce a blank content field (shows placeholder)."""
46
+ msg = _tool_steplist_message([], "pending")
47
+ assert msg["content"].strip() != ""
48
+ assert "Working" in msg["content"]
49
+
50
+
51
+ # ── helpers for on_turn harness ───────────────────────────────────────────────
52
+
53
+
54
+ async def _fake_streaming(
55
+ tool_label: str = "relabel_edge:woody->dylan",
56
+ ) -> AsyncGenerator[Any]:
57
+ """Fake run_agent_turn_streaming: one ToolActivityEvent then a FinalEvent."""
58
+ yield ToolActivityEvent(message=tool_label)
59
+
60
+ # FinalEvent with no TurnResult (pure-speech path without canvas mutation);
61
+ # tool_activity carries the label so _tool_receipt_messages has something to show.
62
+ yield FinalEvent(
63
+ turn_result=None,
64
+ assistant_message="Here is what I did.",
65
+ tool_activity=[tool_label],
66
+ )
67
+
68
+
69
+ # ── on_turn streaming event-path tests ───────────────────────────────────────
70
+
71
+
72
+ @pytest.fixture()
73
+ def _on_turn_fn() -> Any:
74
+ """Extract the on_turn coroutine from the real Blocks app."""
75
+ from loosecanvas.main import build_app
76
+
77
+ demo, _app = build_app()
78
+ for block_fn in demo.fns.values():
79
+ fn = getattr(block_fn, "fn", None)
80
+ if getattr(fn, "__name__", "") == "on_turn":
81
+ return fn
82
+ pytest.fail("on_turn not found in demo.fns")
83
+
84
+
85
+ async def _collect_chatbot_snapshots(
86
+ on_turn: Any,
87
+ message: str = "relabel the edges",
88
+ sid: str = "fake-session",
89
+ selection: dict[str, Any] | None = None,
90
+ history: list[Any] | None = None,
91
+ ) -> list[Any]:
92
+ """Run the on_turn generator and return every chatbot value that is NOT gr.update().
93
+
94
+ We only collect concrete list values (the chatbot history snapshots) so we can
95
+ assert on what was pushed to the Gradio Chatbot at each stage.
96
+ """
97
+ import gradio as gr # noqa: PLC0415
98
+
99
+ snapshots: list[Any] = []
100
+ async for output_tuple in on_turn(message, sid, selection or {}, history or []):
101
+ # on_turn yields 8-tuples; chatbot is index 2.
102
+ chatbot_val = output_tuple[2]
103
+ if not isinstance(chatbot_val, gr.update().__class__): # type: ignore[arg-type]
104
+ snapshots.append(chatbot_val)
105
+ return snapshots
106
+
107
+
108
+ async def test_pending_bubble_appears_mid_stream(_on_turn_fn: Any) -> None:
109
+ """A metadata-titled pending bubble must appear in at least one mid-stream snapshot."""
110
+ with patch(
111
+ "loosecanvas.main.run_agent_turn_streaming",
112
+ side_effect=lambda *_a, **_kw: _fake_streaming(),
113
+ ):
114
+ snapshots = await _collect_chatbot_snapshots(_on_turn_fn)
115
+
116
+ # At least one snapshot must contain a metadata message with status="pending".
117
+ pending_found = False
118
+ for snap in snapshots:
119
+ if not isinstance(snap, list):
120
+ continue
121
+ for msg in snap:
122
+ meta = msg.get("metadata", {}) if isinstance(msg, dict) else {}
123
+ if meta.get("status") == "pending":
124
+ pending_found = True
125
+ break
126
+ assert pending_found, (
127
+ f"No pending step-list bubble found in any mid-stream chatbot snapshot.\n"
128
+ f"Snapshots: {snapshots}"
129
+ )
130
+
131
+
132
+ async def test_final_chatbot_has_no_pending_bubble(_on_turn_fn: Any) -> None:
133
+ """The final chatbot snapshot must NOT contain any pending bubble."""
134
+ with patch(
135
+ "loosecanvas.main.run_agent_turn_streaming",
136
+ side_effect=lambda *_a, **_kw: _fake_streaming(),
137
+ ):
138
+ snapshots = await _collect_chatbot_snapshots(_on_turn_fn)
139
+
140
+ assert snapshots, "on_turn yielded no chatbot snapshots"
141
+ final_snap = snapshots[-1]
142
+ assert isinstance(final_snap, list), f"Final snapshot is not a list: {final_snap}"
143
+ for msg in final_snap:
144
+ meta = msg.get("metadata", {}) if isinstance(msg, dict) else {}
145
+ assert (
146
+ meta.get("status") != "pending"
147
+ ), f"Pending bubble found in final chatbot snapshot: {msg}"
148
+
149
+
150
+ async def test_final_chatbot_contains_tool_receipts(_on_turn_fn: Any) -> None:
151
+ """The final chatbot snapshot must include the tool-receipt messages from FinalEvent."""
152
+ tool_label = "relabel_edge:edge-1->edge-2"
153
+
154
+ async def _fake_with_label() -> AsyncGenerator[Any]:
155
+ yield ToolActivityEvent(message="relabelling edges")
156
+ yield FinalEvent(
157
+ turn_result=None,
158
+ assistant_message="Done.",
159
+ tool_activity=[tool_label],
160
+ )
161
+
162
+ with patch(
163
+ "loosecanvas.main.run_agent_turn_streaming",
164
+ side_effect=lambda *_a, **_kw: _fake_with_label(),
165
+ ):
166
+ snapshots = await _collect_chatbot_snapshots(_on_turn_fn)
167
+
168
+ assert snapshots, "on_turn yielded no chatbot snapshots"
169
+ final_snap = snapshots[-1]
170
+ assert isinstance(final_snap, list)
171
+
172
+ # The tool receipt appears as a metadata-titled message with title containing the
173
+ # tool name extracted from the label (the part before the first ':').
174
+ tool_name = tool_label.split(":")[0]
175
+ receipt_found = any(
176
+ isinstance(msg, dict)
177
+ and tool_name in (msg.get("metadata") or {}).get("title", "")
178
+ for msg in final_snap
179
+ )
180
+ assert receipt_found, (
181
+ f"No tool-receipt message for '{tool_name}' found in final snapshot.\n"
182
+ f"Final snapshot: {final_snap}"
183
+ )
tests/test_tooltip_stale_review.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for Task B β€” stale hover-tooltip fix.
2
+
3
+ After a review the clear_pending RendererPatch must ALSO emit an update_data op
4
+ carrying {'id': node_id, 'data': {'review': <new_review_state>}} so the element's
5
+ Cytoscape data('review') field is refreshed and the hover tooltip no longer shows
6
+ the stale "pending review" text.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ import uuid
13
+ from collections.abc import Iterator
14
+
15
+ import pytest
16
+ from loosecanvas import turn_logic
17
+ from loosecanvas.contracts import Claim, Node, SceneState, SourceSnapshot
18
+ from loosecanvas.graph_repository import LooseGraphRepository
19
+ from loosecanvas.turn_logic import (
20
+ do_review_claim,
21
+ session_locks,
22
+ session_store,
23
+ )
24
+
25
+ # ── Session isolation ─────────────────────────────────────────────────────────
26
+
27
+
28
+ @pytest.fixture(autouse=True)
29
+ def _isolate_sessions() -> Iterator[None]:
30
+ session_store.clear()
31
+ session_locks.clear()
32
+ yield
33
+ session_store.clear()
34
+ session_locks.clear()
35
+
36
+
37
+ # ── Session helpers ───────────────────────────────────────────────────────────
38
+
39
+
40
+ def _make_session(claims: list[Claim] | None = None) -> str:
41
+ node = Node(id="n1", kind="concept", label="Test Node")
42
+ repo = LooseGraphRepository.from_extraction(
43
+ [node], [], claims or [], SourceSnapshot()
44
+ )
45
+ scene = SceneState(scene_id="s1", visible_node_ids=["n1"])
46
+ sid = str(uuid.uuid4())
47
+ now = time.time()
48
+ turn_logic.session_store[sid] = turn_logic.SessionRecord(
49
+ session_id=sid,
50
+ graph=repo,
51
+ scene=scene,
52
+ history=[],
53
+ graph_version=1,
54
+ created_at=now,
55
+ last_seen=now,
56
+ render_scene_id="s1",
57
+ )
58
+ return sid
59
+
60
+
61
+ def _pending_claim() -> Claim:
62
+ return Claim(
63
+ id="c1",
64
+ claim_type="label",
65
+ target_id="n1",
66
+ origin="model_inferred",
67
+ review_state="pending",
68
+ support_state="unverified",
69
+ text="test",
70
+ )
71
+
72
+
73
+ # ── Tooltip fix tests ─────────────────────────────────────────────────────────
74
+
75
+
76
+ def test_do_review_claim_accept_emits_update_data_with_accepted_state() -> None:
77
+ """Accepting a pending node claim must emit an update_data op refreshing
78
+ data('review') to 'accepted' so the hover tooltip no longer shows 'pending'."""
79
+ sid = _make_session([_pending_claim()])
80
+ result = do_review_claim(sid, "c1", "accepted")
81
+
82
+ update_ops = [
83
+ o
84
+ for o in result.renderer_patch.operations
85
+ if o.op == "update_data"
86
+ and o.data.get("id") == "n1"
87
+ and isinstance(o.data.get("data"), dict)
88
+ and "review" in o.data["data"]
89
+ ]
90
+ assert update_ops, (
91
+ "do_review_claim(accept) must emit an update_data op for the reviewed node; "
92
+ f"got operations: {[o.op for o in result.renderer_patch.operations]}"
93
+ )
94
+ review_value = update_ops[0].data["data"]["review"]
95
+ assert (
96
+ review_value == "accepted"
97
+ ), f"update_data review field must be 'accepted', got {review_value!r}"
98
+
99
+
100
+ def test_do_review_claim_reject_node_emits_update_data_with_rejected_state() -> None:
101
+ """Rejecting a pending node claim clears the badge AND refreshes data('review')
102
+ to 'rejected' (not 'pending') so the tooltip reflects the real state."""
103
+ sid = _make_session([_pending_claim()])
104
+ result = do_review_claim(sid, "c1", "rejected")
105
+
106
+ update_ops = [
107
+ o
108
+ for o in result.renderer_patch.operations
109
+ if o.op == "update_data"
110
+ and o.data.get("id") == "n1"
111
+ and isinstance(o.data.get("data"), dict)
112
+ and "review" in o.data["data"]
113
+ ]
114
+ assert update_ops, (
115
+ "do_review_claim(reject) on a node claim must emit an update_data op; "
116
+ f"got operations: {[o.op for o in result.renderer_patch.operations]}"
117
+ )
118
+ review_value = update_ops[0].data["data"]["review"]
119
+ assert (
120
+ review_value == "rejected"
121
+ ), f"update_data review field must be 'rejected', got {review_value!r}"
122
+
123
+
124
+ def test_do_review_claim_update_data_contains_snake_case_state() -> None:
125
+ """The review value in update_data must be the snake_case state string
126
+ ('accepted', 'rejected') β€” NOT a class name ('review-accepted' etc.)."""
127
+ sid = _make_session([_pending_claim()])
128
+ result = do_review_claim(sid, "c1", "accepted")
129
+
130
+ for op in result.renderer_patch.operations:
131
+ if op.op == "update_data" and op.data.get("id") == "n1":
132
+ review_val = op.data.get("data", {}).get("review", "")
133
+ assert (
134
+ "-" not in review_val
135
+ ), f"review value must be snake_case, not a class name; got {review_val!r}"