Commit Graph
11 Commits
Author SHA1 Message Date
amosblomqvist f5edc7f0a5 Semantic topic clusters: local embeddings replace Louvain
Louvain only ever found the reasoning chains (the graph is disconnected
by nature — every connected component became its own cluster) and
edge-orphans could never join anything. Clusters are now RELATED TOPICS:
propositions grouped by meaning.

- Embedder (src/server/embeddings.ts): in-process transformers.js v4
  running EmbeddingGemma-300M q8 (MTEB 68.4, ~300MB one-time download),
  MRL-truncated to 512d, "title: none | text:" prefix on 100% of inputs,
  L2-normalized so cosine = dot. Model/dim mismatch voids the ledger
  (full re-embed). Injected at the composition root — the agent layer
  never imports the server layer (architecture law).
- Ledger (data/graph/embeddings.json): model + dim + vectors + the
  persisted cluster partition. Reclustering runs at the END of every
  embedding batch (post-merge hook, both front doors) — never per read
  (the agglomerative pass is ~1.5s at 338 nodes).
- Clustering (src/core/semantic-clusters.ts): hand-rolled average-linkage
  agglomerative over cosine (npm HDBSCAN is an abandoned alpha). Stragglers
  stay singletons by default; attachStragglers is the all-in toggle.
  Threshold 0.60 swept on the real graph: 26 topics, 74/90 edge-orphans
  land in real topics, 10 stragglers.
- Consumers (snapshot, settle, namer, replay) read the persisted
  partition — one cluster concept everywhere; core-survival label
  matching unchanged (20/26 topics kept labels).
- Louvain retired: detectCommunities + graphology* deps deleted
  (graphology-louvain had leaked into the client bundle since the
  clusters wave); scripts/bench-graph.ts (dead FA2 bench) removed.
- scripts/embed-graph.ts: backfill/cold-start (338 nodes in 150s
  including the one-time model download).
2026-09-13 14:29:16 +03:00
amosblomqvist 975a78a304 Cluster gravity: per-cluster centroid pull force in the engine
The 'virtual node' realized as a centroid force: two allocation-free
O(n) passes per tick (membership resolved once at sim creation;
Float64Array sums). Measured indistinguishable from force-off at 338
nodes (271ms vs 272ms per 400-tick settle).

- createGraphSimulation: SimOptions.clusterPull + linkStrength +
  velocityDecay (physics controls plumbing); degree-scaled link
  stiffness so linkStrength=1 is exactly d3's default
- settleGraphLayout split into layout-settle.ts (it computes the
  Louvain partition for the pull); layout.ts stays browser-barrel-pure
- barrel no longer re-exports clusters.ts: graphology + louvain are
  fully out of the client bundle (verified against built chunks)
- replay passes the per-batch partition; CLUSTER_PULL_STRENGTH=0.15
  tuned by sweep: cluster spread 63->36 world units, centroid
  separation and total span unchanged (no blob merge, no collapse)
2026-09-13 09:52:12 +03:00
amosblomqvist 7eceb58303 Engine unification: d3-force replaces forceAtlas2 everywhere
One engine owns the equilibrium: the merge settle and (next commit)
the live drag both run createGraphSimulation — d3-force link springs,
1/d charge, weak centroid gravity — so a drag's rest state IS the
server's rest state. The FA2-era anchor mechanism survives as a
post-tick POSITION-SPACE re-blend (retention 0.85): d3's forceLink is
a Hooke spring (strength*(d-L)*alpha), which overpowers any force-space
anchor at long range — the re-blend is alpha-independent and always
wins as the sim anneals (the 675-unit drift bug). Gravity retuned for
d3's 1/d many-body falloff (bootstrap 0.2 / incremental 0.12: cluster
spacing lands ~400 world units, the FA2-era density). Rim orphans seed
(and anchor) at 1.7x the CONNECTED core's mean radius — a max-span-based
rim fed back on itself and ran the layout away to 6000 units. Replay:
338 positions in 1.6s, old-node shift within a batch 4.8 (FA2: 23).
2026-09-13 09:17:01 +03:00
amosblomqvist ecadededd9 World-space rendering: geometry cached once, zoom pays only rasterization
Fixes the zoom frame-rate collapse and rebuilds draw() around a single
camera transform, per the user's Obsidian-style sizing proposal.

