wave 7: the prober reads its map — graph-context injection dies whole
The owner's zero-configuration ruling (2026-09-23): nothing configures the graph beyond chat models. The prior-knowledge injection — its budget dial, its runner, its marker part, its dock/dispatch entries — is removed across all bands; the prober now reads the learner's persistent knowledge map on demand through two gated tools instead: - list_sessions: every birth session, names + kinds + counts + dates (graphSessionIndex, Platform's core seam) - list_propositions(session): what was born there + the session's own latest understanding level + that evaluation's date (graphSessionPropositions) — names/levels/dating ONLY, no statement text (owner ruling: the map's organization is the prober's business, its prose is not) Registered via createProberTools when chat-handler passes graphStore (the graphToolsEnabled gate decided server-side, per-turn fresh); the prober doctrine gains the map-review nudge at session start. Also: runner's JobKind special-casing collapses (KIND_ROLE/KIND_RUNNERS are plain records again), semantic-clusters.ts dies with its test, the embedder dep's doc tells the wave-7 stub story, and ports.ts's readClusterLabels/readNamingRuns surface stays untouched for Platform's ledger-port interlude. 848/848 tests, typecheck/lint/build green.
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* CLUSTER LABEL MATCHING (vision §1). DETECTION moved to semantic topic
|
||||
* clustering over embeddings (core/semantic-clusters.ts, DECIDED
|
||||
* 2026-09-13) — Louvain over the edge structure is retired: it only ever
|
||||
* found the reasoning chains (the graph is disconnected by nature), and
|
||||
* edge-orphans could never join anything. What REMAINS here is the
|
||||
* CLUSTER LABEL MATCHING (vision §1). DETECTION is birth-session
|
||||
* clustering since wave 7 (core/session-clusters.ts; the semantic
|
||||
* embedder and its Louvain predecessor are both dead). What REMAINS here
|
||||
* is the
|
||||
* cluster contract + core-survival label matching, which is
|
||||
* detection-agnostic: it consumes the same { members } shape semantic
|
||||
* clustering emits.
|
||||
|
||||
@@ -58,27 +58,8 @@ export interface ChatSettings {
|
||||
/** pi thinking level — must be in the chosen model's supported list
|
||||
* (getSupportedThinkingLevels; enforced by the settings route). */
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
/** Graph context injection (vision-context-injection.md): the session's
|
||||
* token budget for the prior-knowledge block — the top semantically
|
||||
* closest propositions from the knowledge graph, injected once, at
|
||||
* session start, as a steer. Absent = the default (on); 0 = off. The
|
||||
* settings route enforces the range; the trigger (turn-service →
|
||||
* subagents/graph-context.ts) is one-shot, so a mid-session change
|
||||
* affects only sessions that haven't injected yet. */
|
||||
graphContextTokens?: number;
|
||||
}
|
||||
|
||||
/** The injection budget's decided knobs (2026-09-14; scale revised
|
||||
* 2026-09-15, owner): default 1024 (a meaningful, modest slice of prior
|
||||
* knowledge), hard max 16384, 0 = off. The UI is a DISCRETE slider over
|
||||
* these stops — powers of four from 64 (the fine-grained low end was
|
||||
* theater: a 128-token budget is ~2 propositions, nobody wants that
|
||||
* dial); the server still accepts any integer in range (a stale client
|
||||
* can't brick settings), the stops only govern what the slider sends. */
|
||||
export const GRAPH_CONTEXT_TOKENS_DEFAULT = 1024;
|
||||
export const GRAPH_CONTEXT_TOKENS_MAX = 16384;
|
||||
/** The slider's positions, left to right: off, then powers of four. */
|
||||
export const GRAPH_CONTEXT_TOKENS_STOPS = [0, 64, 256, 1024, 4096, 16384] as const;
|
||||
|
||||
// ─── Background-extraction usage (the money channel, extraction side) ────────
|
||||
|
||||
@@ -202,53 +183,6 @@ export const HANDOFF_TEXT =
|
||||
"The probe is concluded — the learner assessment is recorded. " +
|
||||
"Author the session's plan map now (call plan_init) and present it, then begin teaching.";
|
||||
|
||||
// ─── Graph context injection (vision-context-injection.md, 2026-09-14) ────────
|
||||
|
||||
/** One selected prior-knowledge proposition: similarity-ranked, annotated
|
||||
* with the learner's CURRENT understanding level — the fold is MOST
|
||||
* RECENT extraction instance (levels are per-instance, tied to moments in
|
||||
* time; the latest evidence is the best estimate of current understanding).
|
||||
* null = the node has no observation event at all. */
|
||||
export interface GraphContextProposition {
|
||||
id: string;
|
||||
statement: string;
|
||||
level: UnderstandingLevel | null;
|
||||
}
|
||||
|
||||
/** The data-graph-context part payload: what the UI renders (the counts)
|
||||
* AND the audit trail (the full block content — the transcript shows
|
||||
* exactly what was injected, and when). The model-facing text is derived
|
||||
* from this by graphContextText below — the adapter flattens the marker
|
||||
* part to it (the custom-message scheme: no authored human speech). */
|
||||
export interface GraphContextData {
|
||||
chatId: string;
|
||||
/** The session's budget at injection time (for the card's readout). */
|
||||
budget: number;
|
||||
/** Estimated tokens of the injected block — chars/4 heuristic. */
|
||||
tokens: number;
|
||||
/** The selected propositions, similarity-descending. */
|
||||
propositions: GraphContextProposition[];
|
||||
}
|
||||
|
||||
/** The model-facing block text — single-sourced (the adapter, the tests,
|
||||
* and reality can't drift). The level word travels with each line: the
|
||||
* closing gloss tells the agent what to do with each. */
|
||||
export function graphContextText(data: GraphContextData): string {
|
||||
const n = data.propositions.length;
|
||||
if (n === 0) return "";
|
||||
const lines = data.propositions.map(
|
||||
(p) => `- ${p.level ? `[${p.level}] ` : ""}${p.statement}`,
|
||||
);
|
||||
return [
|
||||
`Prior-knowledge context — what this learner already knows (from their knowledge graph, ` +
|
||||
`${n} proposition${n === 1 ? "" : "s"}, ~${data.tokens} tokens):`,
|
||||
"",
|
||||
...lines,
|
||||
"",
|
||||
"Treat this as reliable prior knowledge: build on what is solid, firm up what is shaky, " +
|
||||
"and teach around anything resisted. Do not re-teach solid ground.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export interface PlanBlockData {
|
||||
/** Only "full" survives (the 2026-09-11 pivot): the plan renders IN
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
* of "ai" (safe everywhere: server, client, tests). See docs/vision.md.
|
||||
*/
|
||||
import type { UIMessage } from "ai";
|
||||
import type { CallUsage, GraphContextData } from "./chat.ts";
|
||||
import type { CallUsage } from "./chat.ts";
|
||||
|
||||
// ─── Async sub-agent jobs (vision §2.5; plan.md Wave 0 protocol) ────────────
|
||||
|
||||
export type JobKind = "research" | "demo" | "visual" | "pdf" | "graph-context";
|
||||
export type JobKind = "research" | "demo" | "visual" | "pdf";
|
||||
export type JobStatus = "running" | "done" | "error";
|
||||
|
||||
export interface JobRecord {
|
||||
@@ -295,8 +295,7 @@ export interface AbortSteerMessage extends UIMessage {
|
||||
export type SteerMessage =
|
||||
| SubagentSteerMessage
|
||||
| AbortSteerMessage
|
||||
| ProbeHandoffSteerMessage
|
||||
| GraphContextSteerMessage;
|
||||
| ProbeHandoffSteerMessage;
|
||||
|
||||
/** Whether a result is the failure shape (html/svg empty, failure brief). */
|
||||
export function isSubagentFailure(
|
||||
@@ -426,34 +425,6 @@ export function buildProbeHandoffSteer(chatId: string): ProbeHandoffSteerMessage
|
||||
};
|
||||
}
|
||||
|
||||
// ─── the graph-context steer (vision-context-injection.md) ───────────────────
|
||||
//
|
||||
// The injection's delivery: same wake pipeline, same gates — it queues
|
||||
// behind a pending quiz and rides the turn after the learner's answer, or
|
||||
// fires in the pump's idle window. Marker-only (the custom-message scheme):
|
||||
// no text part; the adapter flattens data-graph-context to graphContextText
|
||||
// (core/contracts/chat.ts owns the wording). One per chat, ever — the id is
|
||||
// deterministic per chat, so re-delivery is idempotent.
|
||||
|
||||
export interface GraphContextSteerMessage extends UIMessage {
|
||||
id: `steer_ctx_${string}`;
|
||||
role: "user";
|
||||
metadata: { graphContext: { chatId: string; jobId: string } };
|
||||
parts: [{ type: "data-graph-context"; data: GraphContextData }];
|
||||
}
|
||||
|
||||
export function buildGraphContextSteer(
|
||||
meta: { chatId: string; jobId: string },
|
||||
data: GraphContextData,
|
||||
): GraphContextSteerMessage {
|
||||
return {
|
||||
id: `steer_ctx_${meta.chatId}`,
|
||||
role: "user",
|
||||
metadata: { graphContext: meta },
|
||||
parts: [{ type: "data-graph-context", data }],
|
||||
};
|
||||
}
|
||||
|
||||
/** True when the transcript already carries this steer — idempotent
|
||||
* delivery for ANY steer kind: every steer id is deterministic per its
|
||||
* event (steer_<jobId> for jobs, steer_probe_<chatId> for the handoff). */
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
/**
|
||||
* Graph context injection — the pure selection core
|
||||
* (docs/vision-context-injection.md, settled 2026-09-14).
|
||||
*
|
||||
* The heavy lifting is deliberately tiny: embedding the topic is the only
|
||||
* slow step (the embedder singleton), and SEARCH IS BRUTE-FORCE COSINE —
|
||||
* ~1M multiply-adds at 2k propositions, exact top-K from the in-memory
|
||||
* ledger, no ANN index until ~10⁶ scale (far away).
|
||||
*
|
||||
* Selection doctrine (owner-decided): the token budget is the ONLY control
|
||||
* on amount — there is NO similarity threshold. Similarity decides ORDER
|
||||
* (which propositions fill the budget first); irrelevant-but-cheap content
|
||||
* rides along harmlessly. Levels fold MOST RECENT: UnderstandingLevel lives
|
||||
* per extraction instance (tied to moments in time), and the latest evidence
|
||||
* is the best estimate of current understanding — an optimistic fold risks
|
||||
* stale claims, a pessimistic fold wastes covered ground.
|
||||
*/
|
||||
|
||||
|
||||
import {
|
||||
GRAPH_CONTEXT_TOKENS_MAX,
|
||||
type GraphContextData,
|
||||
type GraphContextProposition,
|
||||
graphContextText,
|
||||
} from "./contracts/chat.ts";
|
||||
import type { UnderstandingLevel } from "./contracts/graph.ts";
|
||||
import type { GraphNode, LedgerEvent } from "./contracts/graph.ts";
|
||||
|
||||
/** Belt-and-braces count cap — the budget is the primary control; a huge
|
||||
* budget still can't pour hundreds of lines into the block. */
|
||||
export const GRAPH_CONTEXT_MAX_PROPOSITIONS = 64;
|
||||
|
||||
/** The block's framing (header + gloss) in estimated tokens — reserved so
|
||||
* the composed block, not just the proposition lines, stays under budget. */
|
||||
const FRAMING_TOKENS = 55;
|
||||
|
||||
const LEVELS: ReadonlySet<string> = new Set([
|
||||
"solid",
|
||||
"shaky",
|
||||
"accepted",
|
||||
"resisted",
|
||||
"not yet engaged",
|
||||
]);
|
||||
|
||||
export function isUnderstandingLevel(word: unknown): word is UnderstandingLevel {
|
||||
return typeof word === "string" && LEVELS.has(word);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold each node's CURRENT understanding level: the level word of its most
|
||||
* recent observation event (latest `at`; ledger order breaks ties — the
|
||||
* same rule the snapshot's nodeObservations uses, but across ALL sessions,
|
||||
* not per chat). Malformed notes (not on the scale) never count.
|
||||
*/
|
||||
export function currentUnderstanding(events: LedgerEvent[]): Map<string, UnderstandingLevel> {
|
||||
const latest = new Map<string, { at: string; level: UnderstandingLevel; index: number }>();
|
||||
events.forEach((event, index) => {
|
||||
if (event.kind !== "observation" || event.nodeId === null) return;
|
||||
if (!isUnderstandingLevel(event.note)) return;
|
||||
const current = latest.get(event.nodeId);
|
||||
if (!current || event.at > current.at || (event.at === current.at && index > current.index)) {
|
||||
latest.set(event.nodeId, { at: event.at, level: event.note, index });
|
||||
}
|
||||
});
|
||||
const levels = new Map<string, UnderstandingLevel>();
|
||||
for (const [nodeId, { level }] of latest) levels.set(nodeId, level);
|
||||
return levels;
|
||||
}
|
||||
|
||||
/** Estimated tokens for one proposition line: chars/4 (the agreed
|
||||
* heuristic — no server-side tokenizer) + one for the level tag. */
|
||||
function lineTokens(p: GraphContextProposition): number {
|
||||
return Math.ceil(p.statement.length / 4) + (p.level ? 1 : 0);
|
||||
}
|
||||
|
||||
export interface SelectGraphContextInput {
|
||||
chatId: string;
|
||||
/** The topic's query embedding (same model + dim as the ledger — the
|
||||
* caller asserts parity; dimension-mismatched vectors are skipped). */
|
||||
query: number[];
|
||||
/** The embeddings ledger's vectors: nodeId → truncated, L2-normalized
|
||||
* (cosine = dot product). */
|
||||
vectors: Record<string, number[]>;
|
||||
/** Raw node lines (append order; first line per id defines — the
|
||||
* ledger merge rule, mirrored from core/graph.ts). */
|
||||
nodes: GraphNode[];
|
||||
/** The event ledger — for the most-recent level fold. */
|
||||
events: LedgerEvent[];
|
||||
/** The session's token budget (estimated tokens, chars/4). */
|
||||
budget: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the prior-knowledge block: similarity-descending greedy fill under
|
||||
* the token budget, capped by count, each line annotated with its folded
|
||||
* current level. Pure — server composes it with the embedder; tests drive
|
||||
* it with hand-made vectors.
|
||||
*/
|
||||
export function selectGraphContext(input: SelectGraphContextInput): GraphContextData {
|
||||
const { chatId, query, vectors, nodes, events, budget } = input;
|
||||
const levels = currentUnderstanding(events);
|
||||
|
||||
// Score every embedded node: dot product (vectors are normalized). A
|
||||
// dimension mismatch (a stale ledger, a foreign query) never scores.
|
||||
const scored: Array<{ id: string; statement: string; score: number }> = [];
|
||||
const seen = new Set<string>();
|
||||
for (const node of nodes) {
|
||||
if (seen.has(node.id)) continue; // first line defines (merge rule)
|
||||
seen.add(node.id);
|
||||
const vec = vectors[node.id];
|
||||
if (!vec || vec.length !== query.length) continue;
|
||||
let dot = 0;
|
||||
for (let i = 0; i < query.length; i++) dot += query[i]! * vec[i]!;
|
||||
scored.push({ id: node.id, statement: node.statement, score: dot });
|
||||
}
|
||||
scored.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
|
||||
|
||||
// Greedy fill: similarity order decides WHICH propositions enter; the
|
||||
// budget decides WHEN to stop. No similarity threshold, by design.
|
||||
const propositions: GraphContextProposition[] = [];
|
||||
let used = FRAMING_TOKENS;
|
||||
for (const { id, statement } of scored) {
|
||||
if (propositions.length >= GRAPH_CONTEXT_MAX_PROPOSITIONS) break;
|
||||
const p: GraphContextProposition = {
|
||||
id,
|
||||
statement,
|
||||
level: levels.get(id) ?? null,
|
||||
};
|
||||
const cost = lineTokens(p);
|
||||
if (used + cost > budget) continue; // too big for the budget — a later, smaller one may still fit
|
||||
used += cost;
|
||||
propositions.push(p);
|
||||
}
|
||||
return { chatId, budget, tokens: used, propositions };
|
||||
}
|
||||
|
||||
/** True when the selection would inject anything (guards the runner: an
|
||||
* empty block is never delivered — no steer, the job errors instead). */
|
||||
export function hasContext(data: GraphContextData): boolean {
|
||||
return data.propositions.length > 0 && graphContextText(data).length > 0;
|
||||
}
|
||||
|
||||
// ─── The marker-part parser (parse-don't-cast — tool-parts doctrine) ──────────
|
||||
|
||||
function parseProposition(raw: unknown): GraphContextProposition | null {
|
||||
if (typeof raw !== "object" || raw === null) return null;
|
||||
const { id, statement, level } = raw as Record<string, unknown>;
|
||||
if (typeof id !== "string" || typeof statement !== "string") return null;
|
||||
if (level !== null && level !== undefined && !isUnderstandingLevel(level)) return null;
|
||||
return { id, statement, level: (level as UnderstandingLevel | null | undefined) ?? null };
|
||||
}
|
||||
|
||||
function parseGraphContextData(raw: unknown): GraphContextData | null {
|
||||
if (typeof raw !== "object" || raw === null) return null;
|
||||
const { chatId, budget, tokens, propositions } = raw as Record<string, unknown>;
|
||||
if (typeof chatId !== "string") return null;
|
||||
if (typeof budget !== "number" || budget < 0 || budget > GRAPH_CONTEXT_TOKENS_MAX) return null;
|
||||
if (typeof tokens !== "number" || tokens < 0) return null;
|
||||
if (!Array.isArray(propositions)) return null;
|
||||
const parsed: GraphContextProposition[] = [];
|
||||
for (const item of propositions) {
|
||||
const p = parseProposition(item);
|
||||
if (!p) return null; // malformed content: the whole part is untrusted
|
||||
parsed.push(p);
|
||||
}
|
||||
return { chatId, budget, tokens, propositions: parsed };
|
||||
}
|
||||
|
||||
/**
|
||||
* The data-graph-context part of a message, validated — or null when the
|
||||
* part is absent or malformed. The seam the adapter and the chat view read
|
||||
* the marker through (never a raw cast: the part's data crossed a
|
||||
* serialization boundary).
|
||||
*/
|
||||
export function graphContextOfMessage(
|
||||
message: { parts: ReadonlyArray<{ type: string; data?: unknown }> },
|
||||
): GraphContextData | null {
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "data-graph-context") {
|
||||
return parseGraphContextData((part as { data?: unknown }).data);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+4
-5
@@ -399,11 +399,10 @@ const QUIZ_EVENT_KINDS: ReadonlySet<string> = new Set(["correct", "miss", "dont-
|
||||
/**
|
||||
* The latest quiz outcome per node — a raw EVENT FACT, not a node state
|
||||
* (the transitional node-state classes were removed 2026-09-08: nothing
|
||||
* colors nodes by quiz results anymore). Used by the teacher's
|
||||
* graph-as-context selection (graph-context.ts) to find diagnostic
|
||||
* hotspots; the home renderer does not consume it. Nodes with no quiz
|
||||
* events have no entry. (The graph-as-context consumer is shelved —
|
||||
* docs/probe-teach-split.md §5 — but the event fact stays.)
|
||||
* colors nodes by quiz results anymore). Its last consumer, the
|
||||
* graph-as-context selection, died in wave 7 (the injection is gone —
|
||||
* the prober reads its map through tools now); the event fact stays as
|
||||
* raw ledger truth. Nodes with no quiz events have no entry.
|
||||
*/
|
||||
export function latestQuizOutcomes(nodes: GraphNode[], events: LedgerEvent[]): Map<string, LedgerEventKind> {
|
||||
const latest = new Map<string, { at: string; kind: LedgerEventKind; index: number }>();
|
||||
|
||||
@@ -55,4 +55,3 @@ export * from "./auth-keys.ts";
|
||||
// keyboard.ts: the shortcut registry + binding normalization (pure, browser-safe).
|
||||
export * from "./keyboard.ts";
|
||||
export * from "./coverage.ts";
|
||||
export * from "./graph-context.ts";
|
||||
|
||||
+4
-5
@@ -130,11 +130,10 @@ export interface GraphStore {
|
||||
|
||||
/** The embeddings ledger (config.EMBEDDINGS_FILE) — INERT DATA since
|
||||
* wave 7 (owner ruling 2026-09-23: the provider embedder is dead;
|
||||
* clustering is birth-session, labels are session names). Kept ONLY
|
||||
* because the graph-context injection (agent band, Teacher's pass
|
||||
* removes it) still reads it for vector-space staleness — the seam
|
||||
* dies with it in the follow-up interlude. graph-deletion keeps
|
||||
* pruning dead vectors meanwhile (harmless housekeeping). */
|
||||
* clustering is birth-session, labels are session names; the
|
||||
* graph-context injection is gone with it). The whole seam dies in
|
||||
* the follow-up interlude; graph-deletion's vector pruning is
|
||||
* harmless housekeeping meanwhile. */
|
||||
readEmbeddings(): EmbeddingsLedger;
|
||||
/** Replace the whole ledger (graph-deletion's vector pruning only). */
|
||||
writeEmbeddings(ledger: EmbeddingsLedger): void;
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
// Semantic topic clustering — the REPLACEMENT for Louvain community
|
||||
// detection (DECIDED 2026-09-13): structural clusters were just 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: nodes whose proposition
|
||||
// MEANING is close, computed over the embedding ledger
|
||||
// (config.EMBEDDINGS_FILE). This pulls orphans into topics by meaning
|
||||
// and groups small chains with the large chains they discuss.
|
||||
//
|
||||
// Algorithm: threshold AGGLOMERATIVE (average linkage over cosine
|
||||
// similarity). No JS HDBSCAN is trustworthy (the npm package is an
|
||||
// abandoned alpha), and at proposition scale (~100s-1000s of short texts)
|
||||
// brute-force pairwise cosine is sub-second — so the honest ~60-line
|
||||
// hand-roll wins. "Stragglers" (nodes that fit nothing) fall out
|
||||
// naturally: they simply never merge, and stay singletons.
|
||||
|
||||
/** The default similarity threshold, SWEPT ON THE REAL GRAPH
|
||||
* (2026-09-13): 0.60 → 26 topics of ~20 propositions, 74 of the 90
|
||||
* edge-orphans land in real topics, stragglers 10. Higher (0.65-0.68)
|
||||
* gives tighter micro-topics with far more stragglers; lower (0.55)
|
||||
* collapses into ~13 mega-topics. */
|
||||
export const SEMANTIC_SIM_THRESHOLD = 0.6;
|
||||
|
||||
/** Cosine similarity of two vectors (defensively normalizes inputs;
|
||||
* ledger vectors are already L2-normalized, so this is a dot product). */
|
||||
export function cosineSimilarity(a: readonly number[], b: readonly number[]): number {
|
||||
let dot = 0;
|
||||
let ma = 0;
|
||||
let mb = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dot += a[i]! * b[i]!;
|
||||
ma += a[i]! * a[i]!;
|
||||
mb += b[i]! * b[i]!;
|
||||
}
|
||||
if (ma === 0 || mb === 0) return 0;
|
||||
return dot / (Math.sqrt(ma) * Math.sqrt(mb));
|
||||
}
|
||||
|
||||
export interface SemanticClusterOptions {
|
||||
/** Merge while the best cluster pair's average-linkage similarity is
|
||||
* >= this (default SEMANTIC_SIM_THRESHOLD). */
|
||||
similarityThreshold?: number;
|
||||
/** After the cut, force every singleton into its nearest topic
|
||||
* ("all-in" mode) instead of leaving stragglers loose. Default
|
||||
* false: some propositions genuinely fit nothing. */
|
||||
attachStragglers?: boolean;
|
||||
}
|
||||
|
||||
/** Average-linkage agglomerative clustering over embedding vectors.
|
||||
* Pure and deterministic (stable Map iteration, greedy by best
|
||||
* similarity, first-found wins ties). Returns clusters size-desc — the
|
||||
* same { members } shape Louvain produced, so label matching and the
|
||||
* renderer contract are unchanged. An empty/missing ledger returns []
|
||||
* (nothing embedded yet: no clusters, every node loose — the snapshot
|
||||
* degrades gracefully until the first embed). */
|
||||
export function semanticClusters(
|
||||
vectors: Readonly<Record<string, readonly number[]>>,
|
||||
options: SemanticClusterOptions = {},
|
||||
): Array<{ members: string[] }> {
|
||||
const ids = Object.keys(vectors);
|
||||
if (ids.length === 0) return [];
|
||||
const simThreshold = options.similarityThreshold ?? SEMANTIC_SIM_THRESHOLD;
|
||||
const attach = options.attachStragglers ?? false;
|
||||
|
||||
// Sum vectors per cluster: average-linkage similarity between clusters
|
||||
// A and B is dot(sumA, sumB) / (|A|·|B|) — the exact mean of pairwise
|
||||
// dots — so the merge loop never revisits the raw O(n²) matrix.
|
||||
const sums = new Map<number, number[]>();
|
||||
const members = new Map<number, string[]>();
|
||||
ids.forEach((id, i) => {
|
||||
sums.set(i, [...vectors[id]!]);
|
||||
members.set(i, [id]);
|
||||
});
|
||||
|
||||
// Greedy merge: repeatedly join the pair with the highest
|
||||
// average-linkage similarity until it drops below the threshold.
|
||||
// O(k²) pair scan per merge — trivial at proposition scale.
|
||||
for (;;) {
|
||||
let bestA = -1;
|
||||
let bestB = -1;
|
||||
let bestSim = -Infinity;
|
||||
for (const [a, sumA] of sums) {
|
||||
for (const [b, sumB] of sums) {
|
||||
if (a >= b) continue;
|
||||
let dot = 0;
|
||||
for (let i = 0; i < sumA.length; i++) dot += sumA[i]! * sumB[i]!;
|
||||
const sim = dot / (members.get(a)!.length * members.get(b)!.length);
|
||||
if (sim > bestSim) {
|
||||
bestSim = sim;
|
||||
bestA = a;
|
||||
bestB = b;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestA < 0 || bestSim < simThreshold) break;
|
||||
// Merge B into A (deterministic direction: lower cluster index).
|
||||
const sumA = sums.get(bestA)!;
|
||||
const sumB = sums.get(bestB)!;
|
||||
for (let i = 0; i < sumA.length; i++) sumA[i] = sumA[i]! + sumB[i]!;
|
||||
members.get(bestA)!.push(...members.get(bestB)!);
|
||||
sums.delete(bestB);
|
||||
members.delete(bestB);
|
||||
}
|
||||
|
||||
// Optional all-in pass: singletons join their nearest topic regardless
|
||||
// of threshold — exactly the "forced membership" mode. The difference
|
||||
// from stragglers mode is this one pass. Singleton-vs-cluster
|
||||
// average-linkage similarity is dot(sumS, sumK) / |K|.
|
||||
if (attach) {
|
||||
const singletons = [...members.entries()].filter(([, m]) => m.length === 1).map(([k]) => k);
|
||||
for (const s of singletons) {
|
||||
if (!members.has(s)) continue; // absorbed by an earlier attach
|
||||
let bestTarget = -1;
|
||||
let bestSim = -Infinity;
|
||||
for (const [k, m] of members) {
|
||||
if (k === s || m.length < 2) continue;
|
||||
let dot = 0;
|
||||
const sumS = sums.get(s)!;
|
||||
const sumK = sums.get(k)!;
|
||||
for (let i = 0; i < sumS.length; i++) dot += sumS[i]! * sumK[i]!;
|
||||
const sim = dot / m.length;
|
||||
if (sim > bestSim) {
|
||||
bestSim = sim;
|
||||
bestTarget = k;
|
||||
}
|
||||
}
|
||||
if (bestTarget >= 0) {
|
||||
const sumT = sums.get(bestTarget)!;
|
||||
const sumS = sums.get(s)!;
|
||||
for (let i = 0; i < sumT.length; i++) sumT[i] = sumT[i]! + sumS[i]!;
|
||||
members.get(bestTarget)!.push(...members.get(s)!);
|
||||
sums.delete(s);
|
||||
members.delete(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...members.values()]
|
||||
.map((m) => ({ members: [...m].sort() }))
|
||||
.sort((a, b) => b.members.length - a.members.length || (a.members[0] ?? "").localeCompare(b.members[0] ?? ""));
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import type {
|
||||
DemoToolInput,
|
||||
GraphContextData,
|
||||
PdfToolInput,
|
||||
PlanVerdictData,
|
||||
PlanBlockData,
|
||||
@@ -68,10 +67,6 @@ export type TeacherUIDataParts = {
|
||||
/** The probe→teach handoff wake: the wake pipeline's steer message,
|
||||
* carrying ONLY this part (the model-facing text lives in the adapter). */
|
||||
"probe-handoff": { chatId: string };
|
||||
/** The prior-knowledge injection's delivery (vision-context-injection.md):
|
||||
* the marker-only steer — the counts for the UI, the full block content
|
||||
* for the audit trail; the adapter flattens it to graphContextText. */
|
||||
"graph-context": GraphContextData;
|
||||
/** The abort steer's message-type marker (the abort badge renders off it). */
|
||||
"subagent-aborted": SubagentResultMeta & { aborted: true };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user