- ZOOM: the ⌘±/⌘0 ladder is owned, not stock roles — applyZoom sets the
factor and broadcasts 'lh:zoom'; the renderer publishes --lh-zoom; the
gutter lengths (.lh-lights-gutter / .lh-closed-toggle) divide by it so
their RENDERED size is a physical constant at every zoom — the graph
button stays glued next to the lights, nothing slides under them.
before-input-event catches ⌘=/layout variants the menu's Plus may not
claim; did-finish-load re-pushes on reload; getZoom covers first paint.
The lights themselves stay at construction position — Electron 44 has
NO setTrafficLightPosition (options-only; the first run crashed on
it). HIG-acceptable: native lights hug the corner; the web chrome
compensates around them.
- JOBS SUITE FLAKE, actually fixed this time: the seams are
module-global and the sweep scans every record — a timed-out async
test left a running orphan + late wake that poisoned its NEIGHBORS
(respawned 3 !== 0). New afterEach fence: settle every tracked
running job, unregister aborts, seams reset to inert no-ops — every
test starts from identical state regardless of upstream flake.
Verified: two consecutive full-suite runs 1326/1326.
- ENTER SUBMITS REGARDLESS OF FOCUS (owner): dictation means the dock
fields are rarely focused. New useEnterSubmits hook — a window-level
listener inside both docks (mounted only while a problem/question is
active). Plain Enter anywhere outside a typing surface submits the
practice thinking / the teaching answer; typing surfaces keep their
own handlers, buttons keep their click-activation (the collapsed
Problem bar still expands on Enter), modified Enters untouched.
Teaching sessions get the same law as practice — one standard.
- MODEL DASHES: the round-5 join("-") edit never landed (the batch
script aborted on an earlier assert before reaching it) — the source
still joined with spaces while the commit message claimed otherwise.
Now actually joined with dashes: 'glm-5.3-flash' everywhere (picker,
setup dialog, stats bar); pins updated to match.
- SIDEBAR: the practice dumbbell moves to the far right of the session
row — titles sit flush with teaching rows, cleaner list.
Under npm test's parallel load the event loop starves and the 8s
wall-clock budget expired before healthy conditions were ever observed;
a timed-out delivery then leaked a late wake into the NEXT test (the
respawn hook is module-global), poisoning even synchronous neighbors —
the recurring double-run-guard / recovery-sweep failures. waitFor
returns the instant the condition holds, so the generous budget costs
nothing when healthy. Full suite 1326/1326.
- THE 3CH RAIL: 'AGENT’S THINKING' (16ch, flush) − 'YOUR THINKING' (13ch)
= 3ch of mono — the rail widens to 3ch (border + padding), keeping the
chevrons aligned while giving the quote room to breathe (the 1ch rail
read cramped). YOU row follows.
- THE STANDARD CHORD everywhere: Enter submits, Shift+Enter is a
newline, ⌘/Ctrl+Enter works too — the teaching thinking field drops
the ⌘-only Gmail convention; the hint reads '↵ submits · ⇧↵ newline'
in both docks, shown in text mode too (it Enter-submits as well).
- The transcript-end optimistic placeholder joins the register: small
mono caps THINKING… (was text-sm sentence case).
- Model names keep their dashes: prettyModel renders 'glm-5.3-flash'
exactly as written (no auto-caps, no dash→space join).
The round-4 batch threw on an earlier replace and never reached
reasoning.tsx — the trigger row kept text-sm. Now text-xs font-medium
uppercase tracking-wide, matching YOUR THINKING and the card eyebrows;
chevrons align through the 1ch rail. Plus one missed prettyModel pin
(gpt 5.2, no auto-caps).
Owner round 4 (2026-10-05):
- THE RAIL IS THE MISSING CHARACTER: rail + padding = exactly 1ch of
the mono heading font. 'YOUR THINKING' (13ch) + rail == 'AGENT
THINKING' (14ch) flush — the chevrons line up vertically, no fixed
columns. The YOU heading joins the rail (label inside, like the
thinking rows) — one quote geometry everywhere.
- HEADING REGISTER: YOUR THINKING / AGENT THINKING / YOU all use the
card eyebrows' small mono caps (text-xs = the 11px rung, font-medium
uppercase tracking-wide) — the app chrome is JetBrains Mono.
- SUBMIT THINKING: the practice button is now literally the question
dock's Button (h-9, px-3.5) in one p-1 row with the hint left
('↵ submits · ⇧↵ newline') — the keyboard hints are back. Corner
radii concentric in both docks: shell 16 − 6 = field 10 − 4 = button 6
(rounded-[10px] fields, rounded-[6px] buttons — the old rounded-xl/12
was the 'too large' culprit).
- MODEL NAMES: prettyModel no longer auto-capitalizes — names render
exactly as their providers write them ('glm 5.3 flash').
- Quiz explanations pulled closer (mt-3 → mt-2).
Owner round 3 (2026-10-05):
- CONCLUDE CARD: the stacked same-weight headings are gone. Plain
'Concluded' eyebrow + the outcome in the quiz cards' EXACT chip
register. Colors per outcome: solved green (the 'correct' twin),
solvedWithHints amber (succeeded with cost), surrendered red (the
problem won), movedOn muted (the agent's call, not a verdict against
the learner). Verdict as plain 50% prose under the heading — the
'Verdict:' label is gone — then a divider, then the labeled model
solution.
- ALL-CAPS: 'YOUR THINKING' and 'AGENT THINKING' — display strings only
(the model-facing marker label stays sentence-case). The two rows are
equal-length over the shared rail, so the chevrons align vertically.
Dock field labels match.
Owner correction (2026-10-05): the collapsed trigger row carried no rail,
so ownership was only visible once expanded. The rail now wraps the whole
block — label (trigger) and, on expand, the italic 50% text — so the
quote reads as one object and WHICH thinking belongs to whom is clear
from the rail alone, collapsed or expanded. Same shape as the user text
('You') row.
Owner round 2 (2026-10-05), Slice H: the Nudge/Commit two-lane buttons
are gone. One Send; Enter sends, Shift+Enter is a newline. Whether a
submission claims an answer is apparent from the thinking itself — the
agent reads it (hint ladder for traces, moves+verdict for claims).
- The thinking input loses the commit field end-to-end: ChatInput,
parseChatInput, newThinkingMessage (details and the model-facing label
both collapse to the single 'Your thinking'), the optimistic marker.
- Legacy records' commit flags are deliberately unread.
- Doctrine: the two labeled channels become one read instruction; 'answer
commits' section retitled 'Claimed answers' — a claim is not a
progress signal.
- ThinkingCard: one label, collapsed arrival (the unified row).
- The dock placeholder no longer explains lanes that no longer exist.
Owner round 2 (2026-10-05), Slice G: 'Solved' alone over unlabeled prose
didn't read as the model solution. The hero now carries its own 'Model
solution' section label (the quiz cards' register), and the verdict
section mirrors it — label muted, text at the same 50% register as quiz
explanations.
Owner round 2 (2026-10-05), Slice F:
- USER TEXT: the right-aligned bubble is gone. User messages are
full-width document rows — muted 'You' label, muted quote rail,
full-opacity text. Learner words are content, not a chat-app tell.
- USER THINKING (everywhere): arrives COLLAPSED as a trigger row in the
exact register of the agent's reasoning rows (muted label + chevron +
hover affordance); expanding opens the quote block — rail, 50%
opacity, italics. The inner-voice signature. The ThinkingBlock loses
its shell/border: pure document material.
- QUIZ CARDS: the answer's thinking is the SAME collapsible row, same
placement, just inside the container; the explanation drops to 50%
opacity (upright — italics stays the thinking signature).
- AGENT'S THINKING: the duration bookkeeping is gone (it was live-turn
state that died to 'a few seconds' on remount anyway) — the settled
row reads static 'Agent's thinking', pairing with 'Your thinking'.
Spinner + shimmer stay the working signal while streaming.
- Glance law: user thinking = rail + 50% italic; agent thinking = no
rail; user text = upright, full opacity, 'You' label.
Owner round (2026-10-05), Slice D — the dock mechanics:
- INTENT-GATED FOLD: a ResizeObserver on the scroller's content stamps
the existing 400ms mute whenever the transcript GROWS — growth frames
(a live turn streaming under stick-to-bottom's chase) can never fold
the dock; and a fold now needs the distance AND stick-to-bottom's
isAtBottom agreeing the learner left. Fixes the generation churn
(dock disappearing and reappearing while the agent works). Shrinks
stamp nothing (the fold's own morph must not self-mute re-expand).
- MEASURED HEIGHT CAPS: the question (55dvh) and problem (40vh) areas
now cap at the available column (scroller clientHeight − 2rem,
re-measured on resize) via --dock-max-h — questions stretch to the
top bar with sensible padding and scroll internally only when
genuinely huge.
- The practice dock's statement area carries the 'Problem' eyebrow —
the quiz cards' label row, mirrored (dock and transcript card read
identically).
- The fold's CONTENT fade: a 160ms WAAPI fade/rise on the bar↔form
toggle — no remount, so the question form's state survives; the
shell height morph was already animated.
Owner round (2026-10-05), Slice C:
- ProblemCard: ALWAYS OPEN — the answered card's container + eyebrow
discipline ('Problem' label, lh-prose statement), no chevron, no
collapsed bar. The dock mirrors it in Slice D.
- ConcludeCard: the reveal is the modelSolution committed at POSE time,
shown verbatim as the hero — never re-authored (the slim schema's
law). The verdict rides below in the quiz-explanation register
(small, muted 'Verdict:' row). Nothing collapsible. Legacy inputs
with their own solutionReveal fall back to it.
- The reveal join: modelSolutionForConclude (native-messages.ts) — the
newest cleanly-posed problem before the conclude call; refused poses
reveal nothing. projectView enriches tool-conclude parts with
part.reveal at projection; ArtifactPartProps carries the field
(contracts/chat.ts).
Pinned: always-open problem card; hero-first conclude layout with the
verdict after it; legacy fallback; the join (clean pose, refused pose,
multi-problem records) and the view enrichment.
Owner round (2026-10-05): the same act (a human thinking in text) had two
visual languages — the quiz cards' quote block vs the practice container
with a collapsing chevron and raw pre-wrap text that read oversized.
- components/thinking-block.tsx: the quiz quote-block recipe verbatim
(rail + uppercase label + lh-prose at foreground/90) carried by a
faint user-side shell — ml-auto, max-w-[95%] mirroring the user
bubble's geometry, px-4/py-3 rhythm. Never collapsible. Rails reuse
the existing vocabulary: primary = committed register, muted =
working register.
- ThinkingCard becomes the practice adapter: 'Your solution' (primary)
/ 'Your thinking' (muted) — one component, two registers.
- The reading channel LIFTS out of the answered card: chronologically
prior to the question, it renders above it as its own user-side
block under the plain 'Your thinking' label; the in-card inset and
the 'while reading' phrasing are gone.
- lh-card-enter (globals.css): the one-shot 150ms fade+rise mount,
the app's motion register; honors reduced-motion.
Owner round (2026-10-05): the problem pose only really needs statement,
mode, target, modelSolution; conclude needs no reveal field.
- problemInputObject drops decisionPoints, expectedWrongMoves, strategy,
rationale. Halves the pose payload (the GLM incident's failing args
were 3.3KB — a direct weak-model hardening).
- concludeInputObject drops solutionReveal: the reveal is the
modelSolution committed at pose time, shown verbatim at conclude.
- Doctrine pass (00-base mechanics, 10-doctrine V.4/V.5): the commitment
is the solution alone; the reveal instruction points at it.
Fixtures slimmed to the new shapes (flow, agents, heal). ConcludeCard's
render of legacy solutionReveal input stays until the card redesign
lands (Slice C).
The bkt-practice record showed ONE turn in which the model concluded
(clean ack) and then every pose died on 'a problem is already active'
until it gave up ('the problem slot is stuck — the conclude calls report
success'). Two flaws, both fixed:
1. GATES READ A FROZEN RECORD. problem/conclude gates re-read the stored
record per execute — but the record freezes at the turn's write-ahead
(persistTurn lands only at turn END), so a mid-turn conclude → pose
pair could never see each other. practiceTools now builds ONE
turn-scoped gate (createPracticeGate) seeded once from the record;
the executes flip it. Fresh closure per turn (the agent is built per
request) — no cross-turn leakage.
2. PHANTOM ACTIVATION. activeProblemOf ignored tool RESULTS, so the
composer mounted from a pose the gate itself had refused (owner
ruling: 'either it goes through or it doesn't'). Honest activation:
a problem activates only on its clean ack; a conclude frees only on
its clean ack; errored and dangling calls are skipped. Retroactively
heals the BKT record (all its poses errored, all concludes clean →
no active problem). pendingProblemDismissalOf follows the same law
via the shared cleanToolCallIds helper.
Pinned: the mid-turn conclude→pose pair works in one toolset; the gate
re-arms after a successful pose; lone errored/dangling poses activate
nothing; a clean pose under a rejection stays active; an errored
conclude frees nothing.
The thinking-submission incident (owner report, 2026-10-05): two spinners
sandwiching the submitted thinking card, and the card doubling once tokens
flowed. Records on disk were clean — both bugs were live-view-only.
- view.ts LIVE IS POSITIVE EVIDENCE (owner ruling): a tool call renders
live (input-streaming) ONLY when its own message is streaming THIS turn
(streamingIds) — session busyness can never resurrect a settled call.
The pose turn's problem call kept flipping to a mid-transcript spinner
because the fallback keyed on live.inFlight. applyResultTo's placeholder
fork now also MARKS the settled ack (input-available) instead of leaving
the guessed state standing. Live cards now sit at the bottom by
construction, never mid-transcript.
- session-core.ts absorbed(): the custom role gets its identity key
(customType + details deep-equal) — the optimistic thinking marker is
dropped when the write-ahead's server copy lands, so the +400ms
adoption window can no longer double the card. Total over roles; types
that never appear optimistically never reach the branch.
Pinned: settled problem never live mid-turn; this turn's streaming call
stays shimmer; dangling-settled is input-available; absorbed by
customType+details (different details = different submission); model-level
adoption keeps exactly one marker.
The GLM incident (2026-10-04): glm-5.3 sent mode as a JSON-string-encoded
enum ('"recall"') on 19 consecutive problem calls; TypeBox's error never
named the expected values, so the model re-emitted the identical payload
until the dev server was killed. And mid-turn the learner was trapped:
Esc's question/problem rungs only dismissed — the dismiss route 409'd
against the turn-owned record and nothing could stop the running turn.
- healEncodedArguments (practice.ts, the prepareArguments seam): a STRING
where the schema expects a literal union / boolean / null / number /
array is JSON.parse-d and kept when it satisfies the schema. Valid args
pass through untouched (same reference); unhealable values stay for
validation to reject honestly. Wired on BOTH problem and conclude.
- dismissal-park.ts: the dismiss route parks a mid-turn dismissal (202);
the turn's settle path (onFinish, after persistTurn, before endTurn —
the same ordering law as the record write itself) applies it against
the persisted record, only if the dismissed thing really is pending.
- confirmAbort: question/problem discard while busy now ALSO aborts the
in-flight turn; the dialog line says so (busy prop).
No error-result cap (owner ruling). 16 new pins (heal incl. pi-ai
validateToolArguments end-to-end; park registry, apply paths, the 202
route contract).
Owner report, 2026-10-04: starting a session with map picks showed ONE
empty user bubble, no agent turn; a follow-up woke the agent with the
right material but the marker never rendered. Two breaks, one root —
the record no longer starts empty and user-side data parts had no
render lane:
- The first-send gate checked initialMessages.length === 0; the
graph-context marker at message[0] made that false, so the ?prompt
turn NEVER fired. New pure helper initialPromptOwed (the escAbortTarget
pattern): the prompt is owed while no user/assistant message exists —
scaffolding is not conversation. Pinned in tests/graph-context.test.ts.
- The transcript's user branch rendered text parts only; a data-only
message (graph-context, practice-thinking) fell through as an EMPTY
bubble — the data-practice-thinking case I anchored to lived in the
ASSISTANT switch, dead on arrival. Data-only user messages now hoist
to a bubble-free card lane (the subagent-steer precedent); the two
dead assistant-switch cases are gone.
Gates: 1296 pass / 0 fail; typecheck, lint, build clean.
The practice agent wrote wrong LaTeX delimiters — 40-formatting.md (the
\(...\)/\[...\) delimiter law, markdown, mermaid) lived in shared/,
which practice deliberately excluded. Owner ruling: formatting applies
in every setting. Practice now composes its own directory plus a
CURATED shared allowlist (40-formatting.md only — the question/
confidence/file mechanics stay out, practice has no such tools); the
composition test pins the appendix and the order.
The map picks no longer glue into the opening prompt as seed text
(owner, 2026-10-04 late). POST /api/chats accepts graphContext
{propositions}; the route resolves the picks against the live graph,
derives the reasoning chains among them (chainsForSelection →
formatChainLine), and writes a graph-context custom marker as
message[0] — before the learner's first user message, in either mode.
- Core: newGraphContextMessage (model-facing content = the
contextualization framing + proposition lines + chain lines;
details = the structured pick); toLlmMessages flattens it to a user
message like every marker, so the model reads it for free.
- View: projects to a user-side data-graph-context part.
- UI: GraphContextCard — minimal, muted, dashed, collapsed by default
(counts only); expand reveals name — statement rows and the chain
lines. Same compact-expandable pattern as the practice cards.
- Home: sends graphContext with the creation whenever picks exist;
the prompt param carries the learner's own words only.
Root cause of 'doesn't register': the slice-6 floating-bar edit never
landed (a silent no-match replace) — the Body accepted selectMode but
the bar ignored it, so the picker showed Clear + Delete and nothing
ever confirmed. Found by re-reading; the test suite never pinned the
select-mode bar.
The owner vision instead: the chain table's selection is PERSISTENT
and caller-owned.
- ChainTableOverlay gains controlledSelection/onSelectionChange (every
check, clear, and delete-prune reports up immediately; uncontrolled
fallback keeps the delete flow self-contained).
- Select mode's floating bar: Done (closes; the pick already
registered) — the delete fence never renders while picking.
- GraphView threads the lifted set through, resolving ids → nodes
against the live snapshot.
- Home: the pick bar above the composer shows 'N propositions
selected' + Clear in EITHER mode (dashed 'Pick propositions from the
map' hint only in practice mode with nothing picked); visible hover
(bg-accent) so the chip reads clickable. Practice sends still seed
from the pick set.
Drives handleChatRequest with the real practice agent + faux engine:
pose persists its ack and owes no continuation; a commit arrives as the
'Committed answer:' user message and the conclude turn continues to the
reveal; a nudge carries the 'Thinking trace' label and never concludes;
Esc's silent marker deactivates at once and the next input rides the
synthesized dismissed-conclude pair (shared CONCLUDE_ACK_TEXT).
- needsContinuation: a persisted problem-pose ack (terminate:true ended
the turn deliberately) owes no continuation — reloads after a pose no
longer wake a phantom turn; error results stay owed like any
interrupted tool.
- Chat header: practice sessions swap the phase chip for a quiet
Practice tag (no probe→teach arc applies) and hide the coverage ring
(practice never extracts — an ever-filling ring would lie).
- ChatSummary gains pendingProblem (activeProblemOf at projection); the
sidebar meta says 'Problem waiting' for a practice row with a posed
problem. Kind threads through Chat/[id] page.
- Home picker chip gains Clear (picks were otherwise irremovable).
- Sidebar: the three-way kind filter (Teaching/All/Practice, persisted
in localStorage) above the search; two new-session doors (Teaching →
/chat/new, Practice → /chat/new?mode=practice); practice rows carry
the dumbbell badge and 'Practicing…' busy meta.
- Draft: ?mode=practice swaps the copy (title, empty state, 'What do
you want to practice?') and startDraftSession posts kind:'practice'.
- Graph entry (home composer): teach|practice switch on the stats row's
left edge (StatsBar leadingSlot); practice mode shows the propositions
chip that opens the chain table as a picker (its floating bar becomes
'Practice these' — no delete fence); the picks seed the opening prompt
('Propositions from the map:…') while the title stays the typed text.
- PracticeDock (components/practice-dock.tsx): the active problem
replaces the composer — statement (markdown+LaTeX) above, the 'Your
thinking' composer-twin below with the two submit lanes (Nudge =
thinking without a commit; Commit = answer commit; ⌘/Ctrl+Enter
commits). Dictation claims the thinking field.
- Transcript cards (practice-cards.tsx + the artifact registry): the
problem card (compact-expandable statement — the commitment NEVER
renders), the conclude card (outcome + verdict + reveal; the
dismissal synthesis is one muted line), and the user-side thinking
register cards (Your thinking / Your solution).
- view.ts: the problem pose joins the placeholder fork (the ack never
flips the part); practice-thinking markers project as data parts;
problem-dismissed markers project nothing.
- chat.tsx: activeProblemOf drives the dock branch (question > problem
> composer), the esc layering gains the problem rung (below question,
above turn) with its discard confirm + the silent marker route; the
thinking submissions POST through the session's optimistic appends.
- tools/practice.ts: problem (terminate pose; commitment hidden in args)
and conclude (non-terminating; outcome/verdict/reveal/rung/missClass/
retest), gated by activeProblemOf — pose-while-active and conclude-
with-nothing-active both return invalidInputResult.
- prompts/practice/00-base.md (voice + accuracy + tool mechanics) and
10-doctrine.md (the owner's doctrine verbatim; one approved edit —
§I material arrival generalized). Practice composes its own directory
ALONE: no teaching base, no shared fragments.
- createPracticeAgent (teacher-agent.ts); chat-handler branch — kind
'practice' builds the practitioner from turn 1 and skips extraction.
- problemResult/concludeResult acks in tool-execute (conclude text
single-sourced: CONCLUDE_ACK_TEXT).
The native shapes beneath the practice tools (owner design 2026-10-04):
- activeProblemOf — the conclude-before-next-problem gate's fold (a
conclude call OR a dismissal marker deactivates; the pose-time ack
never does — nudges keep the card up).
- The thinking input: {problemCallId, commit, text} → practice-thinking
marker; the label (THINKING_HINT/COMMIT_LABEL, single-sourced in
contracts) is the only register difference the model sees.
- Ask-style problem dismissal: silent route appends the UI-only marker;
the user's NEXT input prepends the synthesized conclude pair (call
outcome:'dismissed' + shared ack) so the agent reads the discard in
its own vocabulary and the slot frees.
- toLlmMessages: dismissal marker filtered; pair passes natively.
Practice sessions (owner, 2026-10-04): the marker rides the record and
the normalized summary (absent field reads as teach — pre-practice
records never migrate); POST /api/chats accepts {kind: 'practice'}.
The agent branch, tools, and UI land in the following slices.
Owner ruling 2026-10-04: the module is a sunk cost, retrievable from
history if ever revisited. Removed: core practice contracts + pipeline
helpers, the PracticeStore port + adapter + data dir path, server
service + API routes (sets, clusters picker, practice aborts), the
practice pages + dialog/exam/list UI, chain-table's Generate handoff,
sidebar nav, activity-dock practice rows, and the partitioner/
problemGenerator/grader model roles (BACKGROUND_MODEL_ROLES 8 → 5).
Survivors, moved to core/chains.ts (pure + browser-safe): ReasoningChain,
chainsForSelection, formatChainLine — the chain table and graph
emission still consume them.
pi-ai 1.0's onProviderStreamEvent, at the layer our incident class lives
(GLM arg truncation, DeepSeek id resets) — env-gated (owner choice:
no app surface, off by default; raw events carry full prompts), one
JSONL per chat under data/debug/provider-events/ with a 20MB cap and a
run-start marker for record correlation.
Wired at every agent construction: teacher/prober turns (AgentBuildOptions.
chatId), jobs via LoopContext (researcher, demo-builder through
runSubagent; visual-maker + pdf-reader through their streamOnce), and
the extraction pipeline (extractor + auditor keyed by source id).
practice deliberately unwired (sunk-cost module).
Authoring refinements (quizInputProblems, nodeId checks, ask/explain
refinements, submit-agent pipeline laws) now return invalidInputResult —
same model-facing failure and retry semantics pi minted from the throw,
but details keeps {tool, problems} as data instead of the throw path's
empty {}. ToolInputError deleted; wire shape unchanged (verified against
pi's own thrown-error conversion in agent-loop).
Esc over a mermaid focus, the plan popup, or the setup dialog opened the
question-dismiss confirm underneath — KeyboardShortcuts' window listener
registers first, so the overlays' own self-closing Esc listeners could
never outrun the lh:kbd-abort-turn dispatch. The state gate in ChatView
is the defense (the architecture's own pattern from the abort dialog):
escAbortTarget, exported pure, rules priority by stacking order — abort
confirm (claimed by its dialog) > mermaid (paints above plan) > plan >
setup (self-closing) > question > turn. The gate closes the overlay; the
overlays' own listeners stay (idempotent). Pinned in esc-layering.test.
DeepSeek via OpenRouter returns tool-call ids that reset every response —
a one-call-per-turn session names every call call_0. Everything keys on
toolCallId (the fold's call↔result join, the pending gate, answer
routing, the option-shuffle seed), so the Lacan session corrupted: the
quiz answer overwrote the ask card, the ask question vanished, and the
order scrambled. Record itself was always intact.
- src/server/pi/call-ids.ts (new): ToolCallIdUniquifier at the wire
boundary — colliding ids remint once (orig~2, ~3…), the same mints at
settlement; tables key by message OBJECT IDENTITY (streaming messages
carry no ids; the message_end object is reference-identical to the
state object). History never rewrites; providers see paired ids, which
is protocol-legal (pi synthesizes ids for providers that send none).
- ui-stream: runPiTurn remaps events + settles through the uniquifier
(spec.usedToolCallIds, seeded by chat-handler from the record).
- view.ts: findToolPart scans newest-first — pre-fix duplicate-id
records (the existing Lacan session) heal on next render.
- tests: 9 unit pins (call-ids.test.ts) + the two-call_0 e2e (chat-flow):
ask answered as ask, quiz as quiz, ids unique in the record.
The teaching cycle gains its motivation question. The teach quiz schema
now carries a REQUIRED type field — predict (posed before the teaching,
arrives open, the learner predicts the next step) or check (posed after,
arrives collapsed behind the reveal). The prober's quiz stays role-less.
- core: quizInputObject.type union + normalizeQuizType + the reveal law
as pure quizNeedsReveal; QuestionData.questionType; note → thinking
rename (legacy reads fold back); readingThinking channel on answers;
compactAnswerForModel emits thinking / thinkingWhileReading
- tools: descriptions stripped to mechanics — QUIZ_OPTION_CRAFT and
EXPLAIN_DOCTRINE deleted, doctrine moved to the prompt layer
(reverses the 2026-09-27 placement, owner 2026-10-03); shared
quizDescription/explainDescription builders differ only in deltas
- prompts: shared/20-question-tools.md rewritten (option craft,
transcribed-speech leniency, the two thinking channels); new
teacher/28-predictions.md (cycle, roles, skip rule, resolution law);
20-teaching + 30-question-principles scoped to the new taxonomy
- UI: predictions render open with a Prediction eyebrow, verdict shows
but the explanation tucks behind a toggle; the reading-thinking field
mounts while a check waits (dictation claims it until reveal);
answered card separates thinking vs thinking-while-reading blocks
Root cause: doSteerOrSend branched on turnInFlight, but the local dedup
(chat.tsx ~370) clears that flag the moment the own stream takes over —
during our own turn busy was true and turnInFlight false, so Enter fell to
the plain-send path whose busy guard returned silently. Branch on busy:
own stream AND foreign/wake turns both have a live agent registered by the
time the send's POST resolved, so the steer lands (a raced settle 409s and
falls back to the plain send).
The button now follows the text: busy + typed text = Send (click steers —
steering must be clickable, not Enter-only); busy + empty = Stop. ESC keeps
its confirm-popup abort in every state, so stop is never unreachable.
STEERING, NOT FOLLOW-UPS — pi-agent-core's two rails, used deliberately:
agent.steer() injects at the next response boundary INSIDE the live run
(one logical turn, the model changes course); followUp() would only extend
an ended run — not built. The same rail job markers already ride
(steeringMode 'all', the 2026-09-26 doctrine).
- POST /api/chats/[id]/steer: 404/400 honest guards; no live turn → 409
and the client silently falls back to a plain text send (the settle
race); live → steerLiveChat hands the message to the live agent's
steering queue AND parks it in the userSteers registry — the stranding
safety net. A steer stranded behind a question termination or abort
stays parked and rides the chat's NEXT run (handOffEligible), the same
deliver-or-requeue lifecycle as job markers. Reconciliation at settle:
delivered ids drop (reconcileUserSteers).
- Client: steer(text) queues + POSTs without flipping any turn state;
the drain's verbatim message events upsert the real user card by the
client-minted id (steer_<localid>) and the indicator fold drops the
line. canSend no longer gates on turnInFlight — Enter mid-turn steers;
Enter with an empty composer still stops; the square button stays Stop.
- The queued-steer indicator (owner spec): quiet one-liners above the
composer shell — right-aligned to where its corner rounding starts,
truncated with an ellipsis, fixed max width, ~half opacity, no
background, no container. Tunable later.
- Verified from pi-agent-core source: steering drains per completed turn
inside the loop; injected messages emit message_start/end verbatim;
finishTurn {action:'end'} (our question stopper) exits BEFORE the
steering poll — hence the registry.
Live test finding: answer A then dismiss B woke the agent — the continuation
fold treated a mixed [answered, dismissed] trailing batch as 'reaction owed'
(all-dismissed rule only). Dismiss A then answer B and dismiss-both were
already correct.
The law now: the trailing batch's LAST resolution decides. Ending on a
dismissal = the user signaled stop — no wake, the answers' reactions ride the
next plain message. Ending on a real answer = the reaction is owed. One law
with the pump's pause (endsInDismissal reads the same last result), so
continuation, delivery-holding, and the pause can never disagree. The wrong
pin ('a real answer in the batch still demands the reaction') flipped.
The incident: deepseek batched quiz + ask_user_question in one assistant
message; the quiz executed first (sequential) and terminated the turn; the
answer to the quiz IMMEDIATELY woke a reaction turn (fork-1 design assumed
one dangling question max), the model complained about the unanswered ask,
and the ask became permanently unanswerable — its toolResult could only
append AFTER the intervening assistant message, a shape DeepSeek rejects.
The gate: for answer inputs, if a question call still dangles after the
append, persist write-ahead + emit record-changed + return the settled
terminal — no turn. The dock advances to the sibling; the LAST answer fires
the one reaction turn with every result adjacent to its calling message.
Nothing else generates while a question dangles (job wakes wait, continuation
folds refuse, the composer gates), so adjacency holds by construction.
Also repaired the stranded session data (ask call stripped from the batch;
backup at /tmp/chat_murkbeq2_backup.json).
- {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.
- full-bleed: blur + translucent surface IS the overlay, no inner card;
own top bar (info + search + close, lh-no-drag so home's drag mask
never eats the X — the clickability bug)
- three levels, one click each: cluster (birth session; a chain lives in
its CONCLUSION's cluster) → chain → proposition STATEMENTS revealed
directly (names never twice; singletons one click); all collapsed by
default
- proposition names as outlined chips, → connectors between; lonely chip
= standalone proposition — the '(no premises)' annotation is dead in
the visible UI (survives only as aria-label); Folder/Waypoints icons;
rotating chevrons; deep statement indent
- real table look: hairline row + column lines; sortable Name + Created
headers (click toggles asc/desc; size is text, not a control)
- selection never shifts layout: floating bottom bar (Google-Photos
pattern) with N propositions → M chains + Clear + Generate
- fuzzy search grouped + auto-expanding clusters
live-verified end-to-end on the owner's dev server; 15 rewritten render
pins; gates: tsc 0, 1179/1179, lint 0, build 0
- core formatChainLine: names + arrow one-liner, singleton marker, pure
- graph-view Chains button (chrome row, empty-graph hidden) opens the
overlay: fuzzy search (names/aliases/statements), collapsible chain →
member → statement rows, birth-session + created columns, no
understanding-levels column
- pinned Selected section derives the agents' actual input live
(chainsForSelection over the selection; premise without its
conclusion shows as the singleton it becomes)
- Generate opens the creation dialog in CUSTOM mode: selection summary
+ dated default title, posts {nodeIds, params, note}; cluster mode
untouched on /practice
- live-verified on the owner's dev server: 219 chains, derived preview,
search-never-filters-Selected, dialog handoff (selection pass-through
bug caught + fixed en route), Esc/backdrop dismiss, zero writes
- practice UI files re-normalized to tabs (my earlier prettier runs had
drifted them to the 2-space default; whitespace-only)
17 render/unit pins added; gates: tsc 0, 1174/1174, lint 0, build 0
- dialog: optional 'Anything else?' textarea (typing-only, 2000-char mirror)
- API: body gains note? — trimmed, whitespace-only omitted, hard 400 over 2000
- pipeline: note persisted on the set and threaded to both agent stages
- agents: THE LEARNER'S NOTE prompt block (present only when set) + the
weighed-never-owed stance pinned in both doctrines
- fix en route: doubled THE PARAMETERS heading in both prompts since slice 3
(renderParams owns the heading; the literal blocks removed)
Rulings: each problem generation is its own visible dock job (the
extraction per-chunk pattern), and practice shares extraction's
semaphore — one process-wide 3-permit pool for ALL background LLM
jobs; total background LLM concurrency bounded at 3.
A. SHARED POOL: new src/agent/subagents/bg-llm-semaphore.ts —
globalThis __lhLlmSlots, PERMIT_COUNT 3 (the ruling), the
fresh-take/handed-off-permit deadlock discipline preserved for
BOTH callers. extraction.ts's withExtractSlot is now a thin
wrapper (job-row signal → withBgLlmSlot); __lhExtractSlots and
__lhPracticeSlots both die. The partitioner now acquires a permit
too (it's a background LLM job — it didn't before).
B. REGISTRY ROWS: the aggregate setId:generation / setId:grading rows
are replaced by setId:partition (phase partitioning, label
'Partitioning — title'), setId:gen:problemId (queued→generating,
'Problem i/N — title'), setId:grade:problemId (queued→grading,
'Grading i/N — title'). Each row carries its OWN abort controller
and its OWN usage (one PracticeStepLog per row — the aggregate
partitioner/generator/grader folding dies). Row key = feed id =
abort key (the dock dup-key law).
C. ABORT SEMANTICS: abortPracticeRow(key) routes by shape — gen row:
THAT problem only (error 'Aborted', siblings continue, set settles
ready with one errored problem); partition row: the whole run;
grade row: that grade drops (answer kept, re-submittable).
abortPractice(setId) composes from row aborts + the synchronous
persisted marks (a set-abort's verdict stands before the settles
re-read it — the settle step keeps an existing error status).
/api/activity/abort kind practice now takes the ROW key (charset
admits ':').
D. FEED + DOCK: ActivityPractice/FeedPractice carry per-row identity
(id = row key, problemIndex/problemTotal); dock blobs render per
row, labels carry the i/N; the aggregate row is gone.
E. Boot sweep unchanged at set grain; the live-row check is now a
key-prefix scan (any live row of the set protects it). The
updatedAt heartbeat covers the new row scheme as-is.
Tests (20 in the pipeline suite): per-problem row shape/uniqueness/
i-N labels (partition row frozen behind a gated partitioner; per-row
usage ISOLATION pin — b's log never shows on c's row); abort-one-gen
(sibling completes, set ready with one errored problem); partition-
row abort; queued grade-row abort (dead-ticket path: answer kept,
re-submittable, sibling lands); shared-pool identity + contention
(practice queues behind extraction-style holders); registry/dock
feed mapping. Test isolation hardened: unique per-store set ids,
afterEach registry/lock/pool reset (a timed-out test can no longer
poison the rest).
architecture.md: jobs-pattern paragraph rewritten (per-problem rows,
shared pool, abort routing).
1149/1149, tsc 0, lint 0, build 0.
Prior state: zero formatting guidance anywhere in the agent module —
models wrote prose-only by default.
GENERATOR_DOCTRINE Part III gains the FORMATTING bullet (after the
THE PARAMETERS bullet, before PREVIOUS PROBLEMS): statement, solution,
and rubric render as full Markdown — math always LaTeX (inline $…$,
display $$…$$), code/pseudocode/formal systems in fenced blocks with
a language tag, identifiers inline as `code`, tables for parallel
structure, lists for enumerations; prose-only renderings of
mathematical or structured content are a defect.
GRADER_INSTRUCTIONS gains the feedback-formatting paragraph (before
the OUTPUT section): full Markdown, LaTeX math, fenced code, lists/
tables where they carry structure — teach the boundary in properly
formatted prose, not plain text.
PARTITIONER gets nothing (machine-shaped output).
Tests: substring pins on the exported doctrine constants — the exact
FORMATTING sentence + ordering (after presentation, before previous
problems), the exact grader paragraph + before-OUTPUT ordering.
Architecture.md §7 one clause (the formatting doctrine in the
generator's prompt inventory).
1144/1144, tsc 0, lint 0, build 0.
Owner report: created a set, 'Interrupted (server restarted)' showed
on list + set views, generation proceeded and overwrote it. The
sweep's 'generating + no registry row' test had a false-positive
mode: it ran at EVERY pipeline instantiation, and an instance could
evaluate against a registry whose fresh row it couldn't observe.
Three guards, per ruling:
1. HEARTBEAT: ProblemSet.updatedAt? (contract + doc) — bumped on
every store write; createSet stamps it = createdAt; saveSet
re-stamps (validator tolerates absence for legacy rows).
2. ONCE PER PROCESS: the sweep guards on globalThis
.__lhPracticeSweepDone — no instance re-evaluates after the
process's first sweep.
3. GRACE WINDOW: only sets whose heartbeat (updatedAt ?? createdAt)
is older than 120s are stamped. A live run saves at every phase/
problem transition, so it is structurally unreachable; a true
orphan is minutes stale at next boot. One [practice <id>] log
line per stamped set (the extraction/jobs console.error idiom).
Tests: stale orphan stamped; FRESH generating set untouched (grace);
once-guard (second factory instantiation never re-sweeps); live-row
protection re-armed via resetSweep() test seam; store pins for
updatedAt birth-stamp + re-stamp + legacy tolerance. Architecture.md
sweep sentence updated (once-per-process + grace semantics).
1142/1142, tsc 0, lint 0, build 0.
Contracts: LadderRung 0-4, CueDistance 0-2, Presentation theory|
application; PracticeParams carries the three named levels. Doc
comments hold the three definition tables verbatim (rung names +
definitions from the ladder doctrine; cue levels; presentation
levels).
Core: LADDER_RUNGS/CUE_LEVELS/PRESENTATIONS tables in src/core/
practice.ts (definitions verbatim) — ONE source spoken by the agents'
prompts, the dialog's stepped controls, and the exam header; core
barrel re-exports practice.ts (pure domain, browser-safe).
Agents: Part I's ladder intro now 'five rungs, numbered; the request
arrives as a rung' with bullets numbered 0-4 (definitions untouched);
Part III's parameters block replaced with the named-level doctrine
(rung = exact class, no continuum; cue counts lesson examples AND
previous problems' situations; presentation dresses, never changes);
partitioner's RESPECT THE PARAMETERS replaced (low rungs = many small
assignments, high = larger; far cue makes confusables-together MORE
important; presentation does not shape the partition). renderParams
outputs the exact named-level block. Anchor/0..1 language swept dead.
Service + store: strict union validation — floats between levels,
out-of-range, wrong strings, legacy dial shapes all 400 at the API
and fail isProblemSet (legacy sets skipped from lists; no migration —
data/practice is disposable scratch, owner wipes before testing).
UI: dialog = 5-detent ladder slider + 3-detent cue slider (each shows
the level NAME + one-line definition) + Theory|Application segmented
toggle; defaults rung 2 / cue 1 / theory; Create posts the new shape.
Exam header gains the levels line ('Direct exercise · Adjacent
context · Theory').
Tests: renderParams exact-block pin; strict-validation pins (new
tests/practice-service.test.ts — bad unions 400, legacy shape 400);
store pins (legacy floats + 2.5 rung fail); render pins (detents +
names + definitions, defaults, detent-swap, toggle active segment,
levels line); every params fixture moved off floats. 1138/1138, tsc
0, lint 0, build 0.
Docs: architecture.md — discretization paragraph (compact tables,
named-level invocation, defaults, one-table source), UI mentions;
README axes sentence.
Owner escalation on deviation 1: practice availability must not
inherit a visual-rendering threshold. graphClusters() now runs
birthSessionPartition(snap.nodes) directly (same size-desc/id-asc
ordering) instead of reading the snapshot's MIN_CLUSTER_SIZE-filtered
clusters array — MIN_CLUSTER_SIZE stays a hull-drawing rule only.
Label join mirrors the snapshot's (chat title / notes path, null when
the naming record is gone). Contract doc + architecture.md sentence
corrected to match; empty-picker copy reworded ('once a session births
its first proposition' — no 3+ mechanics). Render pins: new empty line
+ a 1- and 2-proposition group listing pin. 1130/1130, tsc 0, lint 0,
build 0.