Bug fix first: the eased zoom leaked a queued rAF callback per frame --
advanceZoom applies each step through d3-zoom, whose zoom event called
markDirty() from inside the tick loop, scheduling an extra draw while the
loop scheduled its own. Draws multiplied per displayed frame; sustained
wheel ran at ~20fps against a 120Hz display. markDirty now skips
scheduling while inTick (verified: 62 frames/500ms during a 28x zoom
burst, matching pan).

World-space model: every draw call now runs in graph coordinates under
one setTransform(k). Sizes are defined as screen-px-at-fit and converted
once per geometry build (/ kFit), so nodes, node labels, arrowheads and
the hover halo scale with the graph as you zoom (0.25x painted area at
half zoom, exactly k^2) while edge widths and hull borders stay
viewer-relative hairlines (/ k). Cluster hull geometry (convex hull +
three offset fills + solid boundary -- dashes dropped for now) is built
as cached Path2Ds per data/visibility/settings change; camera moves do
zero geometry math, making zoom per-frame cost identical to pan. Label
wrapping runs once per build, not per frame. Region labels stay
viewer-relative (18px at any zoom) as UI chrome. Picking radius scales
with the world-sized discs.

Glide feel: ZOOM_EASE_MS 120 -> 45, convergence 0.1% -> 0.2%; a notch now
settles in ~260ms instead of ~750ms. Resize still paints synchronously.

Also fixes barrel-law lint violations from the settings wave (import
from @/src/core and @/src/server, not deep module paths).
2026-09-13 07:18:37 +03:00
amosblomqvist 95a4477283 Clusters phase A: community detection core (Louvain, seeded, structural)
detectCommunities(nodes, edges) in src/core/clusters.ts (new dep
graphology-communities-louvain): Louvain modularity over the undirected,
unweighted follows+supports structure (user-decided 2026-09-13). The
random-walk traversal runs on an inlined seeded mulberry32 so the same
graph always yields the same partition; community indices are still
unstable across edits, which is exactly why labelings (phase B) key to
sorted MEMBER SETS. follows+supports between the same pair collapse to
one edge; self-loops and dangling edges are skipped defensively; no
min-size filter here (the >=3 render threshold is a presentation rule).

Tests: two-cliques-plus-bridge split, determinism, singleton/empty
graphs, pair collapse, defensive skips, member sorting. 363/363.

Real-data smoke over the current notes graph: 338 nodes / 211 edges ->
138 communities, modularity 0.95, and the big ones are exactly the
learn-session topics (databases, differential forms, sales, servers,
vim, copulas...). 32 communities clear the >=3 render threshold.
2026-09-13 03:46:24 +03:00
amosblomqvist de0c286311 feat: model + thinking control — the stats bar's picker, on pi-ai's catalog
The model-control wave (user-decided design, 2026-09-12): ONE controller
for the conversation agents (prober/teacher are functionally one agent);
background sub-agents stay config-pinned. pi-ai owns the registry — the
shipped 366-model OpenRouter catalog (cost, context, modality,
thinkingLevelMap), getSupportedThinkingLevels/clampThinkingLevel for the
per-model thinking truth, and streamSimple's automatic clamp. No second
registry to drift out of sync.

Seams:
- config.ts: ReasoningEffort retired for pi's ThinkingLevel
  (off|minimal|low|medium|high|xhigh|max). GLM roles go honest at "low" —
  pi's catalog says GLM-5.3-flash accepts exactly low|high|max, and the old
  "minimal" was silently clamped to low by streamSimple since the swap;
  nothing changes behaviorally. visualMaker → qwen/qwen3.8-flash (fixes
  the dead qwen/qwen3.8-max slug — the catalog only ships -0902; also the
  user's cost pick, 0.15/0.47 per-1M).
- src/agent/models.ts: piEngineFor(role, settings?) — provider+id resolved
  from the catalog, thinking level from settings ?? config, CLAMPED to the
  model's supported list (a settings change between models can never send
  an invalid level). PiEngine gains `level` (observability/tests).
