Skip to content

Commit f2653ca

Browse files
committed
feat(ontology): GPU-resident semantic force system (PRD-018)
Turn OWL axioms into GPU physics forces so the 3D layout encodes ontology semantics: SubClassOf/hasPart/partOf -> Attract, EquivalentClass/sameAs -> Colocate, DisjointWith -> Separate. Whelk EL reasoner materialises inferred axioms; mapper emits 18,933 live-kernel constraints dispatched to CUDA via the reused ConstraintData[] buffer (ADR-098, zero SimParams ABI change). Deletes the redundant ontology_constraints.cu kernel and unified_gpu_compute/ontology.rs. Force boundaries tuned so the layout is legible rather than collapsed into one blob: Colocate rest 2->10, SubClass rest 60->90, Disjoint min-dist 200->350, Colocate weight 0.95->0.85. Global strength defaults to 0.6 with a reversible constraint_buffer_base; the weights slider now re-scales the base (count preserved) instead of clobbering the GPU buffer to 0. Client: new Ontology Forces sub-tab (live readout, enable/disable, strength slider -> PUT /ontology-physics/weights, Re-sync -> POST /admin/sync), inferred edge rendering, SPARQL console, and System Status ontology rigour fields. Adds canonical IRI resolver, vocab registry, SHACL gate, and SPARQL migrations. Co-Authored-By: jjohare <github@thedreamlab.uk>
1 parent 419d7f8 commit f2653ca

72 files changed

