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).
This commit is contained in:
@@ -122,6 +122,11 @@ export interface CanvasColors {
|
||||
export interface CanvasData {
|
||||
nodes: CanvasNode[];
|
||||
edges: CanvasEdge[];
|
||||
/** The LOGICAL arrows (proposition→conclusion, imp contracted) the
|
||||
* depth force consumes — absent = the sim derives depth from the
|
||||
* drawn edges (legacy). Optional so non-graph callers of the
|
||||
* renderer stay simple. */
|
||||
logicalEdges?: CanvasEdge[];
|
||||
clusters: CanvasCluster[];
|
||||
colors: CanvasColors;
|
||||
/** Body font family (labels must match the app typography). */
|
||||
@@ -1304,6 +1309,7 @@ export class GraphCanvasRenderer {
|
||||
}
|
||||
const t = this.physicsTuning;
|
||||
this.sim = createGraphSimulation(data.nodes, data.edges, {
|
||||
logicalEdges: data.logicalEdges,
|
||||
gravity: { x: this.simCenter.x, y: this.simCenter.y, strength: t.gravity },
|
||||
alphaDecay: LIVE_ALPHA_DECAY,
|
||||
velocityDecay: t.velocityDecay,
|
||||
|
||||
@@ -1382,6 +1382,7 @@ export function GraphView() {
|
||||
label: n.name,
|
||||
})),
|
||||
edges: snapshot.edges.map((e) => ({ from: e.from, to: e.to })),
|
||||
logicalEdges: snapshot.logicalEdges.map((e) => ({ from: e.from, to: e.to })),
|
||||
clusters: snapshot.clusters.map((c) => ({ id: c.id, label: c.label, members: c.members })),
|
||||
colors: {
|
||||
node: tokenHex(NODE_COLOR_TOKEN),
|
||||
|
||||
@@ -32,6 +32,7 @@ function main() {
|
||||
const nodes = graph.nodes; // getGraph already merges lines by id
|
||||
const view = deriveView(graph);
|
||||
const edges = view.edges;
|
||||
const logicalEdges = view.logicalEdges;
|
||||
const viewNodeById = new Map(view.nodes.map((n) => [n.id, n]));
|
||||
const chains = graph.chains;
|
||||
// An implication node is delivered the moment its chain is complete
|
||||
@@ -91,6 +92,7 @@ function main() {
|
||||
// Edges activate once BOTH endpoints exist (a later file can claim
|
||||
// one endpoint — the edge lights up then).
|
||||
const activeEdges = edges.filter((e) => delivered.has(e.from) && delivered.has(e.to));
|
||||
const activeLogical = logicalEdges.filter((e) => delivered.has(e.from) && delivered.has(e.to));
|
||||
|
||||
const before = new Map(positions);
|
||||
const batchNodes = view.nodes.filter((n) => delivered.has(n.id));
|
||||
@@ -109,6 +111,7 @@ function main() {
|
||||
spacing: 60,
|
||||
...(depthMode === "radial" ? { baseRadius: 220 } : {}),
|
||||
},
|
||||
logicalEdges: activeLogical,
|
||||
}),
|
||||
});
|
||||
for (const [id, prev] of before) {
|
||||
|
||||
@@ -314,6 +314,11 @@ export interface GraphSnapshot {
|
||||
/** DERIVED untyped arrows: premise → imp → conclusion. A lossy image
|
||||
* of the stored chains — rebuilt after every change, never stored. */
|
||||
edges: GraphEdge[];
|
||||
/** DERIVED LOGICAL arrows: every chain contracted to direct premise→
|
||||
* conclusion over PROPOSITION ids only (the imp hop collapsed). The
|
||||
* depth pass consumes these — inference depth is a property of
|
||||
* propositions, never of the geometry point (owner, 2026-09-30). */
|
||||
logicalEdges: GraphEdge[];
|
||||
/** The STORED reasoning chains (merged by (premises, conclusion) at
|
||||
* read): the chain table's and the practice pipeline's input — they
|
||||
* read the truth, never the derived fan-out. */
|
||||
|
||||
+24
-3
@@ -56,7 +56,7 @@ import type {
|
||||
GraphSnapshot,
|
||||
} from "./contracts.ts";
|
||||
import { MIN_CLUSTER_SIZE } from "./clusters.ts";
|
||||
import { birthSessionOf, birthSessionPartition } from "./session-clusters.ts";
|
||||
import { attachImpToCommunities, birthSessionOf, birthSessionPartition } from "./session-clusters.ts";
|
||||
import { formatChainLine } from "./practice.ts";
|
||||
import type { ChatStore, GraphStore } from "./ports.ts";
|
||||
|
||||
@@ -316,14 +316,27 @@ export function getGraph(graphStore: Pick<GraphStore, "readNodes" | "readChains"
|
||||
* regular citizens (half link distance — layout.ts). Dangling chains
|
||||
* (a member node missing) drop here — the deletion cascades should
|
||||
* prevent them; this is the honest belt. */
|
||||
export function deriveView(graph: DerivedGraph): { nodes: GraphNode[]; edges: GraphEdge[] } {
|
||||
export function deriveView(graph: DerivedGraph): { nodes: GraphNode[]; edges: GraphEdge[]; logicalEdges: GraphEdge[] } {
|
||||
const names: Record<string, { name: string }> = {};
|
||||
for (const node of graph.nodes) names[node.id] = { name: node.name };
|
||||
const live = new Set(graph.nodes.map((node) => node.id));
|
||||
const impNodes: GraphNode[] = [];
|
||||
const edges: GraphEdge[] = [];
|
||||
// The LOGICAL edge set (owner, 2026-09-30): every chain contracted to
|
||||
// direct premise→conclusion arrows over PROPOSITION ids only —
|
||||
// what inference semantics (the depth pass) consumes. The drawn edges
|
||||
// are the canvas image; these are the meaning.
|
||||
const logicalEdges: GraphEdge[] = [];
|
||||
const logicalSeen = new Set<string>();
|
||||
const emitLogical = (from: string, to: string): void => {
|
||||
const key = from + "\u0000" + to;
|
||||
if (logicalSeen.has(key)) return;
|
||||
logicalSeen.add(key);
|
||||
logicalEdges.push({ from, to });
|
||||
};
|
||||
for (const chain of graph.chains) {
|
||||
if (!live.has(chain.conclusion) || !chain.premises.every((premise) => live.has(premise))) continue;
|
||||
for (const premise of chain.premises) emitLogical(premise, chain.conclusion);
|
||||
// Single premise: the argument IS a plain arrow — no geometry
|
||||
// point needed (the 1-in/1-out collapse; rendering arc, owner).
|
||||
if (chain.premises.length === 1) {
|
||||
@@ -341,7 +354,7 @@ export function deriveView(graph: DerivedGraph): { nodes: GraphNode[]; edges: Gr
|
||||
for (const premise of chain.premises) edges.push({ from: premise, to: chain.id });
|
||||
edges.push({ from: chain.id, to: chain.conclusion });
|
||||
}
|
||||
return { nodes: [...graph.nodes, ...impNodes], edges };
|
||||
return { nodes: [...graph.nodes, ...impNodes], edges, logicalEdges };
|
||||
}
|
||||
|
||||
// ─── the /api/graph payload ──────────────────────────────────────────────────
|
||||
@@ -411,6 +424,13 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
|
||||
label: sessions[community.id] ?? fileSources[community.id] ?? null,
|
||||
members: community.members,
|
||||
}));
|
||||
// IMP-NODE CLUSTER ASSIGNMENT (owner, 2026-09-30): majority among the
|
||||
// premises' birth sessions, ties to the chain's own birth — attached
|
||||
// AFTER the hull filter so a two-proposition community + its bundle
|
||||
// never resurrects a hull (the filter counts propositions). Both
|
||||
// cluster-membership consumers — the sim's centroid pull and the
|
||||
// canvas's region rasterization — read it through this one list.
|
||||
attachImpToCommunities(clusters, graph.nodes, graph.chains);
|
||||
|
||||
// Resting layout (Option A): the positions ledger, when the settle has
|
||||
// ever run. Absent on a fresh graph — the view falls back to its own
|
||||
@@ -421,6 +441,7 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
|
||||
nodes,
|
||||
propositions: graph.nodes,
|
||||
edges: view.edges,
|
||||
logicalEdges: view.logicalEdges,
|
||||
chains: graph.chains,
|
||||
sessions,
|
||||
fileSources,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { GraphChain, GraphNode, Position } from "./contracts.ts";
|
||||
import { incrementalLayout } from "./layout.ts";
|
||||
import { birthSessionPartition } from "./session-clusters.ts";
|
||||
import { attachImpToCommunities, birthSessionPartition } from "./session-clusters.ts";
|
||||
import { deriveView, getGraph } from "./graph.ts";
|
||||
|
||||
/**
|
||||
@@ -39,6 +39,15 @@ export function settleGraphLayout(graphStore: {
|
||||
// PROPOSITIONS ONLY (owner, 2026-09-30): implication nodes never
|
||||
// partition; the stored world clusters, its image follows.
|
||||
const communities = birthSessionPartition(graph.nodes);
|
||||
const next = incrementalLayout(view.nodes, view.edges, previous, { clusters: communities });
|
||||
// The SAME imp-node assignment the snapshot renders (majority among
|
||||
// premises' births — core/session-clusters.ts): the resting ledger and
|
||||
// the live sim must agree on where bundles belong, or the first live
|
||||
// tick would fight the settle. Unfiltered here (settle pulls every
|
||||
// community, hulls are a canvas-only concern).
|
||||
attachImpToCommunities(communities, graph.nodes, graph.chains);
|
||||
const next = incrementalLayout(view.nodes, view.edges, previous, {
|
||||
clusters: communities,
|
||||
logicalEdges: view.logicalEdges,
|
||||
});
|
||||
graphStore.writePositions(Object.fromEntries(next));
|
||||
}
|
||||
|
||||
+24
-3
@@ -177,6 +177,11 @@ export interface SimOptions {
|
||||
velocityDecay?: number;
|
||||
/** Inference-depth anchoring (see DepthAnchor). Off by default. */
|
||||
depthAnchor?: DepthAnchor;
|
||||
/** The LOGICAL edge list (proposition→conclusion, imp hops
|
||||
* contracted) for the depth machinery — orphan detection AND the
|
||||
* rank pass (owner, 2026-09-30). Absent = `edges` (legacy
|
||||
* behavior). Springs ALWAYS run over `edges` — the drawn arrows. */
|
||||
logicalEdges?: ReadonlyArray<LayoutEdge>;
|
||||
/** Cluster centroid pull (the "virtual node" without the node):
|
||||
* members of each cluster accelerate toward their own cluster's
|
||||
* centroid. Two O(n) passes per tick — cheaper than a real virtual
|
||||
@@ -288,11 +293,14 @@ export function createGraphSimulation(
|
||||
// depth force and keep the REGULAR view's forces instead (full center
|
||||
// gravity + full cluster pull, unprojected).
|
||||
const mode = options.depthAnchor?.mode ?? "off";
|
||||
// The depth machinery reads the LOGICAL edge list when supplied —
|
||||
// structure is inference over propositions, not the drawn geometry.
|
||||
const depthEdges = options.logicalEdges ?? edges;
|
||||
let orphan: Uint8Array | null = null;
|
||||
if (mode !== "off") {
|
||||
const connected = new Set<string>();
|
||||
const valid = new Set(nodes.map((n) => n.id));
|
||||
for (const e of edges) {
|
||||
for (const e of depthEdges) {
|
||||
// Self-loops and dangling endpoints are not a structure (same
|
||||
// guards as inferenceDepths).
|
||||
if (e.from === e.to || !valid.has(e.from) || !valid.has(e.to)) continue;
|
||||
@@ -301,7 +309,12 @@ export function createGraphSimulation(
|
||||
}
|
||||
orphan = new Uint8Array(nodes.length);
|
||||
nodes.forEach((n, i) => {
|
||||
if (!connected.has(n.id)) orphan![i] = 1;
|
||||
// IMP EXEMPTION joins the mask (owner, 2026-09-30): depth is a
|
||||
// property of inference over PROPOSITIONS; an implication node
|
||||
// is pure geometry — never pinned to a level, keeps the full
|
||||
// regular forces (gravity, cluster pull, springs), and settles
|
||||
// naturally between premise and conclusion levels.
|
||||
if (!connected.has(n.id) || n.id.startsWith(IMP_ID_PREFIX)) orphan![i] = 1;
|
||||
});
|
||||
}
|
||||
const g = options.gravity;
|
||||
@@ -402,7 +415,12 @@ export function createGraphSimulation(
|
||||
// organic sag.
|
||||
const da = options.depthAnchor;
|
||||
if (da && da.mode !== "off" && da.strength > 0) {
|
||||
const depths = inferenceDepths(nodes, edges);
|
||||
// Propositions only: imp ids are absent from the rank map by
|
||||
// construction (belt under the orphan-mask exemption).
|
||||
const depths = inferenceDepths(
|
||||
nodes.filter((n) => !n.id.startsWith(IMP_ID_PREFIX)),
|
||||
depthEdges,
|
||||
);
|
||||
const strength = da.strength;
|
||||
const spacing = da.spacing;
|
||||
const c = da.center ?? { x: 0, y: 0 };
|
||||
@@ -460,6 +478,8 @@ export interface IncrementalLayoutOptions {
|
||||
/** Inference-depth anchoring (see DepthAnchor). `center` defaults to
|
||||
* the anchored core's centroid. */
|
||||
depthAnchor?: DepthAnchor;
|
||||
/** The LOGICAL edge list for the depth pass (see SimOptions). */
|
||||
logicalEdges?: ReadonlyArray<LayoutEdge>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -598,6 +618,7 @@ export function incrementalLayout(
|
||||
options.depthAnchor && options.depthAnchor.mode !== "off"
|
||||
? { ...options.depthAnchor, center: options.depthAnchor.center ?? { x: cx, y: cy } }
|
||||
: options.depthAnchor,
|
||||
logicalEdges: options.logicalEdges,
|
||||
});
|
||||
const dMode = options.depthAnchor?.mode ?? "off";
|
||||
const dCenter = options.depthAnchor?.center ?? { x: cx, y: cy };
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
* core barrel — the shell reads clusters from the snapshot; only the
|
||||
* server-side derivations (snapshot, settle) and tests import this.
|
||||
*/
|
||||
import type { GraphNode } from "./contracts.ts";
|
||||
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
|
||||
@@ -47,7 +47,7 @@ export interface BirthSessionCluster {
|
||||
|
||||
/** 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: GraphNode): string | null {
|
||||
export function birthSessionOf(node: Pick<GraphNode, "provenance">): string | null {
|
||||
return node.provenance[0]?.chatId ?? null;
|
||||
}
|
||||
|
||||
@@ -73,4 +73,58 @@ export function birthSessionPartition(nodes: readonly GraphNode[]): BirthSession
|
||||
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();
|
||||
}
|
||||
|
||||
+4
-1
@@ -33,7 +33,10 @@ export function graphView(): GraphSnapshot {
|
||||
* honest, never guessed. A corrupt/empty graph is an empty list. */
|
||||
export function graphClusters(): GraphClusterGroup[] {
|
||||
const snap = graphSnapshot(stores);
|
||||
return birthSessionPartition(snap.nodes).map((group) => ({
|
||||
// PROPOSITIONS ONLY: snap.nodes is the canvas image (imp nodes ride
|
||||
// it); the picker offers propositions — an arg_ id in nodeIds would
|
||||
// silently vanish inside chainsForSelection and lie about nodeCount.
|
||||
return birthSessionPartition(snap.propositions).map((group) => ({
|
||||
key: group.id,
|
||||
kind: group.id in snap.sessions ? "chat" : "notes",
|
||||
label: snap.sessions[group.id] ?? snap.fileSources[group.id] ?? null,
|
||||
|
||||
@@ -1062,4 +1062,21 @@ describe("deriveView (the argument's geometry)", () => {
|
||||
assert.deepEqual(view.nodes.map((n) => n.id), ["a"]);
|
||||
assert.deepEqual(view.edges, []);
|
||||
});
|
||||
|
||||
it("LOGICAL edges: every chain contracts to direct premise→conclusion arrows (all chains, deduped)", () => {
|
||||
const view = deriveView({
|
||||
nodes: [prop("a"), prop("b"), prop("c")],
|
||||
chains: [chain("arg_ab", ["a", "b"], "c"), chain("arg_ac", ["a"], "c")],
|
||||
});
|
||||
// arg_ab: a→c AND b→c; arg_ac: a→c again → deduped to one arrow.
|
||||
assert.deepEqual(
|
||||
[...view.logicalEdges].sort((x, y) => (x.from < y.from ? -1 : x.from > y.from ? 1 : x.to < y.to ? -1 : 1)),
|
||||
[
|
||||
{ from: "a", to: "c" },
|
||||
{ from: "b", to: "c" },
|
||||
],
|
||||
);
|
||||
// Proposition ids only — no arg_ endpoint ever rides the logical set.
|
||||
assert.ok(view.logicalEdges.every((e) => !e.from.startsWith("arg_") && !e.to.startsWith("arg_")));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -199,6 +199,70 @@ describe("clusterPull (the centroid force)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("depths over the LOGICAL graph (physics arc, owner 2026-09-30)", () => {
|
||||
it("a multi-premise conclusion sits at premises'+1 — NOT +2 — over logical edges", () => {
|
||||
const props = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
||||
const logical = [
|
||||
{ from: "a", to: "c" },
|
||||
{ from: "b", to: "c" },
|
||||
];
|
||||
const depths = inferenceDepths(props, logical);
|
||||
assert.equal(depths.get("c"), 1, "one inference hop from BOTH premises — the imp point is not a hop");
|
||||
});
|
||||
|
||||
it("the imp node is ABSENT from the rank map (depth is a property of propositions)", () => {
|
||||
const nodes = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "arg_x" }];
|
||||
const logical = [
|
||||
{ from: "a", to: "c" },
|
||||
{ from: "b", to: "c" },
|
||||
];
|
||||
const depths = inferenceDepths(
|
||||
nodes.filter((n) => !n.id.startsWith("arg_")),
|
||||
logical,
|
||||
);
|
||||
assert.ok(!depths.has("arg_x"), "geometry points carry no inference rank");
|
||||
assert.equal(depths.get("c"), 1);
|
||||
});
|
||||
|
||||
it("the depth force EXEMPTS the imp node like an orphan (full gravity, never pinned to a row)", () => {
|
||||
const mk = (id: string, x: number, y: number) => ({ id, x, y });
|
||||
const sim = createGraphSimulation(
|
||||
[mk("a", 0, 0), mk("b", 40, 0), mk("c", 0, -480), mk("arg_x", 20, -100)],
|
||||
// Drawn edges: the imp fan (springs ride these).
|
||||
[
|
||||
{ from: "a", to: "arg_x" },
|
||||
{ from: "b", to: "arg_x" },
|
||||
{ from: "arg_x", to: "c" },
|
||||
],
|
||||
{
|
||||
// The logical edges: what depth consumes.
|
||||
logicalEdges: [
|
||||
{ from: "a", to: "c" },
|
||||
{ from: "b", to: "c" },
|
||||
],
|
||||
depthAnchor: { mode: "vertical", strength: 1, spacing: 500 },
|
||||
gravity: { x: 0, y: 0, strength: 0.1 },
|
||||
chargeStrength: 0,
|
||||
// Weak springs: the rows pin crisply while the exempt imp
|
||||
// node still floats (its signal is WHERE it isn't — on
|
||||
// no row).
|
||||
linkStrength: 0.05,
|
||||
},
|
||||
);
|
||||
for (let i = 0; i < 400; i++) sim.tick();
|
||||
const pos = new Map((sim.nodes() as unknown as { id: string; x: number; y: number }[]).map((n) => [n.id, n]));
|
||||
// Propositions pin to their rows: a/b at depth 0 (y≈0), c at
|
||||
// depth 1 (y≈-500 — targetY = baseY − depth·spacing).
|
||||
assert.ok(Math.abs(pos.get("a")!.y) < 50, `a on row 0 (got ${pos.get("a")!.y.toFixed(1)})`);
|
||||
assert.ok(Math.abs(pos.get("b")!.y) < 50, `b on row 0 (got ${pos.get("b")!.y.toFixed(1)})`);
|
||||
assert.ok(Math.abs(pos.get("c")!.y + 500) < 50, `c on row 1 (got ${pos.get("c")!.y.toFixed(1)})`);
|
||||
// The imp node is exempt: pulled by full gravity toward the
|
||||
// center, NOT stamped onto c's row (old behavior pinned it to
|
||||
// −500 as a depth-1 "node").
|
||||
assert.ok(Math.abs(pos.get("arg_x")!.y) < 250, `arg_x unpinned, floats between levels (got ${pos.get("arg_x")!.y.toFixed(1)})`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferenceDepths (SCC-safe ranking)", () => {
|
||||
const nodes = (ids: string[]) => ids.map((id) => ({ id }));
|
||||
|
||||
|
||||
@@ -19,11 +19,19 @@ import {
|
||||
graphSnapshot,
|
||||
type GraphDeps,
|
||||
} from "../src/core/graph.ts";
|
||||
import { birthSessionOf, birthSessionPartition } from "../src/core/session-clusters.ts";
|
||||
import {
|
||||
attachImpToCommunities,
|
||||
birthSessionOf,
|
||||
birthSessionPartition,
|
||||
impClusterKey,
|
||||
} from "../src/core/session-clusters.ts";
|
||||
|
||||
// ─── fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const pt = (chatId: string, messageId = "m1") => ({ chatId, messageId });
|
||||
/** attachImpToCommunities takes a structural Pick; full chains are
|
||||
* assignable but literals need the widening. */
|
||||
const attachable = (c: GraphChain): Pick<GraphChain, "id" | "premises" | "conclusion" | "provenance"> => c;
|
||||
const node = (id: string, name: string, provenance: Array<{ chatId: string; messageId: string }>): GraphNode => ({
|
||||
id,
|
||||
name,
|
||||
@@ -38,9 +46,10 @@ function fakeStores(
|
||||
over: {
|
||||
chats?: Array<Pick<ChatSummary, "id" | "title" | "createdAt" | "updatedAt">>;
|
||||
sources?: Array<Pick<NotesSource, "id" | "filePath" | "importedAt">>;
|
||||
chains?: GraphChain[];
|
||||
} = {},
|
||||
): GraphDeps {
|
||||
const chains: GraphChain[] = [];
|
||||
const chains: GraphChain[] = over.chains ?? [];
|
||||
const graphStore: GraphStore = {
|
||||
readNodes: () => nodes,
|
||||
readChains: () => chains,
|
||||
@@ -241,4 +250,106 @@ describe("graphSessionPropositions (the per-session view)", () => {
|
||||
{ nodeId: "b", name: "Beta" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── imp-node cluster assignment (physics arc, owner 2026-09-30) ─────────────
|
||||
|
||||
describe("impClusterKey (majority among PREMISES' births)", () => {
|
||||
const chainOf = (premises: string[], provenance: Array<{ chatId: string; messageId: string }> = []) =>
|
||||
({ id: "arg_t", premises, conclusion: "c", provenance, createdAt: "2026-09-30T00:00:00.000Z" }) as GraphChain;
|
||||
|
||||
it("unanimous premises → that session", () => {
|
||||
const key = impClusterKey(chainOf(["p1", "p2", "p3"]), () => "chatA");
|
||||
assert.equal(key, "chatA");
|
||||
});
|
||||
|
||||
it("cross-session majority wins (2 of 3 premises born in chatA)", () => {
|
||||
const births: Record<string, string | null> = { p1: "chatA", p2: "chatA", p3: "chatB" };
|
||||
const key = impClusterKey(chainOf(["p1", "p2", "p3"]), (id) => births[id] ?? null);
|
||||
assert.equal(key, "chatA");
|
||||
});
|
||||
|
||||
it("tie → the chain's OWN birth (the asserting session), not the conclusion's", () => {
|
||||
const births: Record<string, string | null> = { p1: "chatA", p2: "chatB", c: "chatC" };
|
||||
const key = impClusterKey(chainOf(["p1", "p2"], [pt("chatT")]), (id) => births[id] ?? null);
|
||||
assert.equal(key, "chatT", "the chain's first provenance point breaks the tie");
|
||||
});
|
||||
|
||||
it("no birth info at all → null is honest", () => {
|
||||
assert.equal(impClusterKey(chainOf(["p1", "p2"]), () => null), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachImpToCommunities (the shared seam for pull + hulls)", () => {
|
||||
it("a multi-premise live chain's imp id joins its home community, members stay sorted", () => {
|
||||
const props = [node("p1", "P1", [pt("chatA")]), node("p2", "P2", [pt("chatA")]), node("p3", "P3", [pt("chatA")])];
|
||||
const communities = birthSessionPartition(props);
|
||||
attachImpToCommunities(communities, props, [
|
||||
attachable({ id: "arg_t", premises: ["p1", "p2"], conclusion: "p3", provenance: [pt("chatA")], createdAt: "2026-09-30T00:00:00.000Z" }),
|
||||
]);
|
||||
assert.deepEqual(communities[0]!.members, ["arg_t", "p1", "p2", "p3"]);
|
||||
});
|
||||
|
||||
it("single-premise chains attach NOTHING (no imp node exists — the collapse)", () => {
|
||||
const props = [node("p1", "P1", [pt("chatA")]), node("p2", "P2", [pt("chatA")])];
|
||||
const communities = birthSessionPartition(props);
|
||||
attachImpToCommunities(communities, props, [
|
||||
attachable({ id: "arg_t", premises: ["p1"], conclusion: "p2", provenance: [pt("chatA")], createdAt: "2026-09-30T00:00:00.000Z" }),
|
||||
]);
|
||||
assert.deepEqual(communities[0]!.members, ["p1", "p2"]);
|
||||
});
|
||||
|
||||
it("dangling chains mint nothing; a home community ABSENT from the list stays unattached", () => {
|
||||
const props = [
|
||||
node("p1", "P1", [pt("chatA")]),
|
||||
node("p1b", "P1b", [pt("chatA")]),
|
||||
node("p1c", "P1c", [pt("chatA")]),
|
||||
];
|
||||
const elsewhere = [{ id: "chatB", members: ["q1"] }];
|
||||
attachImpToCommunities(
|
||||
elsewhere,
|
||||
props,
|
||||
[
|
||||
// dangling conclusion — no imp node is minted, nothing to attach
|
||||
attachable({ id: "arg_gone", premises: ["p1", "p1b"], conclusion: "ghost", provenance: [pt("chatA")], createdAt: "2026-09-30T00:00:00.000Z" }),
|
||||
// fully live, but its home community (chatA) is not in the
|
||||
// list — the post-hull-filter case: stays unclustered
|
||||
attachable({ id: "arg_home", premises: ["p1", "p1b"], conclusion: "p1c", provenance: [pt("chatA")], createdAt: "2026-09-30T00:00:00.000Z" }),
|
||||
],
|
||||
);
|
||||
assert.deepEqual(elsewhere[0]!.members, ["q1"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── the snapshot seam: imp ids ride cluster members to BOTH consumers ──────
|
||||
|
||||
describe("graphSnapshot imp-node cluster assignment (physics arc, 2026-09-30)", () => {
|
||||
const chats = [{ id: "chatA", title: "Session A", createdAt: "2026-09-30T00:00:00.000Z", updatedAt: "2026-09-30T00:00:00.000Z" }];
|
||||
const multi = (id: string, premises: string[], conclusion: string, chatId = "chatA"): GraphChain => ({
|
||||
id, premises, conclusion, provenance: [pt(chatId)], createdAt: "2026-09-30T00:00:00.000Z",
|
||||
});
|
||||
|
||||
it("a live multi-premise chain's imp id JOINS the cluster's members (pull + hull read this list)", () => {
|
||||
const snap = graphSnapshot(
|
||||
fakeStores(
|
||||
[node("p1", "P1", [pt("chatA")]), node("p2", "P2", [pt("chatA")]), node("p3", "P3", [pt("chatA")])],
|
||||
{ chats, chains: [multi("arg_t", ["p1", "p2"], "p3")] },
|
||||
),
|
||||
);
|
||||
assert.equal(snap.clusters.length, 1);
|
||||
assert.ok(snap.clusters[0]!.members.includes("arg_t"), "the imp node is a member — centroid pull AND hull rasterization consume members");
|
||||
});
|
||||
|
||||
it("the hull filter counts PROPOSITIONS — attaching an imp never resurrects a 2-member hull", () => {
|
||||
// p1,p2 born chatA; the conclusion born chatB. The chain is fully
|
||||
// live, its premises-majority home is chatA — a 2-proposition
|
||||
// community the hull filter drops; the imp id must NOT tip it to 3.
|
||||
const snap = graphSnapshot(
|
||||
fakeStores(
|
||||
[node("p1", "P1", [pt("chatA")]), node("p2", "P2", [pt("chatA")]), node("p3b", "P3b", [pt("chatB")])],
|
||||
{ chats, chains: [multi("arg_t", ["p1", "p2"], "p3b")] },
|
||||
),
|
||||
);
|
||||
assert.equal(snap.clusters.length, 0, "two propositions + a bundle is still not a hull");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user