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:
+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.
|
||||
|
||||
Reference in New Issue
Block a user