- ChatRecord.settings {model?: {provider, id}, thinkingLevel?} — written
  by PUT /api/chats/[id]/settings (replace-whole, no updatedAt churn),
  read by chat-handler at turn composition: agents are stateless per turn,
  so the change is next-turn-effective by construction. The turns log
  still records the ACTUAL billed model (the honest witness).
- GET /api/models: the configured-provider catalog as client-safe DTOs
  (core/contracts/models.ts) + config defaults; a second provider joins
  the picker by registering it in piModels() — nothing else changes.
- PUT settings validation gate (src/server/model-catalog.ts): unknown
  provider/model or unsupported level → 400 carrying the supported list,
  never a silent clamp.
- scripts/sync-pricing.ts: regenerated PRICES/CONTEXT_LIMITS from pi-ai's
  shipped openrouter.json (offline; same source as the engine + picker).
  All 366 models priced; glm's stale 0.15/0.5 → pi's 0.075/0.25, limit
  1310720 → 1048576 — cost is inferred at read time, so history re-prices.
- UI (components/model-picker.tsx): stats-bar right side becomes model +
  thinking buttons; menus open ABOVE; fuzzy search over "provider id
  name" (core/fuzzy.ts — pi's /model pattern); model rows show name/id/
  price/context; the thinking menu lists exactly the chosen model's
  supported levels (GLM: low|high|max — no dead options, no "off").
  Model switch drops an incompatible pinned level (falls back to config,
  which clamps honestly) rather than carrying a 400.

Tests (350/350, +15): catalog projection + defaults, the settings gate,
pi thinking truth pinned against upgrades (glm low|high|max; minimal→low;
off→low — GLM cannot disable thinking), the engine factory's
override/fallback/clamp/throw matrix, fuzzy ranking, store round-trip,
and the re-priced folds (usage, stats bar, activity dock).

Live smoke (disposable driver, deleted): catalog route 366 + defaults;
glm+minimal → 400 + supported; turn 1 billed qwen/qwen3.8-flash under
the override; mid-session switch flipped turn 2 to glm; clear-overrides
restored the config pin. ALL CHECKS PASS on real OpenRouter.
2026-09-12 06:02:40 +03:00
amosblomqvist 4ba594bab5 fix(arch): parse, don't cast — the UI parse seam for model-authored input
The architectural answer to the validation-crash question: validation IS
first at the model-facing boundary (pi validates arguments before
execute; failures return to the model same-turn — all three live
incidents retried successfully). But the same bytes also exist UNVALIDATED
by design in two other places: the live wire streams raw tool-input
deltas DURING generation, and the transcript keeps the raw input of
REJECTED calls (honesty + the replay invariant). The consumers were
supposed to trust part STATES — but that trust was enforced by
TypeScript casts ('part.input as X'), which are compile-time lies with
no runtime check behind them. That is the hole both crashes came
through, and it should not have been possible.

The structural rule now: model-authored INPUT is consumed only through
parsers running the SAME zod schema the model faced — boundary-valid ⇔
consumer-parseable, mechanically. Server/learner-authored OUTPUT stays
trusted (our code authors it).

- src/core/tool-parts.ts (new): parseQuizInput (display parse over the
  shared quizInputBase — strips the teacher-only nodeId, accepts both
  prober and teacher boundary shapes), parseAskInput, parseProbeDoneInput.
- contracts/probe.ts: probeDoneInputSchema is now core-owned zod — the
  probe_done tool declares it (single source of truth), the seam runs
  the literal same schema.
- question-dock.tsx: the dock parses its input at entry; unparsable
  renders the quiet malformed-question fallback, never a crash.
- probe-done-card.tsx: the input preview goes through the parse; the
  old state reasoning and shape guards collapse into it.