Lines changed: 6575 additions & 1643 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/src/features/graph/components/GraphManager.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../../store/settingsStore'
88
import { BinaryNodeData, getActualNodeId } from '../../../types/binaryProtocol'
99
import { GemNodes, GemNodesHandle } from './GemNodes'
1010
import { GlassEdges, GlassEdgesHandle } from './GlassEdges'
11+
import { InferredEdges } from './InferredEdges'
1112
import { KnowledgeRings } from './KnowledgeRings'
1213
import { ClusterHulls } from './ClusterHulls'
1314
import { useGraphEventHandlers } from '../hooks/useGraphEventHandlers'
@@ -560,6 +561,14 @@ const GraphManager: React.FC<GraphManagerProps> = ({ onDragStateChange }) => {
560561
colorOverride={settings?.visualisation?.interaction?.selectionHighlightColor || '#00FFFF'}
561562
/>
562563

564+
{/* Inferred-graph edges (urn:ngm:graph:ontology:inferred) rendered in a
565+
distinct dashed-amber style, gated by the InferencePanel toggle.
566+
Additive overlay — does not touch the GlassEdges instanced pipeline. */}
567+
<InferredEdges
568+
nodePositionsRef={nodePositionsRef}
569+
nodeIdToIndexMap={nodeIdToIndexMap}
570+
/>
571+
563572
<KnowledgeRings
564573
nodes={knowledgeNodes}
565574
perNodeVisualModeMap={perNodeVisualModeMap}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* InferredEdges — R3F overlay that renders edges from the inferred named graph
3+
* (`urn:ngm:graph:ontology:inferred`) in a visually-distinct style: dashed,
4+
* amber. Mirrors ontosphere's amber-dashed convention (PRD-018 WS-2, ADR-099 D4).
5+
*
6+
* Why a separate overlay (reuse-first, GPU-only-solving compliant):
7+
* - It does NOT modify the optimised GlassEdges instanced pipeline or the
8+
* GraphManager per-frame edge hot loop — zero risk to the asserted-edge path.
9+
* - It does NO solving/layout. It reads node positions from the SAB-backed
10+
* `nodePositionsRef` (the same buffer GlassEdges/labels read) and draws line
11+
* segments between the inferred (source → target) node pairs each frame.
12+
* - Gated by the `showInferred` toggle. Empty inferred set → renders nothing.
13+
*
14+
* Positions: `nodePositionsRef.current` is a Float32Array laid out [x,y,z] per
15+
* node, indexed by the node's render index via `nodeIdToIndexMap`.
16+
*/
17+
18+
import React, { useMemo, useRef, useEffect } from 'react';
19+
import { useFrame } from '@react-three/fiber';
20+
import * as THREE from 'three';
21+
import { useInferredEdgesStore } from '../../ontology/store/useInferredEdgesStore';
22+
23+
/** Default differentiated style — amber, dashed. Matches ADR-099 D4 convention. */
24+
const INFERRED_COLOR = 0xffb000; // amber
25+
const DASH_SIZE = 1.4;
26+
const GAP_SIZE = 0.9;
27+
const LINE_OPACITY = 0.85;
28+
29+
interface InferredEdgesProps {
30+
/** SAB-backed node positions, [x,y,z] per node, shared with GlassEdges. */
31+
nodePositionsRef: React.MutableRefObject<Float32Array | null>;
32+
/** Map from string node id to its index in the position buffer. */
33+
nodeIdToIndexMap: Map<string, number>;
34+
}
35+
36+
export const InferredEdges: React.FC<InferredEdgesProps> = ({
37+
nodePositionsRef,
38+
nodeIdToIndexMap,
39+
}) => {
40+
const showInferred = useInferredEdgesStore((s) => s.showInferred);
41+
const inferredEdges = useInferredEdgesStore((s) => s.inferredEdges);
42+
43+
// Resolve each inferred edge to a (srcIndex, tgtIndex) pair once per data
44+
// change. Edges whose endpoints aren't in the current render set are dropped.
45+
const resolved = useMemo(() => {
46+
const pairs: Array<[number, number]> = [];
47+
for (const e of inferredEdges) {
48+
const si = nodeIdToIndexMap.get(String(e.sourceId));
49+
const ti = nodeIdToIndexMap.get(String(e.targetId));
50+
if (si !== undefined && ti !== undefined) pairs.push([si, ti]);
51+
}
52+
return pairs;
53+
}, [inferredEdges, nodeIdToIndexMap]);
54+
55+
// Pre-allocated geometry sized to the resolved edge count (2 verts/edge).
56+
const geometry = useMemo(() => {
57+
const geo = new THREE.BufferGeometry();
58+
const positions = new Float32Array(Math.max(resolved.length, 1) * 2 * 3);
59+
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
60+
return geo;
61+
}, [resolved.length]);
62+
63+
const material = useMemo(
64+
() =>
65+
new THREE.LineDashedMaterial({
66+
color: INFERRED_COLOR,
67+
dashSize: DASH_SIZE,
68+
gapSize: GAP_SIZE,
69+
transparent: true,
70+
opacity: LINE_OPACITY,
71+
depthWrite: false,
72+
}),
73+
[],
74+
);
75+
76+
const linesRef = useRef<THREE.LineSegments | null>(null);
77+
78+
// Dispose GPU resources on unmount / geometry swap.
79+
useEffect(() => {
80+
return () => {
81+
geometry.dispose();
82+
};
83+
}, [geometry]);
84+
useEffect(() => {
85+
return () => {
86+
material.dispose();
87+
};
88+
}, [material]);
89+
90+
// Per-frame: pull current positions from the SAB and update the line buffer.
91+
useFrame(() => {
92+
if (!showInferred || resolved.length === 0) {
93+
if (linesRef.current) linesRef.current.visible = false;
94+
return;
95+
}
96+
const positions = nodePositionsRef.current;
97+
if (!positions) return;
98+
99+
const attr = geometry.getAttribute('position') as THREE.BufferAttribute;
100+
const arr = attr.array as Float32Array;
101+
let w = 0;
102+
for (let i = 0; i < resolved.length; i++) {
103+
const [si, ti] = resolved[i];
104+
const so = si * 3;
105+
const to = ti * 3;
106+
arr[w++] = positions[so];
107+
arr[w++] = positions[so + 1];
108+
arr[w++] = positions[so + 2];
109+
arr[w++] = positions[to];
110+
arr[w++] = positions[to + 1];
111+
arr[w++] = positions[to + 2];
112+
}
113+
attr.needsUpdate = true;
114+
geometry.setDrawRange(0, resolved.length * 2);
115+
if (linesRef.current) {
116+
linesRef.current.visible = true;
117+
// Dashes require per-vertex line distances; recompute as positions move.
118+
linesRef.current.computeLineDistances();
119+
}
120+
});
121+
122+
if (resolved.length === 0) return null;
123+
124+
return (
125+
<lineSegments ref={linesRef} frustumCulled={false}>
126+
<primitive object={geometry} attach="geometry" />
127+
<primitive object={material} attach="material" />
128+
</lineSegments>
129+
);
130+
};
131+
132+
export default InferredEdges;

client/src/features/graph/hooks/useGraphFiltering.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@
99
* - nodeIdToIndexMap: O(1) lookup from string node ID to its index in graphData.nodes
1010
* - filteredEdges: edges whose both endpoints survive filtering (same as graphData.edges
1111
* today since edge filtering is position-based, but provided for future use)
12+
*
13+
* Population scope (PRD-018 WS-4): this hook filters by quality/authority/
14+
* linked-page/degree — NOT by population type. Restricting the rendered graph to
15+
* a single population (knowledge | ontology | agent) is done SERVER-SIDE via
16+
* `graphDataManager.setGraphTypeFilter(...)` → `?graph_type=` so the whole graph
17+
* is no longer transferred-then-filtered. The two layers are orthogonal: the
18+
* server scopes *which population* is fetched; this hook scopes *which of those
19+
* fetched nodes* pass quality/visibility thresholds.
1220
*/
1321

1422
import { useMemo, useEffect } from 'react';

client/src/features/graph/managers/dataManager/restClient.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,47 @@ interface RawGraphResponse {
1414
settlementState?: { isSettled: boolean; stableFrameCount: number; kineticEnergy: number };
1515
}
1616

17+
/**
18+
* Server-side graph-type filter (PRD-018 WS-4). Sent as `?graph_type=` so the
19+
* backend returns only the requested population instead of the whole graph
20+
* (which the client then filtered locally — the transfer this eliminates).
21+
* `null` / `'all'` → no filter param → server returns the full graph.
22+
*/
23+
export type GraphTypeFilter = 'knowledge' | 'ontology' | 'agent' | 'all' | null;
24+
1725
/**
1826
* Fetch raw graph data from the backend REST API with up to `maxRetries`
1927
* attempts and exponential back-off. Returns a validated `GraphData` object
2028
* with string-coerced node/edge IDs and enriched positions.
29+
*
30+
* @param graphType Multi-graph identity (`logseq`/`visionclaw`) — diagnostic only.
31+
* @param graphTypeFilter Server-side population filter → `?graph_type=`. When
32+
* `null`/`'all'` the whole graph is requested (back-compat default).
2133
*/
22-
export async function fetchGraphData(graphType: string): Promise<GraphData> {
34+
export async function fetchGraphData(
35+
graphType: string,
36+
graphTypeFilter: GraphTypeFilter = null,
37+
): Promise<GraphData> {
2338
const maxRetries = 3;
2439
const initialDelay = 500;
2540

41+
// Build the request URL: only append ?graph_type= for a concrete population.
42+
const requestUrl =
43+
graphTypeFilter && graphTypeFilter !== 'all'
44+
? `/graph/data?graph_type=${encodeURIComponent(graphTypeFilter)}`
45+
: '/graph/data';
46+
2647
for (let attempt = 1; attempt <= maxRetries; attempt++) {
2748
try {
2849
if (debugState.isEnabled()) {
29-
logger.info(`Fetching initial ${graphType} graph data (attempt ${attempt}/${maxRetries})`);
50+
logger.info(
51+
`Fetching initial ${graphType} graph data` +
52+
(graphTypeFilter && graphTypeFilter !== 'all' ? ` (graph_type=${graphTypeFilter})` : '') +
53+
` (attempt ${attempt}/${maxRetries})`,
54+
);
3055
}
3156

32-
const response = await unifiedApiClient.get('/graph/data', { timeout: 10000 });
57+
const response = await unifiedApiClient.get(requestUrl, { timeout: 10000 });
3358

3459
const responseData: RawGraphResponse = response.data.data || response.data;
3560

@@ -141,9 +166,14 @@ export function scheduleEmptyDataRetry(
141166
existingTimerRef: number | null,
142167
onSuccess: () => Promise<unknown>,
143168
onReschedule: (handle: number) => void,
169+
graphTypeFilter: GraphTypeFilter = null,
144170
): void {
145171
const MAX_ATTEMPTS = 20;
146172
const INTERVAL_MS = 15_000;
173+
const retryUrl =
174+
graphTypeFilter && graphTypeFilter !== 'all'
175+
? `/graph/data?graph_type=${encodeURIComponent(graphTypeFilter)}`
176+
: '/graph/data';
147177

148178
if (attempt > MAX_ATTEMPTS) {
149179
logger.warn(`T6 empty-data retry: reached ${MAX_ATTEMPTS} attempts (${MAX_ATTEMPTS * INTERVAL_MS / 1000}s). Giving up.`);
@@ -157,18 +187,18 @@ export function scheduleEmptyDataRetry(
157187
const handle = window.setTimeout(async () => {
158188
console.info(`[GraphDataManager] T6 empty-data retry attempt ${attempt}/${MAX_ATTEMPTS}`);
159189
try {
160-
const response = await unifiedApiClient.get('/graph/data', { timeout: 10000 });
190+
const response = await unifiedApiClient.get(retryUrl, { timeout: 10000 });
161191
const responseData = response.data.data || response.data;
162192
const nodes = Array.isArray(responseData?.nodes) ? responseData.nodes : [];
163193
if (nodes.length > 0) {
164194
console.info(`[GraphDataManager] T6 empty-data retry: received ${nodes.length} nodes on attempt ${attempt}. Triggering full load.`);
165195
await onSuccess();
166196
} else {
167-
scheduleEmptyDataRetry(attempt + 1, null, onSuccess, onReschedule);
197+
scheduleEmptyDataRetry(attempt + 1, null, onSuccess, onReschedule, graphTypeFilter);
168198
}
169199
} catch (err) {
170200
logger.warn(`T6 empty-data retry attempt ${attempt} failed:`, createErrorMetadata(err));
171-
scheduleEmptyDataRetry(attempt + 1, null, onSuccess, onReschedule);
201+
scheduleEmptyDataRetry(attempt + 1, null, onSuccess, onReschedule, graphTypeFilter);
172202
}
173203
}, INTERVAL_MS) as unknown as number;
174204

client/src/features/graph/managers/graphDataManager.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { useWorkerErrorStore } from '../../../store/workerErrorStore';
1111
import { ensureNodeHasValidPosition, validateNodeMappings } from './dataManager/nodeUtils';
1212
import { buildNodeIdMaps, upsertNodeIdEntry, setDataAndNotify, topologyHash } from './dataManager/topology';
1313
import { ListenerRegistry } from './dataManager/listeners';
14-
import { fetchGraphData, scheduleEmptyDataRetry } from './dataManager/restClient';
14+
import { fetchGraphData, scheduleEmptyDataRetry, type GraphTypeFilter } from './dataManager/restClient';
1515
import { handleBinaryFrame, sendNodePositions as _sendNodePositions, enableBinaryUpdates as _enableBinaryUpdates } from './dataManager/wsClient';
1616

1717
// Re-export types for backward compat
@@ -42,6 +42,8 @@ class GraphDataManager {
4242
// ── Worker / graph type ─────────────────────────────────────────────────
4343
private workerInitialized: boolean = false;
4444
private graphType: 'logseq' | 'visionclaw' = 'logseq';
45+
// Server-side population filter (PRD-018 WS-4). null/'all' = whole graph.
46+
private graphTypeFilter: GraphTypeFilter = null;
4547
private workerUnsubscribers: Array<() => void> = [];
4648

4749
// ── User interaction state ──────────────────────────────────────────────
@@ -166,10 +168,24 @@ class GraphDataManager {
166168
return this.graphType;
167169
}
168170

171+
/**
172+
* Set the server-side population filter (PRD-018 WS-4). When set to a concrete
173+
* population the next `fetchInitialData` sends `?graph_type=` so only that
174+
* subset is transferred instead of the whole graph. `null`/`'all'` clears it.
175+
*/
176+
public setGraphTypeFilter(filter: GraphTypeFilter): void {
177+
this.graphTypeFilter = filter;
178+
if (debugState.isEnabled()) logger.info(`Graph type filter set to: ${filter ?? 'all'}`);
179+
}
180+
181+
public getGraphTypeFilter(): GraphTypeFilter {
182+
return this.graphTypeFilter;
183+
}
184+
169185
// ── REST data fetch ───────────────────────────────────────────────────
170186

171187
public async fetchInitialData(): Promise<GraphData> {
172-
const validatedData = await fetchGraphData(this.graphType);
188+
const validatedData = await fetchGraphData(this.graphType, this.graphTypeFilter);
173189

174190
await this.setGraphData(validatedData);
175191

@@ -183,6 +199,7 @@ class GraphDataManager {
183199
this.retryTimeout,
184200
() => this.fetchInitialData(),
185201
handle => { this.retryTimeout = handle; },
202+
this.graphTypeFilter,
186203
);
187204
}
188205

0 commit comments

Comments
 (0)