Add a bounded relation graph and knowledge journeys.

CONTENT-WP-0004 keeps outgoing and incoming labels apart, limits the graph to two hops, and stores focus and detail in the URL.

Assistant: grok
Assistant-Session: 01a1531b-6698-7732-8587-f28ea604bcdf
This commit is contained in:
tegwick 2026-10-19 10:00:20 +02:00
parent 749752df4e
commit b5b6f6bc00
15 changed files with 1290 additions and 52 deletions

View file

@ -1,6 +1,7 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { columnDefs, HOME_COLUMNS, mapBrowserRow, type BrowserRow, type ColumnDef } from "./browser-row";
import { memberLinks, partitionIncident, readingPathCrumbs, type GraphEdge, type RelationLabels } from "./graph";
type KindRecord = { id: string; label: string; plural: string };
@ -13,6 +14,7 @@ type EntityRecord = {
tags?: string[];
sourceRefs?: string[];
claimRefs?: string[];
properties?: unknown;
};
type SourceRecord = { id: string; title: string; license: string };
@ -69,6 +71,22 @@ export type RelationView = {
otherHref: string | null;
};
export type EntityLink = {
id: string;
title: string;
href: string | null;
glossary: string | null;
preview: { title: string; summary: string } | null;
};
export type RelationItem = {
id: string;
predicate: string;
direction: "out" | "in";
wording: string;
other: EntityLink;
};
export type EntityView = {
id: string;
title: string;
@ -80,9 +98,38 @@ export type EntityView = {
kindId: string;
kindLabel: string;
kindHref: string;
graphHref: string;
glossary: string | null;
sources: { title: string; license: string }[];
claims: { key: string; status: string; value: string | null }[];
relations: RelationView[];
outgoing: RelationItem[];
incoming: RelationItem[];
relationRemainder: number;
collection: { title: string; members: EntityLink[] } | null;
related: EntityLink[];
readingPath: EntityLink[];
};
export type GraphNodeRecord = {
id: string;
title: string;
summary: string;
href: string;
};
export type GraphEdgeRecord = GraphEdge & {
forward: string;
inverse: string;
};
export type GraphDataset = {
dataspaceId: string;
dataspaceTitle: string;
nodes: GraphNodeRecord[];
edges: GraphEdgeRecord[];
labels: RelationLabels;
defaultFocusId: string;
};
type Loaded = {
@ -221,6 +268,42 @@ function hrefForEntity(loaded: Loaded, id: string): string | null {
return entityHref(loaded.snapshot.dataspace.id, entity.kindId, entity.id);
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asText(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value : null;
}
function asIdList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === "string" && item.length > 0);
}
function linkFor(loaded: Loaded, id: string): EntityLink {
const entity = loaded.byId.get(id);
if (!entity) return { id, title: id, href: null, glossary: null, preview: null };
const props = asRecord(entity.properties);
const preview = asRecord(props?.preview);
const previewTitle = asText(preview?.title);
const previewSummary = asText(preview?.summary);
return {
id: entity.id,
title: entity.title,
href: hrefForEntity(loaded, entity.id),
glossary: asText(props?.glossary),
preview: previewTitle && previewSummary ? { title: previewTitle, summary: previewSummary } : null,
};
}
function labelRecord(loaded: Loaded): RelationLabels {
const labels: Record<string, { forward: string; inverse: string }> = {};
for (const [predicate, wording] of loaded.labels) labels[predicate] = wording;
return labels;
}
export function findEntity(dataspaceId: string, kindId: string, slug: string): EntityView | null {
const loaded = loadAll().get(dataspaceId);
if (!loaded) return null;
@ -232,27 +315,34 @@ export function findEntity(dataspaceId: string, kindId: string, slug: string): E
if (matches.length !== 1) return null;
const entity = matches[0];
const sources = new Map((loaded.snapshot.sources ?? []).map((source) => [source.id, source]));
const relations: RelationView[] = [];
for (const relation of loaded.snapshot.relations ?? []) {
const wording = loaded.labels.get(relation.predicate);
if (relation.from === entity.id) {
const other = loaded.byId.get(relation.to);
relations.push({
wording: wording?.forward ?? relation.predicate,
direction: "out",
otherTitle: other?.title ?? relation.to,
otherHref: hrefForEntity(loaded, relation.to),
});
} else if (relation.to === entity.id) {
const other = loaded.byId.get(relation.from);
relations.push({
wording: wording?.inverse ?? relation.predicate,
direction: "in",
otherTitle: other?.title ?? relation.from,
otherHref: hrefForEntity(loaded, relation.from),
});
}
}
const edges = loaded.snapshot.relations ?? [];
const blocks = partitionIncident(entity.id, edges, labelRecord(loaded));
const toItem = (relation: (typeof blocks.outgoing)[number]): RelationItem => ({
id: relation.id,
predicate: relation.predicate,
direction: relation.direction,
wording: relation.wording,
other: linkFor(loaded, relation.otherId),
});
const outgoing = blocks.outgoing.map(toItem);
const incoming = blocks.incoming.map(toItem);
const relations: RelationView[] = [...outgoing, ...incoming].map((relation) => ({
wording: relation.wording,
direction: relation.direction,
otherTitle: relation.other.title,
otherHref: relation.other.href,
}));
const props = asRecord(entity.properties);
const collection = asRecord(props?.collection);
const collectionTitle = asText(collection?.title);
const resolve = (id: string) => {
const link = linkFor(loaded, id);
return { title: link.title, href: link.href };
};
const readingIds = asIdList(props?.readingPath);
const reading = readingPathCrumbs(readingIds, resolve).map((step) => linkFor(loaded, step.id));
const related = memberLinks(asIdList(props?.related), resolve).map((step) => linkFor(loaded, step.id));
const self = linkFor(loaded, entity.id);
return {
id: entity.id,
title: entity.title,
@ -264,6 +354,8 @@ export function findEntity(dataspaceId: string, kindId: string, slug: string): E
kindId: kind.id,
kindLabel: kind.label,
kindHref: kindHref(dataspaceId, kind.id),
graphHref: `/${dataspaceId}/graph/?${new URLSearchParams({ focus: entity.id }).toString()}`,
glossary: self.glossary,
sources: (entity.sourceRefs ?? []).map((id) => {
const source = sources.get(id);
return { title: source?.title ?? id, license: source?.license ?? "" };
@ -272,5 +364,44 @@ export function findEntity(dataspaceId: string, kindId: string, slug: string): E
.filter((claim) => claim.subjectId === entity.id)
.map((claim) => ({ key: claim.key, status: claim.status, value: claim.value ?? null })),
relations,
outgoing,
incoming,
relationRemainder: blocks.remainder,
collection: collectionTitle
? { title: collectionTitle, members: memberLinks(asIdList(collection?.members), resolve).map((step) => linkFor(loaded, step.id)) }
: null,
related,
readingPath: reading,
};
}
export function findGraph(dataspaceId: string): GraphDataset | null {
const loaded = loadAll().get(dataspaceId);
if (!loaded || loaded.snapshot.entities.length === 0) return null;
const labels = labelRecord(loaded);
const nodes = loaded.snapshot.entities.map((entity) => ({
id: entity.id,
title: entity.title,
summary: entity.summary,
href: entityHref(loaded.snapshot.dataspace.id, entity.kindId, entity.id),
}));
const preferred = nodes.find((node) => node.id.endsWith(":feedback"));
return {
dataspaceId,
dataspaceTitle: loaded.snapshot.dataspace.title,
nodes,
edges: (loaded.snapshot.relations ?? []).map((relation) => {
const pair = labels[relation.predicate];
return {
id: relation.id,
predicate: relation.predicate,
from: relation.from,
to: relation.to,
forward: pair?.forward || relation.predicate,
inverse: pair?.inverse || relation.predicate,
};
}),
labels,
defaultFocusId: preferred?.id ?? nodes[0].id,
};
}

202
ui/lib/graph.test.ts Normal file
View file

@ -0,0 +1,202 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { findEntity, findGraph } from "./catalog";
import {
HOP_BOUND,
exportAdjacency,
memberLinks,
parseGraphSearch,
partitionIncident,
readingPathCrumbs,
selectNeighborhood,
serializeGraphSearch,
type GraphModel,
} from "./graph";
const root = join(process.cwd(), "../examples/dataspaces/systems-thinking");
function loadJson(path: string): {
entities: { id: string; properties?: { glossary?: string; preview?: { title: string; summary: string }; readingPath?: string[] } }[];
relations: { id: string; predicate: string; from: string; to: string }[];
} {
return JSON.parse(readFileSync(path, "utf8"));
}
describe("incident relations", () => {
it("splits feedback into forward and inverse blocks", () => {
const snapshot = loadJson(join(root, "snapshot.json"));
const manifest = JSON.parse(readFileSync(join(root, "manifest.json"), "utf8")) as {
relationLabels: Record<string, { forward: string; inverse: string }>;
};
const feedback = snapshot.entities.find((entity) => entity.id.endsWith(":feedback"));
expect(feedback).toBeTruthy();
const blocks = partitionIncident(feedback!.id, snapshot.relations, manifest.relationLabels);
const incident = snapshot.relations.filter((relation) => relation.from === feedback!.id || relation.to === feedback!.id);
const listed = [...blocks.outgoing, ...blocks.incoming];
expect(blocks.remainder).toBe(0);
expect(listed.map((relation) => relation.id).sort()).toEqual(incident.map((relation) => relation.id).sort());
expect(new Set(listed.map((relation) => relation.direction)).size).toBe(2);
const represented = blocks.outgoing.find((relation) => relation.predicate === "represented-by");
expect(represented?.wording).toBe(manifest.relationLabels["represented-by"].forward);
expect(represented?.wording).toBe("Represented by");
const modeled = blocks.incoming.find((relation) => relation.predicate === "models");
expect(modeled?.wording).toBe(manifest.relationLabels.models.inverse);
expect(modeled?.wording).not.toBe(manifest.relationLabels.models.forward);
for (const relation of listed) {
const source = incident.find((item) => item.id === relation.id);
expect(source).toBeTruthy();
expect(relation.direction === "out" ? source!.from : source!.to).toBe(feedback!.id);
}
});
it("reports a remainder when incident relations are not all listed", () => {
const snapshot = loadJson(join(root, "snapshot.json"));
const manifest = JSON.parse(readFileSync(join(root, "manifest.json"), "utf8")) as {
relationLabels: Record<string, { forward: string; inverse: string }>;
};
const feedback = snapshot.entities.find((entity) => entity.id.endsWith(":feedback"))!;
const incident = snapshot.relations.filter((relation) => relation.from === feedback.id || relation.to === feedback.id);
const blocks = partitionIncident(feedback.id, snapshot.relations, manifest.relationLabels, 1);
expect(blocks.outgoing.length + blocks.incoming.length).toBe(1);
expect(blocks.remainder).toBe(incident.length - 1);
expect(blocks.remainder).toBeGreaterThan(0);
});
it("keeps a self-loop in one block", () => {
const blocks = partitionIncident("a", [{ id: "loop", predicate: "related-to", from: "a", to: "a" }], {
"related-to": { forward: "Related to", inverse: "Related from" },
});
expect(blocks.outgoing.map((relation) => relation.id)).toEqual(["loop"]);
expect(blocks.incoming).toEqual([]);
expect(blocks.remainder).toBe(0);
});
});
describe("neighborhood", () => {
it("stops a cycle at the hop bound", () => {
const model: GraphModel = {
nodes: [
{ id: "a", title: "A" },
{ id: "b", title: "B" },
{ id: "c", title: "C" },
{ id: "d", title: "D" },
{ id: "e", title: "E" },
],
edges: [
{ id: "ab", predicate: "p", from: "a", to: "b" },
{ id: "bc", predicate: "p", from: "b", to: "c" },
{ id: "ca", predicate: "p", from: "c", to: "a" },
{ id: "cd", predicate: "p", from: "c", to: "d" },
{ id: "de", predicate: "p", from: "d", to: "e" },
],
};
const bounded = selectNeighborhood(model, "a", 100);
expect(bounded.hops).toBe(HOP_BOUND);
expect(bounded.nodeIds).not.toContain("e");
expect(bounded.edges.map((edge) => edge.id)).not.toContain("de");
expect(bounded.excludedCount).toBe(model.edges.length - bounded.edges.length);
expect(bounded.excludedCount).toBeGreaterThan(0);
const again = selectNeighborhood(model, "a", HOP_BOUND);
expect(again.nodeIds).toEqual(bounded.nodeIds);
expect(again.edges).toEqual(bounded.edges);
});
it("keeps parallel edges and both parents", () => {
const model: GraphModel = {
nodes: [
{ id: "a", title: "A" },
{ id: "b", title: "B" },
{ id: "c", title: "C" },
],
edges: [
{ id: "e1", predicate: "p", from: "a", to: "b" },
{ id: "e2", predicate: "q", from: "a", to: "b" },
{ id: "parent-a", predicate: "parent", from: "a", to: "c" },
{ id: "parent-b", predicate: "parent", from: "b", to: "c" },
],
};
const fromA = selectNeighborhood(model, "a", 1);
const exported = exportAdjacency(fromA);
const parallel = exported.edges.filter((edge) => edge.from === "a" && edge.to === "b");
expect(parallel.map((edge) => edge.id).sort()).toEqual(["e1", "e2"]);
expect(parallel.map((edge) => edge.predicate).sort()).toEqual(["p", "q"]);
expect(new Set(parallel.map((edge) => edge.direction))).toEqual(new Set(["out"]));
const fromC = selectNeighborhood(model, "c", 1);
const parents = exportAdjacency(fromC).edges.filter((edge) => edge.predicate === "parent");
expect(parents.map((edge) => edge.id).sort()).toEqual(["parent-a", "parent-b"]);
expect(fromC.nodeIds).toEqual(expect.arrayContaining(["a", "b", "c"]));
});
it("keeps a disconnected node addressable", () => {
const model: GraphModel = {
nodes: [
{ id: "a", title: "A" },
{ id: "b", title: "B" },
{ id: "solo", title: "Solo" },
],
edges: [{ id: "ab", predicate: "p", from: "a", to: "b" }],
};
expect(model.nodes.map((node) => node.id)).toContain("solo");
const alone = selectNeighborhood(model, "solo", 2);
expect(alone.nodeIds).toEqual(["solo"]);
expect(alone.edges).toEqual([]);
expect(alone.excludedCount).toBe(model.edges.length);
const connected = selectNeighborhood(model, "a", 2);
expect(connected.nodeIds).not.toContain("solo");
expect(connected.excludedCount).toBe(0);
});
it("keeps a synthetic graph inside the hop bound and counts the rest", () => {
const model = JSON.parse(readFileSync(join(process.cwd(), "fixtures/large-graph.json"), "utf8")) as GraphModel;
expect(model.edges.length).toBeGreaterThan(HOP_BOUND);
const one = selectNeighborhood(model, "n0", 1);
const two = selectNeighborhood(model, "n0", HOP_BOUND);
const forced = selectNeighborhood(model, "n0", 50);
expect(two.hops).toBe(HOP_BOUND);
expect(forced).toEqual(two);
expect(two.nodeIds.length).toBeGreaterThan(one.nodeIds.length);
expect(two.nodeIds.length).toBeLessThan(model.nodes.length);
expect(two.nodeIds).toContain("n0");
expect(two.nodeIds).not.toContain("n8");
const inside = new Set(two.nodeIds);
const omitted = model.edges.filter((edge) => !inside.has(edge.from) || !inside.has(edge.to));
expect(two.excludedCount).toBe(omitted.length);
expect(two.edges.length + two.excludedCount).toBe(model.edges.length);
expect(two.excludedCount).toBeGreaterThan(0);
const exported = exportAdjacency(two);
expect(exported.edges.map((edge) => edge.id)).toEqual(two.edges.map((edge) => edge.id));
expect(exported.edges.every((edge) => edge.direction === "out" && edge.predicate.length > 0)).toBe(true);
const solo = selectNeighborhood(model, "solo", 2);
expect(model.nodes.some((node) => node.id === "solo")).toBe(true);
expect(solo.nodeIds).toEqual(["solo"]);
expect(solo.excludedCount).toBe(model.edges.length);
});
});
describe("journeys", () => {
it("keeps reading-path order and collection members", () => {
const steps = readingPathCrumbs(["boundary", "feedback", "diagram"], (id) => ({ title: id, href: `/${id}/` }));
expect(steps.map((step) => step.id)).toEqual(["boundary", "feedback", "diagram"]);
expect(steps[0]?.href).toBe("/boundary/");
const members = memberLinks(["left", "right"], (id) => ({ title: id.toUpperCase(), href: `/${id}/` }));
expect(members.map((member) => member.title)).toEqual(["LEFT", "RIGHT"]);
const feedback = findEntity("systems-thinking", "concept", "feedback");
const snapshot = loadJson(join(root, "snapshot.json"));
const declared = snapshot.entities.find((entity) => entity.id.endsWith(":feedback"))?.properties?.readingPath ?? [];
expect(feedback?.readingPath.map((step) => step.id)).toEqual(declared);
expect(feedback?.collection?.members.length).toBeGreaterThan(1);
expect(feedback?.related.length).toBeGreaterThan(0);
});
it("round-trips focus and detail in the query", () => {
const graph = findGraph("systems-thinking");
expect(graph).not.toBeNull();
const ids = graph!.nodes.map((node) => node.id);
const focus = ids.find((id) => id.endsWith(":feedback")) ?? ids[0];
const detail = ids.find((id) => id.endsWith(":causal-loop")) ?? ids[1];
const search = serializeGraphSearch({ focus, detail, hops: 1 });
expect(parseGraphSearch(search, ids, graph!.defaultFocusId)).toEqual({ focus, detail, hops: 1 });
expect(parseGraphSearch("", ids, graph!.defaultFocusId).focus).toBe(graph!.defaultFocusId);
});
});

234
ui/lib/graph.ts Normal file
View file

@ -0,0 +1,234 @@
/**
* Neighborhood, incident relations, and reading order for the relation explorer.
* Hop distance is capped at HOP_BOUND. Domain wording stays in the manifest.
*/
export const HOP_BOUND = 2;
export type GraphNode = {
id: string;
title: string;
};
export type GraphEdge = {
id: string;
predicate: string;
from: string;
to: string;
};
export type GraphModel = {
nodes: readonly GraphNode[];
edges: readonly GraphEdge[];
};
export type RelationLabels = Readonly<Record<string, { forward?: string; inverse?: string }>>;
export type IncidentRelation = {
id: string;
predicate: string;
direction: "out" | "in";
wording: string;
otherId: string;
};
export type RelationBlocks = {
outgoing: IncidentRelation[];
incoming: IncidentRelation[];
remainder: number;
};
export type Neighborhood = {
focusId: string;
hops: 1 | 2;
nodeIds: string[];
edges: GraphEdge[];
excludedCount: number;
};
export type AdjacencyEdge = {
id: string;
predicate: string;
from: string;
to: string;
direction: "out";
};
export type AdjacencyExport = {
nodes: string[];
edges: AdjacencyEdge[];
};
export type GraphLocation = {
focus: string;
detail: string | null;
hops: 1 | 2;
};
export type GraphHistory = {
read: () => string;
push: (search: string) => void;
subscribe: (listener: () => void) => () => void;
};
function clampHops(hops: number): 1 | 2 {
return hops <= 1 ? 1 : HOP_BOUND;
}
/** Undirected distance from the focus, capped at two hops. Edge direction is kept. */
export function selectNeighborhood(model: GraphModel, focusId: string, hops: number): Neighborhood {
const maxHops = clampHops(hops);
const known = new Set(model.nodes.map((node) => node.id));
if (!known.has(focusId)) {
return { focusId, hops: maxHops, nodeIds: [], edges: [], excludedCount: model.edges.length };
}
const links = new Map<string, { other: string }[]>();
for (const node of model.nodes) links.set(node.id, []);
for (const edge of model.edges) {
if (!known.has(edge.from) || !known.has(edge.to)) continue;
links.get(edge.from)?.push({ other: edge.to });
if (edge.to !== edge.from) links.get(edge.to)?.push({ other: edge.from });
}
const distance = new Map<string, number>([[focusId, 0]]);
const queue = [focusId];
while (queue.length > 0) {
const current = queue.shift();
if (current === undefined) break;
const currentDistance = distance.get(current) ?? 0;
if (currentDistance >= maxHops) continue;
for (const link of links.get(current) ?? []) {
if (distance.has(link.other)) continue;
distance.set(link.other, currentDistance + 1);
queue.push(link.other);
}
}
const nodeIds = [...distance.keys()];
const inside = new Set(nodeIds);
const edges = model.edges.filter((edge) => inside.has(edge.from) && inside.has(edge.to));
return {
focusId,
hops: maxHops,
nodeIds,
edges,
excludedCount: model.edges.length - edges.length,
};
}
/** One record per in-scope edge. Parallel edges stay separate and keep their direction. */
export function exportAdjacency(neighborhood: Neighborhood): AdjacencyExport {
return {
nodes: [...neighborhood.nodeIds],
edges: neighborhood.edges.map((edge) => ({
id: edge.id,
predicate: edge.predicate,
from: edge.from,
to: edge.to,
direction: "out",
})),
};
}
function wordingFor(labels: RelationLabels, predicate: string, direction: "out" | "in"): string {
const pair = labels[predicate];
if (direction === "out") return pair?.forward || predicate;
return pair?.inverse || predicate;
}
/**
* Place every relation that touches entityId in exactly one block.
* A self-loop is outgoing. `limit` lists only the first incident relations
* and reports the rest as `remainder`.
*/
export function partitionIncident(
entityId: string,
edges: readonly GraphEdge[],
labels: RelationLabels,
limit?: number,
): RelationBlocks {
const incident: IncidentRelation[] = [];
for (const edge of edges) {
if (edge.from === entityId) {
incident.push({
id: edge.id,
predicate: edge.predicate,
direction: "out",
wording: wordingFor(labels, edge.predicate, "out"),
otherId: edge.to,
});
} else if (edge.to === entityId) {
incident.push({
id: edge.id,
predicate: edge.predicate,
direction: "in",
wording: wordingFor(labels, edge.predicate, "in"),
otherId: edge.from,
});
}
}
const capped = limit != null && limit < incident.length ? incident.slice(0, Math.max(0, limit)) : incident;
const listed = new Set(capped.map((item) => item.id));
return {
outgoing: capped.filter((item) => item.direction === "out"),
incoming: capped.filter((item) => item.direction === "in"),
remainder: incident.length - capped.length,
};
}
export type ResolvedStep = {
id: string;
title: string;
href: string | null;
};
/** Keep declared path order. An unknown id stays visible as its own id. */
export function readingPathCrumbs(
path: readonly string[],
resolve: (id: string) => { title: string; href: string | null } | null,
): ResolvedStep[] {
return path.map((id) => {
const found = resolve(id);
return found ? { id, title: found.title, href: found.href } : { id, title: id, href: null };
});
}
export function memberLinks(
memberIds: readonly string[],
resolve: (id: string) => { title: string; href: string | null } | null,
): ResolvedStep[] {
return readingPathCrumbs(memberIds, resolve);
}
export function parseGraphSearch(search: string, knownIds: readonly string[], fallbackId: string): GraphLocation {
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
const known = new Set(knownIds);
const requested = params.get("focus");
const detail = params.get("detail");
const hops = params.get("hops") === "1" ? 1 : HOP_BOUND;
return {
focus: requested && known.has(requested) ? requested : fallbackId,
detail: detail && known.has(detail) ? detail : null,
hops,
};
}
export function serializeGraphSearch(location: GraphLocation): string {
const params = new URLSearchParams();
params.set("focus", location.focus);
if (location.detail) params.set("detail", location.detail);
if (location.hops === 1) params.set("hops", "1");
return params.toString();
}
export function browserGraphHistory(): GraphHistory {
return {
read: () => (typeof window === "undefined" ? "" : window.location.search),
push: (search) => {
const path = window.location.pathname;
window.history.pushState(null, "", search ? `${path}?${search}` : path);
},
subscribe: (listener) => {
window.addEventListener("popstate", listener);
return () => window.removeEventListener("popstate", listener);
},
};
}