- {type:"dismiss"} input event: parse + pure appends with the answer-event
orphan discipline (dangling-call 400, kind match, answer must be dismissed)
- POST /api/chats/[id]/dismiss: the no-turn composition — write-ahead append,
409 while a turn runs, emits record-changed (new bus word; idle views sync)
- needsContinuation: an all-dismissed trailing batch demands nothing;
endsInDismissal = the derived pause predicate (tail-based, no latch)
- wake pipeline: while the record ends in a dismissal the pump holds every
delivery in the steer queue — the user's next send is the wake (until the
text-turn steer claim lands, settlement re-pump delivers right after it)
- session-core.dismissQuestion: optimistic skipped card + silent POST;
chat.tsx confirmAbort question-mode swaps submitAnswer for it; ESC-abort
popup everywhere (AbortTurnDialog) with popup-Esc claimed via state gate
- parallel question calls dismiss call-by-call (results bind by id)
- requiresReveal gates on pending.tool === 'quiz' (owner: only graded quizzes
arrive collapsed; ask/explain always open)
- ESC-abort chain opens AbortTurnDialog everywhere (owner ruling); from a
pending question abort IS dismissal — dismissedAnswer rides the answer
path, the teacher acknowledges the skip in its next turn; popup-Esc is
claimed via the state gate (two window listeners fire per Esc)
- 25-confidence.md: stripped the slider/JSON/calibration mechanics — pure
reading doctrine remains
- toolQuestionKind named once in core (dock parity), dock switched to it
Consistency ruling (owner, 2026-10-01): the finish-first send and the
run-end focus are properties of the COMPOSER, not of one page.
- home (graph view, raw textarea): topicRef mirror; start() finishes
dictation first (before the setup/key guards, so an intercepted draft
is the complete one) then reads the mirror; the start square stays
live while a run holds deferred speech; run end focuses the topic
input.
- new-session: the shell owns the dictation instance — handleSend wraps
the parent's send with finish-first, the composer stays sendable
during a run, and run end focuses it via the Composer's inputRef. The
parent's send reads a textRef mirror (applyText at every write): the
flush lands via setState after the shell's await, and the parent's
closure must not read a stale draft.
Two owner rulings, 2026-10-01 ('nothing gets lost' round):
1. SUBMIT FINISHES DICTATION FIRST. Every submit path — the dock's
answer (Enter/Cmd+Enter/button) and the composer's send — stops any
live run and lets Deepgram's last finals land in the target field
BEFORE building the payload. Dictation.finish() resolves at cleanup,
post-flush; a module finisher registry (same pattern as the question
target) hands it to the dock without a prop bridge. Field values are
read from REF MIRRORS (thinkingTextRef/answerTextRef, the composer's
textRef): the flush lands via setState after an await, invisible to
the closure. The quiz flow the owner described now holds: dictate
reasoning → pick answer → pick confidence → Enter → dictation ends,
reasoning saved into the thinking field, THEN the answer submits.
The composer stays sendable while a run is live (its spoken text is
deferred in the run's buffer) so Enter can trigger the finish-first
send at all; nothing said → the send no-ops.
2. RUN END FOCUSES THE BARE COMPOSER. The falling edge of
dictation.active focuses the composer's textarea — only when the
composer is mounted: a pending question unmounts it (ref null, no
focus), so the thinking field is deliberately left alone. Composer
gains an optional inputRef for the page's focus handle.
The rail paint is extracted to components/confidence-rail.tsx (ramp,
tick stack, geometry laws) and shared by both surfaces so they cannot
drift: the dock's live slider paints the rail under its disc, the
answered card renders it disc-less — the missing handle itself says
'record, not control'. Same look otherwise: thin rail, stop color,
solid ticks, % read-out on the right, dock-mirrored placement (below
the options, above the thinking).
Renders only where a confidence exists: quiz answers with one sent;
never on 'I don't know' (none was sent), never on explain/ask, never
on pre-feature records (optional field, absent).
Round-4 regression, root-caused:
- The rail overlay (position:absolute) painted above the in-flow input
no matter the DOM order — CSS paints positioned elements after
in-flow ones — so the bar sliced across the disc ('disc behind the
bar').
- The wrapper's height was unpinned: the rail's top-1/2 centered
against a stretched box while the disc rode the input at the top —
vertical drift, which also exaggerated the disc's overhang and made
the original-size 16px disc read oversized.
Fix: the input is absolute inset-0 too (DOM order now governs — the
disc rides above the rail), and the wrapper is pinned to h-4 so rail
center and disc track share one box. Geometry otherwise unchanged from
round 4: rail spans the thumb-center range, 0/100 flush at the very
ends, solid ticks, no rim.
- 0/100 at the very edges: the paint moved off the input onto a rail
overlay spanning exactly the thumb-CENTER range (inset 8px = half
the 16px disc per side) — the disc rests flush against the rail's
ends, no bare stub poking past it. The input is now pure interaction:
full width, transparent, 16px-tall hit area, native drag.
- Ticks solid, not dashed; plain p% positions (the overlay IS the
center range), still background layers under the fill.
- Typing guard (owner): a range input is NOT a typing field — after a
click or drag on the slider the ↑/↓ option walk and ←/→ confidence
cycling keep working. Only real text fields (thinking, answer,
search, renames) own their keys. The slider's own keydown is gone —
the global machine owns arrows, and its preventDefault cancels the
native 1% step.
- Disc rim reverted: plain bg-current disc again.
Owner refinements 2026-10-01:
- Bar back to the original h-1.5 rail (colors unchanged).
- Disc bleeds into its own fill no more: 2px #141414 rim on the thumb
reads as a gap wherever it sits.
- Dashed stop ticks rebuilt as BACKGROUND LAYERS on the input itself:
#141414 base color, four dashed tick gradients (30/50/70/90 only —
the rail's ends already say 0 and 100) sized 1px x 100% at the
thumb-center inset, and the fill gradient now TRANSPARENT past the
thumb and painted on top — ticks live inside the bar, top to bottom,
visible only on the empty side, never over the filled portion.
Root-cause find: the sidebar 'highlighting' on ←/→ was :focus-visible —
pointer-clicked elements match it only after a keypress, so any key in
the quiz lit the still-focused session row. The arrows never moved
focus. Fix: the no-ring law (owner) — *:focus/:focus-visible outlines
removed app-wide in globals.css; surfaces keep their own affordances
and the dock's keyboard walk keeps its ring utilities.
Slider repairs:
- mousedown-preventDefault had killed click + drag in Chromium —
removed (with tabIndex -1); the slider is natively interactive again,
and the ring law makes its focus invisible anyway.
- Focused arrows step the STOP index on the input itself (native would
step 1% and snap straight back — dead keys); the global handler
skips INPUT targets, so no double-step. Enter still submits.
- Empty side: var(--muted) matched the card and vanished — now #141414
(the owner's main-background pick), and the pill carries a border
again (border-border).
- Dashed tick per snap stop below the bar, inset by the thumb's
half-width so each tick sits under the thumb's center at that stop.
Owner UI round 2026-10-01, three rulings on the slider plus one en-route
bug:
- GLOBAL ←/→, the ↑/↓ pattern: confidence adjusts wherever focus sits
(window keydown, typing-guarded), no click-in, no focus ring. The
slider itself takes NO focus — tabIndex -1, mousedown prevented,
removed from the Tab chain (rows → thinking directly again).
- Position = fraction: the thumb's scale is 0-100 with the rendered
value the stop's own percent, so 50 sits halfway; onChange snaps to
the nearest stop (magnet feel while dragged).
- Six-step ramp (owner pick over three bands): 0 red, 30 orange,
50 yellow (the coin flip), 70 lime, 90 green, 100 emerald — fill AND
handle wear the stop's color; taller 10px pill bar, custom track/thumb
(webkit + moz), muted remainder via --muted.
- Plan-flank phantom: gating the marginalia button on a JSX element
(always truthy) mounted an invisible clickable button in the
bottom-right whenever a question was pending with no plan.
planExists() now gates it (pinned in plan-window tests).
Owner design round 2026-10-01: every multiple-choice quiz answer now
carries the learner's stated probability that his selected answer is
correct — calibration data for the prober and teacher, the flow itself
unchanged.
Definition (owner ruling, after weighing chance-normalization): RAW
probability. Sub-chance reports are diagnostic gold, not dead space —
'picked an option, believes it wrong' is a real state. The agents get
the sophistication: new shared/25-confidence.md composes for both
agents (chance level, sub-chance = distrusts the pick, correct+low =
fragile, wrong+high = committed misconception, IDK = genuine gap).
- question-core: AnswerRequest/QuestionAnswerData.confidence (quiz
only, optional — old records valid), buildAnswer clamps and drops it
on dontKnow, formatAnswerMessage states the line, CONFIDENCE_STOPS +
DEFAULT_CONFIDENCE_INDEX exported.
- question-dock: snapped slider below 'I don't know', above 'Your
thinking' — native range over the stop INDEX so ←/→ step the uneven
grid cleanly, Tab chain rows → slider → thinking, Enter submits from
the slider too. Rests at 50 and always sends (owner: 50 must mean a
real coin flip, never 'untouched'). 'I don't know' GRAYS it out —
layout holds, no value sent.
Deliberately untouched: explain free-recall card (quiz-only scope),
answered-card rendering of confidence, ask flow.
The partitioner was told to reference chain ids, but no rendered line
ever showed one — chainLine emitted bare 'premises → conclusion', so
the model re-typed whole rendered lines into submit_partition and every
call failed 'unknown chain ids' validation (owner live report,
2026-09-30 evening). The generator's YOUR TARGET block had the same
blindness.
- chainLine now leads with the chain's id: [arg_x] n_a, n_b → n_c;
a bare proposition repeats its own id ([n_y] n_y (no premises)) —
mechanically teaching that its chain id IS its proposition id.
- PARTITIONER_INSTRUCTIONS rewritten to chains-primary vocabulary
(was still 'follows-edges, bijective re-encoding' — pre-chains
framing) with the id-only doctrine and singular propositions
first-class.
- partitionSchema/problemSchema gained field descriptions: ids
VERBATIM, never re-type, never invent.
- Generator OUTPUT bullet + target-block header say the same.
Design ruling en route: partitioner stays an LLM partitioning chains
(deterministic partitioning and propositions-as-targets both
considered and declined by the owner).
Owner live-test verdict on the spine (2026-09-30): no perceptible gain —
the real problems (restatement nodes, shallow re-derivations, same
conclusion from many angles) are mint-time issues a post-hoc chain
minter cannot fix. This arc moves assembly INTO the extractor and adds
the deterministic dedupe the owner specified:
- Digest-aware extraction: each stretch's extractor prompt carries THE
GRAPH SO FAR (its own source's merged propositions + chains, ids
verbatim); build on existing ids instead of restating, lean on
established conclusions as premises. Mixed-namespace chain endpoints
(fresh p-ids OR existing n_-ids) validated at the submit seam.
- Serialized per source: a source's batches/chunks run strictly in
order (each extract reads every earlier merge); cross-source runs
stay parallel; source-wide abort stops the loop. The spine stage,
role, phase, usage stage, cost row, and setup roster are gone.
- Core-ideas prompt: goal reframed as uncovering core ideas and showing
how everything derives from the foundations; direction upward, prefer
lean derivations, never re-derive what the graph already holds.
- Chain simplification (merger maintenance): simplifyChains drops
provably redundant chains at every merge — prefer-fewest-premises +
ancestry-cover ([A,B]⟹X with [X,C]⟹Y covers [A,B,C]⟹Y, transitive);
same-key provenance lines drop together; rewriteLedgers under the
audit lock; droppedChains counted separately in stats.
Chunk budget stays 8192 (owner: keep for now, maybe lower later).
Per-batch pipeline is now extract → spine → audit → merge (owner order,
2026-09-30), the spine inside the serialized audit section so it reads
the graph exactly as the following audit will. Measured motive: 0 of 133
chains in the live corpus crossed a stretch boundary — no stage minted
cross-stretch chains.
- src/agent/subagents/spine.ts: SESSION/NOTES spine preambles + shared
SPINE_CORE (span doctrine; extraction doctrines carried over), the
submit_spine TypeBox schema, validateSpine (mechanical span law: every
chain touches >=1 fresh endpoint AND >=1 existing digest endpoint;
unknown ids, self-reference, and id collisions are loud), the digest
prompt builder, and the runner (strong tier, retry-only).
- extraction.ts: 'assembling' phase, spine stage in the per-need gate,
usage/billing stage, assemble() inside withAuditLock for both front
doors; first stretch of a source skips the call (empty digest, no
spend); the audit sees COMBINED propositions (chunk + connectives),
never chains; merger receives the combined findings.
- core/graph.ts mergeFindings: chain endpoints resolve through the
job-local map OR pass through verbatim as existing n_ ids (the spine's
mixed namespace).
- EXTRACTION_CORE: steps-of-reasoning selection bar (examples, notation
glosses, and restatements that no argument needs are not steps).
- Factory chunk budget 4096 -> 8192 (fewer seams, more local context).
- healStringifiedArrays moved to the shared submit seam (submit-agent.ts).
- Cost panel: Assembly row; usage records carry a spine stage (optional
on legacy records).
- Tests +5: prompt pins, span-law matrix, digest rendering, seeded
end-to-end flow (spanning chain + connective, verbatim n_ endpoint,
spine billed as its own stage), non-spanning refused + retried.
Data analysis of the five-source corpus (490 nodes, 244 chains) found
three pathologies the goal-first core let through:
1. Narrative-adjacency chains — premises that occasioned rather than
entailed conclusions (concentrated in the polemical sources).
2. Session-scaffolding minted as knowledge — the source's own axiom
numbering (A1/F2/UT2), arc labels ('the crux') persisted as nodes.
3. Full-discourse volume — glossary and argument-arc material at equal
rank with load-bearing ideas (~200 nodes per source).
Three doctrine lines, same terse register as the core:
- entailment is the only link that earns a chain; historical/narrative
connections keep their pieces as bare propositions or drop them
- load-bearing ideas only (what a reference on the subject would teach)
- propositions must stand alone for a reader who never saw the source;
no leaning on the source's own scaffolding
Substring pins for all three; shared by session + notes extractors by
construction.
Owner ruling (2026-09-30, live-testing loop — supersedes 02a58db's
exemption): implication nodes ARE depth-anchored after all. Their anchor
is the average of the HIGHEST premise row and the conclusion's row —
depth(imp) = (max(premise depths) + depth(conclusion)) / 2 — so the
junction always sits strictly above every premise and strictly below
the conclusion (a conclusion is >= highest premise + 1 in the logical
graph). Uniform across modes: a half-row in vertical, a ring midway in
radial (owner confirmed both).
Implementation:
- layout.ts: new ImpLink type; SimOptions/incrementalLayout gain
impLinks (the derived chain linkage). The rank map is now computed
once alongside the orphan mask: proposition ranks via Tarjan over the
logical edges, then each supplied link adds its midpoint entry. An
imp node WITH an anchor is structured (leaves the orphan mask —
gravity projections treat it like any ranked node); WITHOUT supplied
links, arg_ ids keep the legacy 02a58db exemption (honest fallback
for callers that predate the field). The depth force reuses the
precomputed map — one pass, shared.
- deriveView (core/graph.ts) emits impLinks (one per multi-premise
live chain — single-premise chains mint no imp node); GraphSnapshot
carries them; graph-view maps them into CanvasData; settleGraphLayout
and replay-layout forward them (replay activates a junction's link
exactly when its chain is delivered).
Tests (+3, 1 rewritten): the 02a58db exemption pin became the MIDPOINT
pin (premises row 0, conclusion row -500, junction pinned to -250);
mixed-depth pin (premises at 0 and 1, conclusion 2, junction at the 1.5
half-row — highest premise, not average-of-premises); legacy fallback
pin (no impLinks = never depth-anchored); deriveView impLinks emission
pin (multi-premise only). The horizontal-pole and ORPHAN RULE pins from
5554c3a survive unchanged — proposition orphans are untouched.
Owner ruling (2026-09-30, straight from the live-testing loop): when the
depth anchor is vertical, the central pole (center-of-mass gravity)
should create forces on the x axis ONLY.
Half of this was already true: structured propositions have had x-only
gravity in vertical mode since the 2026-09-13 depth projections (the
depth force owns y). The change extends it to the one branch that still
pulled on both axes — the orphan mask (unstructured propositions, and
since 02a58db the imp geometry points): in vertical mode everyone gets
the x-only pull; the full regular pull survives only in radial mode
(where the rings own the radius) and in mode-off (plain forceX/forceY).
Why this is coherent: y in vertical mode belongs to the depth rows and
to nothing else. A structured node sits on its row; an unstructured
node has no row to sit on — and now no pole to sag toward either. Its
y is free (charge/springs only), so orphans keep their seeded or
warm-started height instead of piling onto the center line, and imp
bundles settle purely by their springs between premise and conclusion
levels.
Amended the ORPHAN RULE comments to state the new law, plus the imp-
exemption comment (gravity is horizontal-only in vertical mode "like
everyone"). Radial and mode-off paths untouched.
Tests: the ORPHAN RULE pin rewritten to the amended law (orphan's y
untouched at its seed, x still converges on the pole — the old pin
asserted y converging on gravity's center); new explicit pin in the
logical-graph describe (lonely node: |x| < 50, |y − seed| < 5); the
02a58db exemption fixture's comment updated to match (springs alone
place arg_x between levels).
Owner-approved brief (orchestrator, physics arc): two laws.
1. IMP-NODE CLUSTER ASSIGNMENT. Imp nodes were unclustered (the partition
runs over stored propositions only) — no centroid pull, outside the
painted hulls, bundles sitting on the periphery pointing outward.
Now: a derived imp node's cluster is a function of its derivation —
MAJORITY among its PREMISES' birth sessions (owner: premises, NOT the
conclusion), ties to the chain's OWN birth (its provenance's first
point — the asserting session; deterministic, coincides with the
unanimous common case). Imp nodes are always re-derived, never
birthed, so the assignment must be exactly this.
Seam chosen: core/session-clusters.ts exports impClusterKey + a
shared attachImpToCommunities that mutates a communities list's
members IN PLACE. graphSnapshot attaches AFTER the hull filter (a
2-proposition community + its bundle never resurrects a hull — the
filter counts propositions); settleGraphLayout attaches to the
unfiltered partition (settle pulls every community; hulls are a
canvas-only concern). Both cluster-membership consumers — the sim's
centroid pull and buildRegions' rasterization — read it through the
members lists that went through the one helper, so the resting
ledger and the live sim agree on where bundles belong.
2. DEPTHS OVER THE LOGICAL GRAPH. inferenceDepths ran Tarjan over the
DRAWN edges (premise→imp→conclusion), inflating every multi-premise
conclusion one hop (depth(imp)+1 instead of depth(premises)+1) and
ranking the geometry point itself. Now:
- deriveView additionally emits logicalEdges — every chain contracted
to direct premise→conclusion arrows over PROPOSITION ids only
(per-premise, deduped — a conclusion's rank is max over premises,
which a single arbitrary premise edge could not express).
- GraphSnapshot carries logicalEdges; createGraphSimulation /
incrementalLayout accept a logicalEdges option: the depth
machinery (orphan detection AND the rank pass) reads it, springs
keep the drawn edges. Absent = edges (legacy behavior).
- Imp nodes join the ORPHAN-EXEMPTION mask explicitly — never pinned
to a level, keep the full regular forces (gravity, cluster pull,
springs), settle naturally between premise and conclusion levels;
the rank map is computed over propositions only, so arg_ ids are
absent from it by construction.
Wiring: settle + canvas (CanvasData.logicalEdges → buildSim) +
replay-layout all pass the logical set.
EXTRA FIX (in-slice discovery, one line, graph-server domain):
server/graph.ts graphClusters — the practice picker's /api/graph/
clusters — partitioned snap.NODES (the canvas image, imp nodes riding
it since chains-primary), leaking arg_ ids into nodeIds: pickable ids
that silently vanish inside chainsForSelection while nodeCount lied.
Now partitions snap.propositions — same purity law as the chain
table's 767a559 fix.
Tests (+13): impClusterKey pins (unanimous; cross-session majority; tie
→ chain's own birth, not the conclusion's); attach pins (members gain
the imp id, sorted; single-premise attaches nothing — no node exists;
dangling mints nothing; home absent from the list stays unattached);
snapshot pins (imp id rides cluster members to both consumers; the hull
filter counts propositions); logical-depth pins (multi-premise
conclusion at premises'+1 NOT +2; imp absent from the rank map);
depth-force exemption pin (rows pin crisply, arg_x floats unpinned —
old behavior stamped it onto the conclusion's row); deriveView logical
arrows (per-premise, deduped, proposition ids only).
Owner-approved brief (orchestrator): the canvas stops drawing implication
nodes as first-class nodes and starts drawing them as what they are — the
point where co-premise lines converge.
- deriveView (core/graph.ts): single-premise chains collapse to ONE direct
premise->conclusion arrow — no imp node minted (a chain is always N-in/
1-out; one premise is the degenerate case). Multi-premise chains keep
the imp node + fan. Dangling guard unchanged.
- physics (core/layout.ts createGraphSimulation): per-link rest length —
a link touching an arg_-prefixed endpoint gets HALF the base link
distance, default or user-tuned alike; everything else about imp-node
physics citizenship unchanged.
- canvas (components/graph-canvas.ts): imp nodes are PURE GEOMETRY —
invisible (no disc, no label) and unpickable (excluded from pickAt's
hit-test projection). Edges INTO an imp node: headless, shaft runs to
its CENTER (toRadius 0, no head triangle). Edges OUT: shaft starts at
the center (fromRadius 0), arrowhead lands on the conclusion's disc
edge. Single-premise direct arrows render as today's plain arrows.
- comment repairs (same commit): the two stale double-shaft claims
(CanvasEdge doc + the draw-pass block) rewritten to the truth — plain
single shafts; bundles are headless converging lines + one arrowed
out-edge; no double-shaft glyph exists anywhere.
- tests (+9): deriveView pins (single-premise collapse, multi-premise
fan, shared-conclusion distinctness, dangling drop); imp-link half-
distance settle pins; arrowGeometry zero-radius laws + isImpNodeId
pin; graph.test.ts's snapshot invariant now counts only MULTI-premise
live chains (its fixture's single-premise chains mint zero imp nodes);
layout settle fixture updated for the collapse.
The owner's live test surfaced it: after importing notes, the chains view
listed implication nodes as bogus singleton "chains" — and each carried
the same arg_ id as the real stored chain, tripping React's duplicate-key
error (chain-table.tsx:372).
Root cause: graph-view passed snapshot.nodes — the derived canvas view
(propositions + implication nodes) — into ChainTableOverlay, whose
chainsForSelection turns every unchained node into a singleton chain.
An implication node is never a chain member (members are proposition
ids), so every imp node minted a bogus singleton with a colliding id;
its row was even selectable, leaking arg_ ids into practice input.
Fix (owner-approved shape): GraphSnapshot gains `propositions` — the
stored-truth node list riding beside `chains` for exactly this class of
consumer. The chain table now receives snapshot.propositions; the canvas
keeps snapshot.nodes (imp nodes are meant to be drawn). Practice pipeline
unaffected (it already read getGraph, which is propositions-only).
Test: the snapshot split pinned as an invariant in graph.test.ts — image
= truth + one imp node per fully-live stored chain; truth never carries
an arg_ id.
- architecture.md section 6 rewritten: stored truth = propositions + chains
(chains.jsonl); deriveView as the lossy image (implication nodes +
untyped arrows, rebuilt per read); the bijection framing (independent
arguments for one conclusion stay distinct — the corruption the edge
store invited); new deletion-semantics paragraph (proposition death
cascades to every referencing chain; arg_ death = one argument);
supports retirement stated plainly; auditor's propositions-only scope;
clusters stated as birth-session partition truth (no Louvain claim)
- section 7 practice chain model: stored-chains inclusion law replaces
follows-edge grouping; chainsToGraph bijection claim deleted
- README: graph clauses (stored chains, derived view, chain dedupe),
practice clause, ledger inventory, and two long-stale line-5 claims
(ledger events, semantic top-K) corrected
- contracts/graph.ts GraphSnapshot comment: Louvain claim replaced with
birth-session truth; extraction.ts comments no longer narrate the
dead expandFindings/chainsToEdges seam
Owner rulings 2026-09-30: reasoning chains are the stored truth; the graph
canvas renders a lossy derived image. No migration, no backcompat — old
graph data was wiped.
- contracts: GraphChain (arg_-minted id, premises, conclusion) replaces
typed edges; GraphEdge survives only as the untyped derived arrow;
GraphSnapshot carries stored chains; supports edges retired entirely
- store: chains.jsonl ledger (edges.jsonl purged at load alongside legacy
events.jsonl); readChains/appendChain seam; merge dedupes by
(sorted premises, conclusion), re-assertions append under the same arg_ id
- core: deriveView mints implication nodes (name ⟹, statement via
formatChainLine) plus premise→imp→conclusion arrows; clusters stay
proposition-only (birthSessionPartition); chainsForSelection now groups
STORED chains (chain rides iff every member selected) — independent
arguments for one conclusion never union; chainsToGraph/chainsToEdges die
- extraction: extractor submits chains natively (expandFindings deleted);
auditor receives propositions only; supports fan-out gone
- deletion: full death of a proposition kills every referencing chain;
arg_-prefixed node deletes kill that chain; chainsRemoved stats
- UI: every arrow renders the ⟹ double shaft; supports toggle + thickness
slider and detail-card edge sections deleted (card keeps name +
proposition); chain table + practice pipeline read stored chains
- tests: 1188 green across the new seams
'A → B → C' misread as chained implication when A and B are
CO-premises. The honest shape (owner, 2026-09-30): premises join
with the LaTeX LAND (∧), the conclusion separates by the LaTeX
IMPLICATION (⟹) — A ∧ B ⟹ C.
The glyphs are REAL KaTeX renders — components/latex-glyph.tsx wraps
katex.renderToString (cached per command; the app's math engine, the
stylesheet already rides app-wide from app/layout.tsx). No SVGs, no
font-dependent unicode. PROMINENT in the theme foreground (white in
the dark theme) per the owner's same-round ruling — the logic is the
payload, not the old dim gray. A lonely chip carries no glyph (it
reads as a standalone proposition); the plain-text '→' survives only
in the row's aria-label (formatChainLine, the screen-reader form).
Owner 2026-09-30: the confirm dialog's pre-flight impact read had to
go — what full death takes is obvious, the per-open fetch flickered,
and richer facts would only confuse as deletion semantics evolve
(rewiring is on the table). The screen is now: Delete node? / Delete
N nodes?, the names in the mono block, one permanence line, Cancel /
Delete. No loading state, no gone state (a 404 mid-run lands in the
honest failure line), no sessions join — the joins prop died too.
The dead path is deleted end to end: GET /api/graph/nodes/[id],
graphNodeImpact (server), nodeDeletionImpact (core) and their tests.
WORDING: node/node(s) is THE UI word for graph objects, standardized
across every surface's copy (owner, same ruling): the dialog titles,
the chain table's counts/search/summary/delete aria-labels, the
practice dialog's cluster counts, selection line, and empty-cluster
hint. Internal identifiers (selection.propositions) unchanged.
Rewritten dialog pins (tests/delete-node-dialog.test.tsx): minimal
contract — no facts/loading/fetch copy, singular/plural titles, the
cap, 409 dock phrasing, busy states, no 'proposition' left in the
dialog.
"This is really the view deletion should be done in" — the chain table
becomes the graph's MANAGEMENT surface. The floating selection bar
gains Delete FENCED at its right end (owner-approved placement): a
divider quarantines the destructive action from Clear and Generate,
red destructive pair + trash icon, opening the SAME full-death confirm
dialog the canvas uses.
DeleteNodeDialog extended from one node to an array:
- one node renders exactly as always; many pluralize the title
(Delete N propositions?) and list the names in the mono block,
capped at 8 + 'and K more'
- the pre-flight impact read aggregates: summed edge counts, unioned
referencing sessions (deduped, first-seen order); all-404 = gone
- confirm runs the per-node DELETEs SEQUENTIALLY — a failure stops
the run (extraction_running is never plowed through); onDeleted
now carries every id that actually died
- the chain table prunes its selection on delete and announces
lh:graph-revision so the snapshot refetches
Drift fix found en route (surfaced as NaN in the aggregated facts):
the server's NodeDeletionImpact has been {edges, sessions} only since
the quiz-events ledger died in the practice-arc schema slim — the
dialog kept rendering quizEvents/observations as undefined. The dead
fact lines are removed; the dialog now shows exactly what the server
can honestly report.
The chains-overlay TopBar had dead drag zones (counts, around search,
close gap) — cause: app-region subtraction resolves by DOM ORDER, not
z-index; the graph chrome's lh-no-drag column came later in the DOM and
punched holes through the overlay's drag band despite sitting visually
underneath (z-40 vs z-10).
Two laws, per the owner's standardization ruling:
- AUTO CARVE-OUT (globals.css): inside any .lh-drag band, every native
interactive element (button/input/textarea/select/a/summary/
[contenteditable]) is automatically a no-drag island — draggable
anywhere that is not a clickable element becomes structural. Manual
lh-no-drag is only for clickable non-natives.
- THE OVERLAY DOM LAW: a surface containing a TopBar that covers other
content must be the LAST child of its container — ChainTableOverlay
moved after the chrome column (verified live: last child, after the
column; auto-carve rule compiled; search/close/sidebar-toggle all
still click).
The tree threads no longer touch their arrows. Each thread is
BRACKETED by checkbox bottom edges (live-measured anchors):
- starts at its parent row's checkbox bottom edge — a visible gap
below the arrow's stroke (the stroke ends ~2.4px above the anchor)
- terminates exactly at the LAST child row's checkbox bottom edge
(cluster: top-[23px]/bottom-[8px] incl. the 1px group rule; chain:
top-[22px]/bottom-[8px]). Verified live: 23=23 top anchor, bottom
ends within 0.13px of the last checkbox at both levels.
- TREE THREADS: open folders and chains drop a vertical guide from
their expander arrow straight down through their children — at the
arrow's own column (37px clusters / 73px chains), subtle border/50.
Live-measured pixel-exact: arrow center = thread x (325=325, 361=361).
- The framed table: a rule under the header and under the last group
join the between-group separators; rows stay lineless.
Each staircase step is now TWO columns (36px): chains pl-12 — the
checkbox aligned with the cluster's FOLDER icon — and statements pl-21
— the checkbox where a chain icon WOULD sit (the chains lost theirs;
the pattern continues anyway). Live-measured: chain checkbox = folder
icon (336=336); statement checkbox = chain checkbox + 36 (372).
- chain rows lose the WaypointsIcon (owner: not needed — the chips +
connectors already read as chains); folders keep their icons.
- THE ALIGNMENT STAIRCASE: every level's checkbox sits directly under
its parent's expander arrow. The expander now shares the checkbox's
14px width (h-5 w-3.5), making each nesting step exactly 18px —
clusters pl-3 (12px) → chains pl-7.5 (30px, the cluster expander) →
statements pl-12 (48px, the chain expander). Live-measured: 318=318,
336=336.
- components/top-bar.tsx: THE shared top bar (standardization ruling) —
h-15, lh-drag + carve-outs, select-none, SidebarToggle reopen with the
Electron traffic-light gutter; adopted by chat (rename keeps its
drag={false} escape hatch), new-session, practice-list, practice-exam,
and the chains overlay.
- chain table: prominent indentation (pl-11 / pl-[4.75rem]), the ONLY
lines are the separators between cluster groups (row + vertical lines
gone), plain statement rows (no conclusion marker), all three columns
sortable (Props too) with a small chevron on the active header.