Clusters phases B+C: labeling records, the naming call, snapshot payload
Core-survival labelings (vision section 1, DECIDED 2026-09-13): ClusterLabeling records in a new append-only ledger (data/graph/cluster-labels.jsonl), immutable, id = clu_ + sha256 of the sorted core so re-naming races and re-runs dedupe at the store seam. matchClusterLabelings (core/clusters.ts, pure): a community keeps a record's label while at least half its core survives in it; both sides single-successor (a split keeps one name; greedy by overlap, ties to the newer record). The namer (src/agent/subagents/cluster-namer.ts): ONE singular cheap LLM call (new 'labeler' role, glm-flash/low) names every new/changed community at once, given member names + statements (capped at 12 members per cluster). refreshClusterLabels runs POST-EXTRACTION (user-decided) from both front doors, fire-and-forget — naming failure never fails the run; refreshes serialize behind a chain so parallel imports never mint duplicate labels. import-notes.ts awaits clusterNamingSettled before exit. Payload: GraphSnapshot.clusters (>= MIN_CLUSTER_SIZE 3) joins detection with labelings at read time — claimed clusters carry the record's label and id; unlabeled ones carry a member-hash id and null label. Detection is deterministic so no memo in v1 (ms at current scale; revisit at vault scale). Tests (372/372): matcher half-rule/split/merge/greedy/tie cases; naming end-to-end through importNotes (one call, one record, claim on re-refresh), naming failure non-fatal; snapshot derivation with labeling join; 11 test pipelines now pin an explicit labeler seam.
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
*/
|
||||
import Graph from "graphology";
|
||||
import louvain from "graphology-communities-louvain";
|
||||
import type { ClusterLabeling } from "./contracts.ts";
|
||||
|
||||
/** A detected community. Members are SORTED node ids — the member set IS
|
||||
* the identity a labeling record can outlive a reshuffle with. */
|
||||
@@ -33,6 +34,11 @@ export interface Community {
|
||||
members: string[];
|
||||
}
|
||||
|
||||
/** Communities below this size are not rendered, not named — presentation
|
||||
* rule (user-decided 2026-09-13): a region of the world model needs at
|
||||
* least three propositions to be worth a hull and a label. */
|
||||
export const MIN_CLUSTER_SIZE = 3;
|
||||
|
||||
export interface CommunityDetection {
|
||||
communities: Community[];
|
||||
/** Modularity of the found partition — the one quality number. */
|
||||
@@ -116,3 +122,47 @@ export function detectCommunities(
|
||||
);
|
||||
return { communities, modularity: details.modularity };
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim labeling records for detected communities — CORE-SURVIVAL (DECIDED
|
||||
* 2026-09-13): a community keeps a record's label while AT LEAST HALF of
|
||||
* the record's core survives in it (overlap * 2 >= core.length; exactly
|
||||
* half survives). Both sides are single-successor: one community holds at
|
||||
* most one label, one record is claimed by at most one community — on a
|
||||
* split, only the branch carrying the majority of the core keeps the name;
|
||||
* on a merge, the best-overlapping record wins.
|
||||
*
|
||||
* Greedy assignment by overlap size (largest first; ties broken by newer
|
||||
* record). Returns a claim (or null) per community, in input order —
|
||||
* unclaimed communities are the namer's input. Pure: same inputs, same
|
||||
* claims.
|
||||
*/
|
||||
export function matchClusterLabelings(
|
||||
communities: readonly Community[],
|
||||
labelings: readonly ClusterLabeling[],
|
||||
): (ClusterLabeling | null)[] {
|
||||
interface Pair {
|
||||
community: number;
|
||||
labeling: ClusterLabeling;
|
||||
overlap: number;
|
||||
}
|
||||
const pairs: Pair[] = [];
|
||||
communities.forEach((community, ci) => {
|
||||
const members = new Set(community.members);
|
||||
for (const labeling of labelings) {
|
||||
const overlap = labeling.core.reduce((n, id) => n + (members.has(id) ? 1 : 0), 0);
|
||||
if (overlap * 2 >= labeling.core.length && overlap > 0) {
|
||||
pairs.push({ community: ci, labeling, overlap });
|
||||
}
|
||||
}
|
||||
});
|
||||
pairs.sort((a, b) => b.overlap - a.overlap || (a.labeling.createdAt < b.labeling.createdAt ? 1 : -1));
|
||||
const claims: (ClusterLabeling | null)[] = communities.map(() => null);
|
||||
const takenRecords = new Set<string>();
|
||||
for (const pair of pairs) {
|
||||
if (claims[pair.community] || takenRecords.has(pair.labeling.id)) continue;
|
||||
claims[pair.community] = pair.labeling;
|
||||
takenRecords.add(pair.labeling.id);
|
||||
}
|
||||
return claims;
|
||||
}
|
||||
|
||||
@@ -270,6 +270,37 @@ export interface AuditResult {
|
||||
}
|
||||
|
||||
/** The GET /api/graph payload — everything the home view's PURE renderer needs. */
|
||||
/** cluster-labels.jsonl line — one LLM-named community (vision §1,
|
||||
* DECIDED 2026-09-13). Append-only history: records are immutable, never
|
||||
* edited or deleted; dead labelings remain as history. IDENTITY is the
|
||||
* sorted member set ("core"), hashed into the id, so re-naming the same
|
||||
* community dedupes and a labeling survives graph edits as long as at
|
||||
* least half its core survives in some single community (core-survival;
|
||||
* under half → the community is renamed, splits can keep only one
|
||||
* successor). */
|
||||
export interface ClusterLabeling {
|
||||
/** "clu_" + content hash of the sorted core. */
|
||||
id: string;
|
||||
/** The short domain label (1–4 words), e.g. "Differential forms". */
|
||||
label: string;
|
||||
/** The member node ids the label was minted for, sorted — the survival core. */
|
||||
core: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** One rendered cluster in the snapshot payload (vision §1). Members are
|
||||
* node ids; the renderer draws the hull from member positions at draw
|
||||
* time and gates the label inversely on the node-label zoom control. */
|
||||
export interface Cluster {
|
||||
/** The labeling record id when claimed; unlabeled clusters still
|
||||
* appear (hull, no name) with a member-hash id. */
|
||||
id: string;
|
||||
/** null until the naming call has minted/claimed a labeling. */
|
||||
label: string | null;
|
||||
/** Node ids, sorted. Hull + centroid are renderer concerns. */
|
||||
members: string[];
|
||||
}
|
||||
|
||||
export interface GraphSnapshot {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
@@ -286,6 +317,10 @@ export interface GraphSnapshot {
|
||||
* shown next to the session list — the confidence history). Multiple
|
||||
* notes for one (node, chat) collapse to the latest. */
|
||||
nodeObservations: Record<string, Record<string, string>>;
|
||||
/** Structural communities (Louvain over follows+supports), size ≥ 3,
|
||||
* joined with persisted LLM labelings. Purely structural — never
|
||||
* depends on layout. */
|
||||
clusters: Cluster[];
|
||||
}
|
||||
|
||||
/** Derive a short chat title from the learner's first message (pure).
|
||||
|
||||
+30
-2
@@ -38,10 +38,11 @@
|
||||
* adapters in.
|
||||
*/
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { isToolUIPart, type UIMessage } from "ai";
|
||||
import type {
|
||||
AuditResult,
|
||||
Cluster,
|
||||
EmissionStats,
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
@@ -50,6 +51,7 @@ import type {
|
||||
LedgerEvent,
|
||||
LedgerEventKind,
|
||||
} from "./contracts.ts";
|
||||
import { detectCommunities, matchClusterLabelings, MIN_CLUSTER_SIZE } from "./clusters.ts";
|
||||
import type { QuestionAnswerData } from "./question-core.ts";
|
||||
import type { ChatStore, GraphStore } from "./ports.ts";
|
||||
|
||||
@@ -330,6 +332,16 @@ export function pendingQuizAnswers(chatId: string, deps: GraphDeps): number {
|
||||
|
||||
// ─── derivation (reads only — everything below is recomputable) ─────────────
|
||||
|
||||
/** The content identity of a community: "clu_" + sha256 of the sorted
|
||||
* member ids (12 hex). Deterministic — the same member set always mints
|
||||
* the same id — so re-naming races and idempotent re-runs dedupe at the
|
||||
* store seam (appendClusterLabel). Lives here (server-only core), not in
|
||||
* clusters.ts, because clusters.ts is barrel-exported and must stay
|
||||
* browser-safe (no node:crypto). */
|
||||
export function clusterContentId(members: readonly string[]): string {
|
||||
return "clu_" + createHash("sha256").update([...members].sort().join("\n")).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
export interface DerivedGraph {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
@@ -485,7 +497,23 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
|
||||
(nodeObservations[nodeId] ??= {})[chatId] = note;
|
||||
}
|
||||
|
||||
return { nodes, edges, sessions, fileSources, nodeSessions, nodeObservations };
|
||||
// Structural communities (vision §1): Louvain over the undirected
|
||||
// follows+supports structure, joined with persisted labelings —
|
||||
// core-survival matching, size-filtered at the presentation threshold.
|
||||
// Detection is DETERMINISTIC (seeded), so no memo in v1: a snapshot
|
||||
// read recomputes it (ms at current scale; revisit at vault scale).
|
||||
const detection = detectCommunities(nodes, edges);
|
||||
const claims = matchClusterLabelings(detection.communities, deps.graphStore.readClusterLabels());
|
||||
const clusters: Cluster[] = detection.communities
|
||||
.map((community, index) => ({ community, claim: claims[index] }))
|
||||
.filter(({ community }) => community.members.length >= MIN_CLUSTER_SIZE)
|
||||
.map(({ community, claim }) => ({
|
||||
id: claim?.id ?? clusterContentId(community.members),
|
||||
label: claim?.label ?? null,
|
||||
members: community.members,
|
||||
}));
|
||||
|
||||
return { nodes, edges, sessions, fileSources, nodeSessions, nodeObservations, clusters };
|
||||
}
|
||||
|
||||
// Re-export: the contract owns the shape; legacy importers still reach it.
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
SessionCheckpoint,
|
||||
TurnUsage,
|
||||
NotesSource,
|
||||
ClusterLabeling,
|
||||
} from "./contracts.ts";
|
||||
|
||||
// ─── ChatStore — the session record's home (data/chats) ─────────────────────
|
||||
@@ -82,6 +83,11 @@ export interface GraphStore {
|
||||
appendCheckpoint(checkpoint: SessionCheckpoint): void;
|
||||
readCheckpoints(): SessionCheckpoint[];
|
||||
|
||||
/** Append one cluster labeling — idempotent by id (the id hashes the
|
||||
* sorted core, so identical member sets dedupe). */
|
||||
appendClusterLabel(labeling: ClusterLabeling): void;
|
||||
readClusterLabels(): ClusterLabeling[];
|
||||
|
||||
/** Upsert one imported-notes source record (config.SOURCES_FILE — a
|
||||
* JSON map, not a ledger; an upsert keeps the existing usage log). */
|
||||
upsertSource(source: NotesSource): void;
|
||||
|
||||
Reference in New Issue
Block a user