Pinned in tests/tool-parts.test.ts: both exact live crash shapes
(stringified quiz options, stringified assessment), boundary-parity
cases for all three schemas, nodeId stripping, mid-stream partials;
render-cards pins the dock fallback markup.

Gates: tsc, lint, build, 328/328.
2026-09-12 04:51:39 +03:00
amosblomqvist 5653c77a29 feat: probe/teach agent split — explicit phase markers, position-only plan map
Diagnosis (2026-09-11 user testing, minimal-effort GLM): sessions stuck on
'plan' forever. Two causes: (1) the three-phase view read the stored tree's
currentNodeId, which nothing ever writes post author-once (2026-09-03) — a
structural dead seam; (2) the teacher stochastically left quiz.nodeId
untagged, and done-by-quiz-result semantics would have falsely completed
nodes anyway.

The redesign (docs/probe-teach-split.md) abolishes derived occupancy in
favor of explicit events:

- Two agents over one transcript: createProberAgent (quiz without a
  nodeId field, ask, probe_done) and createTeacherAgent (full toolset,
  plan_init onward). The chat-handler construction branch keys on a
  probe_done part in the transcript. MODELS.prober added.
- probe_done: server-side seam tool — carries the learner assessment
  (strands + free-form edge read), refuses while a question is open or
  the probe is already concluded, and queues a handoff steer through the
  generalized wake pipeline (steers now dedupe by id, not jobId; job
  finalization only for job steers). The teacher auto-wakes and authors
  the plan; the learner sees the assessment via the probe-done card.
- phases: probe ends at a concluded probe_done, plan ends at a
  successful plan_init — strictly sequential, no skipping, no legacy
  compatibility (data wiped at the pivot per doctrine).
- Plan map = position, not completion: derivePlanStatuses reads only
  tagged quiz CALLS (state/outcome feed nothing); current = latest
  tagged node with consecutive duplicates collapsed, visited = earlier
  distinct stays (a revisit stays in both — current wins at render).
  PlanTree is structure-only; currentNodeId/doneNodeIds and the dead
  mutation helpers are gone. No fallback current.
- Prompts split per agent (prompts/prober/, prompts/teacher/ over the
  trimmed shared base); graph-context injection removed entirely
  (shelved, docs/probe-teach-split.md §5); knowledge-graph context no
  longer reaches either agent.
- Plan blocks are full-only snapshots at plan_init (neighborhood
  machinery retired; the persistent DAG window is the future UI wave);
  usage capture unchanged.

Gates: tsc clean, 294/294 tests (phases/plan-tree/plan-blocks/question-
tools rewritten to the new semantics; probe-done + handoff-wake tests
added), lint, build. Live GLM smoke PASS: prober concluded with an honest
assessment, teacher auto-woke, authored a 10-node plan with the block
attached, then tagged checks on slope-instinct → secant derived current/
visited exactly as designed; grading honest both ways (driver off-by-one
proved a wrong answer grades false).
2026-09-11 06:44:20 +03:00
amosblomqvist 83a00800e9 Observability contracts: usage accounting, plan blocks, price table (Phase 0)
The 2026-09 observability wave's type-level foundation, landed before the
streams so nothing renders against guessed shapes:

- contracts/chat.ts: CallUsage + TurnUsage (per-model-call token accounting,
  one entry per teacher/wake turn keyed by messageId) and ChatRecord.turns —
  a DELIBERATELY parallel append-only usage log, not per-message metadata:
  the client re-POSTs its message list every turn and its copies never
  carry server-stamped fields, so anything inside messages gets clobbered
  by the next persist (the wave-3 merge-rule lesson).
- contracts/chat.ts: PlanBlockData — the persisted data-plan-block part
  (full DAG on plan_init, one-level neighborhood on plan updates, snapshot
  semantics so history renders what was true then).
- contracts/jobs.ts: JobUsage + JobRecord.usage — a sub-agent run's
  accumulated accounting, written by the runner.
- core/ports.ts + store/chats.ts: ChatStore.appendTurnUsage — idempotent
  per messageId (abort replays / wake retries append once); listChats no
  longer leaks turns into the list payload.
