2026-09-13 03:46:24 +03:00
|
|
|
/**
|
2026-09-13 14:29:16 +03:00
|
|
|
* 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 contract + core-survival label matching, which is
|
|
|
|
|
* detection-agnostic: it consumes the same { members } shape semantic
|
|
|
|
|
* clustering emits.
|
2026-09-13 03:46:24 +03:00
|
|
|
*
|
2026-09-13 14:29:16 +03:00
|
|
|
* Labelings key to sorted MEMBER SETS, never to detection indices —
|
|
|
|
|
* a labeling record outlives any reshuffle of the partition.
|
2026-09-13 03:46:24 +03:00
|
|
|
*
|
2026-09-13 14:29:16 +03:00
|
|
|
* Browser-safe pure logic (no node builtins, no fs): the server runs
|
|
|
|
|
* matching for the snapshot payload, tests run it directly.
|
2026-09-13 03:46:24 +03:00
|
|
|
*/
|
2026-09-13 03:59:48 +03:00
|
|
|
import type { ClusterLabeling } from "./contracts.ts";
|
2026-09-13 03:46:24 +03:00
|
|
|
|
|
|
|
|
/** A detected community. Members are SORTED node ids — the member set IS
|
|
|
|
|
* the identity a labeling record can outlive a reshuffle with. */
|
|
|
|
|
export interface Community {
|
|
|
|
|
members: string[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-13 03:59:48 +03:00
|
|
|
/** 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;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|