Files
learnharness/src/core/session-clusters.ts
T
amosblomqvist af666aaa3f physics: bundles join their cluster; depth runs over the logical graph
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).
2026-09-30 04:30:25 +03:00

131 lines
6.4 KiB
TypeScript

/**
* BIRTH-SESSION CLUSTERING (wave 7, owner-locked 2026-09-23): embeddings
* are dead — a proposition's cluster is the SESSION THAT CREATED IT,
* derived deterministically from provenance. Cluster labels are session
* names (the chat title or the imported file's path): no model call, no
* key, no configuration. What the agglomerative pass grouped by MEANING
* proximity, this groups by WHERE KNOWLEDGE WAS BORN — the owner's call
* that the session that minted a set of propositions is the honest
* "region of the world model" they belong to.
*
* Birth session (owner ruling 2026-09-23): a node's FIRST surviving
* provenance point's chatId — the create line's first point at read time
* (getGraph merges lines by id, first line defines, provenance unions in
* line order — so provenance[0] is the birth point of the surviving
* lines). This makes deletion self-healing with ZERO special handling:
* delete-nodes strips a session's create lines and its provenance points;
* a SURVIVING shared node re-attributes to its earliest remaining
* contributor (provenance[0] walks forward), and a node left with NO
* provenance at all is simply unclustered. The partition is derived on
* every read — there is no persisted partition to invalidate.
*
* Notes imports are birth-sessions too (owner ruling 2026-09-23): their
* nodes carry a "src_…" source id instead of a chat id; the caller labels
* them with the file's path (kind: file in the session views). The old
* mapping-line fallback for provenance-empty notes nodes is GONE (owner,
* 2026-09-29): every merge writes provenance points, and the mappings
* ledger itself died with per-job in-memory resolution — provenance is
* the one birth record.
*
* Browser-safe pure logic (no node builtins, no fs): same contract style
* as the rest of the core graph derivation. Not re-exported through the
* core barrel — the shell reads clusters from the snapshot; only the
* server-side derivations (snapshot, settle) and tests import this.
*/
import type { GraphChain, GraphNode } from "./contracts.ts";
/** One birth-session cluster: the session id + the SORTED member node
* ids born in it. The shape is deliberately the { members } contract
* the layout consumer takes; `id` rides along for consumers that need
* the session identity (the snapshot's cluster id + label join). */
export interface BirthSessionCluster {
/** The birth session's id — a chatId or a notes source id ("src_…"). */
id: string;
/** Node ids born in this session, sorted. */
members: string[];
}
/** A node's birth session: its first surviving provenance point's chatId.
* null when the node carries no provenance at all (legacy data). */
export function birthSessionOf(node: Pick<GraphNode, "provenance">): string | null {
return node.provenance[0]?.chatId ?? null;
}
/**
* The birth-session partition over the given node LINES: dedupes by id
* (first line defines — the getGraph rule), groups by birth session, and
* returns the groups size-desc with members sorted (deterministic output:
* ties break by session id ascending). A node with no provenance is left
* unclustered — it belongs to no session the graph can name.
*/
export function birthSessionPartition(nodes: readonly GraphNode[]): BirthSessionCluster[] {
const members = new Map<string, string[]>();
const seen = new Set<string>();
for (const node of nodes) {
if (seen.has(node.id)) continue;
seen.add(node.id);
const birth = birthSessionOf(node);
if (birth === null) continue;
const bucket = members.get(birth);
if (bucket) bucket.push(node.id);
else members.set(birth, [node.id]);
}
return [...members.entries()]
.map(([id, list]) => ({ id, members: [...list].sort() }))
.sort((a, b) => b.members.length - a.members.length || (a.id < b.id ? -1 : 1));
}
/** The cluster key a derived implication node takes: MAJORITY among its
* PREMISES' birth sessions (owner, 2026-09-30 — premises, NOT the
* conclusion), ties to the chain's OWN birth (its provenance's first
* point, the asserting session — deterministic, and it coincides with
* the unanimous common case anyway). Imp nodes are always re-derived,
* never birthed, so their cluster must be a function of the derivation
* — this is that function. */
export function impClusterKey(
chain: Pick<GraphChain, "premises" | "provenance">,
birthOf: (nodeId: string) => string | null,
): string | null {
const counts = new Map<string, number>();
for (const premise of chain.premises) {
const birth = birthOf(premise);
if (birth !== null) counts.set(birth, (counts.get(birth) ?? 0) + 1);
}
const own = chain.provenance[0]?.chatId ?? null;
if (counts.size === 0) return own;
let max = 0;
for (const n of counts.values()) if (n > max) max = n;
const winners: string[] = [];
for (const [key, n] of counts) if (n === max) winners.push(key);
if (winners.length === 1) return winners[0]!;
// Tie → the chain's own birth; if even that is missing, the
// alphabetically-first winner (last-resort determinism).
return own ?? winners.sort()[0]!;
}
/** Attach derived imp nodes to communities IN PLACE (the shared seam for
* both consumers of cluster membership — the sim's centroid pull and
* the canvas's region rasterization both read members lists that went
* through here). Only MULTI-premise fully-live chains mint imp nodes
* (single-premise chains collapsed to direct arrows, rendering arc
* 2026-09-30); dangling chains mint nothing. A chain whose home
* community is absent from the list stays unclustered — like its
* premises when the hull filter drops them. */
export function attachImpToCommunities(
communities: Array<{ id: string; members: string[] }>,
propositions: ReadonlyArray<Pick<GraphNode, "id" | "provenance">>,
chains: ReadonlyArray<Pick<GraphChain, "id" | "premises" | "conclusion" | "provenance">>,
): void {
const birthById = new Map(propositions.map((node) => [node.id, birthSessionOf(node)]));
const live = new Set(propositions.map((node) => node.id));
for (const chain of chains) {
if (chain.premises.length < 2) continue;
if (!live.has(chain.conclusion) || !chain.premises.every((p) => live.has(p))) continue;
const key = impClusterKey(chain, (id) => birthById.get(id) ?? null);
const community = key === null ? undefined : communities.find((c) => c.id === key);
if (!community) continue;
community.members.push(chain.id);
}
for (const community of communities) community.members.sort();
}