- core/prices.ts: PRICES + CONTEXT_LIMITS (generated from OpenRouter's
  published pricing via scripts/sync-pricing.ts) + pure folds: callCost,
  turnCost, sessionCost, contextWindow, jobCost. Unknown model or absent
  fields render as absent, never zero.
- server/observability.ts: barrel-approved surface for the transport layer
  (fixes the import-law violation in the graph status route that slipped
  through the wave-3 merge verification).

Capture wiring (agent onStepFinish -> persist seam, plan-block emission)
lands next as master seam work; the streams start against these shapes.
2026-09-11 02:43:35 +03:00
Amos 1167157c23 Storage ports: ChatStore/GraphStore/JobStore + adapters + core inversion (stage 3)
Per-entity semantic ports (src/core/ports.ts), one adapter family
(src/store/*, fs JSON/JSONL), exactly one composition point
(src/server/stores.ts). Dependency direction is now machine-checked:
adapters implement core interfaces; core never imports an adapter.

The inversion:
- core/graph.ts (emitChat/getGraph/graphSnapshot/foldStaleChats) is now
  pure — it takes the storage ports as explicit parameters (GraphDeps).
  No store imports; newGraphNodeId is local; node id format unchanged.
- core/graph-context.ts buildGraphContext() no longer defaults to the
  live ledgers: the chat-handler hands in getGraph(graphStore) per turn.
- store adapters export exactly one object each implementing their port
  (chatStore/graphStore/jobStore); bodies otherwise verbatim.
  NodeMapping + SessionCheckpoint moved store/graph -> core/contracts
  (they ARE contract shapes); CreateJobInit/JobPatch live in ports.
- titleFromPrompt moved store/chats -> core/contracts (pure helper,
  planNodeStatus precedent). Store modules now contain persistence only.
- server/stores.ts is the composition root; server/{chats,graph,jobs}.ts
  and chat-handler wire from it; agent (plan tools, sub-agent runner)
  call the port objects. The shell surface (@/src/server exports) is
  unchanged.

Lint law extended: the whole of src/core is now a purity zone (was a
file glob with a graph/graph-context exemption). Verified it fires on
core->store imports; tsc clean, 190/190 tests, eslint clean, prod build
clean (prerender 6/6). Tests pass deps explicitly — graph.test uses the
same fs-adapter pair stores.ts wires in production; graph-context.test
keeps its in-memory ledgers as a GraphStore implementation.
2026-08-31 20:00:12 +03:00
Amos e92140f993 Shell/core import law: public API barrels + ESLint enforcement (stage 2)
The shell now imports through exactly two barrels, enforced by
import/no-restricted-paths (npm run lint):

  components/**  ->  "@/src/core"      (pure core API, browser-safe)
  app/**         ->  "@/src/core" + "@/src/server" (server entries)

Zone violations are lint errors with architecture-pointer messages; the
pure-core modules (contracts, question-core, plan-tree, phases, pending,
math-delims, ui-message) may never import agent/server/store.

Supporting moves (direction: IO layers -> core):
- core/index.ts: the core public API barrel
- core/ui-message.ts: moved from src/agent (it was shell-facing all along)
- quiz/ask input zod schemas moved agent/tools -> core/question-core
  (the question contract, alongside the grading logic)
- ChatRecord/ChatSummary moved store/chats -> core/contracts (single schema)
- GraphSnapshot/NodeStateClass moved core/graph -> core/contracts (the
  /api/graph payload IS a contract; the pure renderer needs only these)
- src/server/{chats,graph,jobs}.ts + index.ts: thin server entries for the
  transport layer; GET /api/graph's fold side-effect now lives behind
  graphView() instead of inline in the route

Gates: tsc clean, 190/190 tests, eslint clean (law verified to fire on
store/agent/subpath violations), production build clean.

(Repo repair en route: a git object (app/chat/[id] tree) was missing from
the object store — reconstructed byte-exactly via mktree from its blob;
the broken node_modules that stalled the build was cleanly reinstalled.)
2026-08-31 19:51:17 +03:00