Skip to content

Commit 1aa234b

Browse files
author
Jurij Skornik
committed
fix(node-ui): canonicalize context graph layer counts
1 parent 2a663ad commit 1aa234b

4 files changed

Lines changed: 151 additions & 16 deletions

File tree

packages/node-ui/src/ui/hooks/useMemoryEntities.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -410,11 +410,14 @@ export function useMemoryEntities(
410410
}, [entities]);
411411

412412
const counts = useMemo(() => {
413-
const wm = new Set(layeredTriples.filter(t => t.layer === 'working').map(t => t.subject)).size;
414-
const swm = new Set(layeredTriples.filter(t => t.layer === 'shared').map(t => t.subject)).size;
415-
const vm = new Set(layeredTriples.filter(t => t.layer === 'verified').map(t => t.subject)).size;
416-
return { wm, swm, vm, total: entities.size };
417-
}, [layeredTriples, entities]);
413+
let wm = 0, swm = 0, vm = 0;
414+
for (const entity of entityList) {
415+
if (entity.trustLevel === 'verified') vm++;
416+
else if (entity.trustLevel === 'shared') swm++;
417+
else wm++;
418+
}
419+
return { wm, swm, vm, total: entityList.length };
420+
}, [entityList]);
418421

419422
return {
420423
entities,

packages/node-ui/src/ui/views/project/components.tsx

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -573,12 +573,13 @@ export function MemoryStrip({
573573
color: string;
574574
icon: string;
575575
entities: MemoryEntity[];
576+
count: number;
576577
promoteLabel: string | null;
577578
viewLayer: LayerView;
578579
}> = [
579-
{ key: 'wm', label: 'Working Memory', color: '#64748b', icon: '◇', entities: layerEntities.wm, promoteLabel: 'Promote All → Shared', viewLayer: 'wm' },
580-
{ key: 'swm', label: 'Shared Working Memory', color: '#f59e0b', icon: '◈', entities: layerEntities.swm, promoteLabel: 'Publish to Verified Memory', viewLayer: 'swm' },
581-
{ key: 'vm', label: 'Verified Memory', color: '#22c55e', icon: '◉', entities: layerEntities.vm, promoteLabel: null, viewLayer: 'vm' },
580+
{ key: 'wm', label: 'Working Memory', color: '#64748b', icon: '◇', entities: layerEntities.wm, count: memory.counts.wm, promoteLabel: 'Promote All → Shared', viewLayer: 'wm' },
581+
{ key: 'swm', label: 'Shared Working Memory', color: '#f59e0b', icon: '◈', entities: layerEntities.swm, count: memory.counts.swm, promoteLabel: 'Publish to Verified Memory', viewLayer: 'swm' },
582+
{ key: 'vm', label: 'Verified Memory', color: '#22c55e', icon: '◉', entities: layerEntities.vm, count: memory.counts.vm, promoteLabel: null, viewLayer: 'vm' },
582583
];
583584

584585
return (
@@ -594,7 +595,7 @@ export function MemoryStrip({
594595
>
595596
<div className="v10-layer-label" style={{ color: layer.color }}>
596597
<span className="v10-layer-abbr">{layer.label}</span>
597-
<span className="v10-layer-count">{layer.entities.length}</span>
598+
<span className="v10-layer-count">{layer.count}</span>
598599
</div>
599600
<div className="v10-layer-items">
600601
<span className="v10-layer-chevron"></span>
@@ -754,8 +755,9 @@ export function TypeBreakdownWidget({ entities }: { entities: MemoryEntity[] })
754755
);
755756
}
756757

757-
export function LayerStatsWidget({ entities, triples, layer }: {
758+
export function LayerStatsWidget({ entities, entityCount, triples, layer }: {
758759
entities: MemoryEntity[];
760+
entityCount: number;
759761
triples: number;
760762
layer: 'wm' | 'swm' | 'vm';
761763
}) {
@@ -779,7 +781,7 @@ export function LayerStatsWidget({ entities, triples, layer }: {
779781
<div className="v10-layer-summary">
780782
<div className="v10-layer-summary-stat">
781783
<span className="v10-layer-summary-label">Knowledge Assets</span>
782-
<span className="v10-layer-summary-value">{entities.length}</span>
784+
<span className="v10-layer-summary-value">{entityCount}</span>
783785
</div>
784786
<div className="v10-layer-summary-stat">
785787
<span className="v10-layer-summary-label">Triples</span>
@@ -872,14 +874,15 @@ export function LayerActionsWidget({ layer, count, contextGraphId, onComplete }:
872874

873875
// ─── Horizontal widget strip (stats + types + CTA) for the Entities tab ──
874876

875-
export function LayerWidgetStrip({ layer, entities, tripleCount, contextGraphId, onComplete }: {
877+
export function LayerWidgetStrip({ layer, entities, entityCount, tripleCount, contextGraphId, onComplete }: {
876878
layer: 'wm' | 'swm' | 'vm';
877879
entities: MemoryEntity[];
880+
entityCount: number;
878881
tripleCount: number;
879882
contextGraphId?: string;
880883
onComplete?: () => void;
881884
}) {
882-
if (entities.length === 0) {
885+
if (entityCount === 0) {
883886
return (
884887
<div className="v10-layer-widgets-strip empty">
885888
<div className="v10-canvas-empty">
@@ -894,12 +897,12 @@ export function LayerWidgetStrip({ layer, entities, tripleCount, contextGraphId,
894897
return (
895898
<div className="v10-layer-widgets-strip">
896899
<div className="v10-layer-widgets-strip-stats">
897-
<LayerStatsWidget entities={entities} triples={tripleCount} layer={layer} />
900+
<LayerStatsWidget entities={entities} entityCount={entityCount} triples={tripleCount} layer={layer} />
898901
<TypeBreakdownWidget entities={entities} />
899902
</div>
900903
{(layer === 'wm' || layer === 'swm') && (
901904
<div className="v10-layer-widgets-strip-action">
902-
<LayerActionsWidget layer={layer} count={entities.length} contextGraphId={contextGraphId} onComplete={onComplete} />
905+
<LayerActionsWidget layer={layer} count={entityCount} contextGraphId={contextGraphId} onComplete={onComplete} />
903906
</div>
904907
)}
905908
</div>
@@ -1051,6 +1054,7 @@ export function LayerContent({
10511054
const isInitialVerifiedMemoryLoad = layer === 'vm' && vmLayerStatus === 'loading' && entities.length === 0;
10521055
const isVerifiedMemoryUnavailable = layer === 'vm' && vmLayerStatus === 'error' && entities.length === 0;
10531056
const isEmptyVerifiedMemory = layer === 'vm' && vmLayerStatus === 'ok' && entities.length === 0;
1057+
const entityCount = memory.counts[layer];
10541058

10551059
const handleTab = (tab: LayerContentTab) => (e: React.MouseEvent) => {
10561060
e.stopPropagation();
@@ -1112,6 +1116,7 @@ export function LayerContent({
11121116
<LayerWidgetStrip
11131117
layer={layer}
11141118
entities={entities}
1119+
entityCount={entityCount}
11151120
tripleCount={tripleCount}
11161121
contextGraphId={contextGraphId}
11171122
onComplete={memory.refresh}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// @vitest-environment happy-dom
2+
3+
import React, { act } from 'react';
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
import { createRoot, type Root } from 'react-dom/client';
6+
import { useMemoryEntities } from '../src/ui/hooks/useMemoryEntities.js';
7+
8+
const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
9+
const MENTIONS = 'http://schema.org/mentions';
10+
11+
class MockEventSource {
12+
static instances: MockEventSource[] = [];
13+
readonly listeners = new Map<string, Array<(e: MessageEvent) => void>>();
14+
constructor(readonly url: string) { MockEventSource.instances.push(this); }
15+
addEventListener(t: string, l: (e: MessageEvent) => void) {
16+
const a = this.listeners.get(t) ?? [];
17+
a.push(l);
18+
this.listeners.set(t, a);
19+
}
20+
close() {}
21+
}
22+
23+
function typeBinding(subject: string, graph: string) {
24+
return {
25+
s: { value: subject },
26+
p: { value: RDF_TYPE },
27+
o: { value: 'http://schema.org/Thing' },
28+
g: { value: graph },
29+
};
30+
}
31+
32+
function uriBinding(subject: string, predicate: string, object: string, graph: string) {
33+
return {
34+
s: { value: subject },
35+
p: { value: predicate },
36+
o: { value: object },
37+
g: { value: graph },
38+
};
39+
}
40+
41+
function Probe({ id }: { id: string }) {
42+
const memory = useMemoryEntities(id);
43+
return React.createElement('div', {
44+
id: 'probe',
45+
'data-loading': String(memory.loading),
46+
'data-wm': String(memory.counts.wm),
47+
'data-swm': String(memory.counts.swm),
48+
'data-vm': String(memory.counts.vm),
49+
'data-total': String(memory.counts.total),
50+
'data-current-layers': memory.entityList.map(e => `${e.uri}:${e.trustLevel}`).join('|'),
51+
});
52+
}
53+
54+
async function flush() {
55+
await act(async () => {
56+
await Promise.resolve();
57+
await Promise.resolve();
58+
});
59+
}
60+
61+
describe('useMemoryEntities canonical layer counts', () => {
62+
let container: HTMLDivElement;
63+
let root: Root;
64+
65+
beforeEach(() => {
66+
MockEventSource.instances = [];
67+
(globalThis as any).EventSource = MockEventSource;
68+
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
69+
container = document.createElement('div');
70+
document.body.appendChild(container);
71+
root = createRoot(container);
72+
73+
vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: RequestInit) => {
74+
const { sparql = '', contextGraphId = 'cg' } =
75+
JSON.parse(String(init?.body ?? '{}')) as { sparql?: string; contextGraphId?: string };
76+
const isVm = sparql.includes('_verified_memory_meta');
77+
const isSwm = !isVm && sparql.includes('STRENDS');
78+
const graphBase = `did:dkg:context-graph:${contextGraphId}`;
79+
const bindings = isVm
80+
? [
81+
typeBinding('urn:test:verified', graphBase),
82+
typeBinding('urn:test:full-pipeline', graphBase),
83+
]
84+
: isSwm
85+
? [
86+
typeBinding('urn:test:promoted', `${graphBase}/notes/_shared_memory`),
87+
typeBinding('urn:test:full-pipeline', `${graphBase}/notes/_shared_memory`),
88+
]
89+
: [
90+
typeBinding('urn:test:promoted', `${graphBase}/notes/assertion/agent/a-1`),
91+
typeBinding('urn:test:full-pipeline', `${graphBase}/notes/assertion/agent/a-1`),
92+
typeBinding('urn:test:draft', `${graphBase}/notes/assertion/agent/a-2`),
93+
typeBinding('urn:test:draft', `${graphBase}/docs/assertion/agent/a-3`),
94+
uriBinding('urn:test:draft', MENTIONS, 'urn:test:object-only', `${graphBase}/docs/assertion/agent/a-3`),
95+
];
96+
return {
97+
ok: true,
98+
json: async () => ({ result: { bindings } }),
99+
} as Response;
100+
}));
101+
});
102+
103+
afterEach(() => {
104+
act(() => root.unmount());
105+
container.remove();
106+
vi.unstubAllGlobals();
107+
});
108+
109+
it('counts each visible entity once in its current highest layer', async () => {
110+
await act(async () => {
111+
root.render(React.createElement(Probe, { id: 'cg-counts' }));
112+
});
113+
await flush();
114+
115+
const el = container.querySelector('#probe')!;
116+
expect(el.getAttribute('data-loading')).toBe('false');
117+
expect(el.getAttribute('data-wm')).toBe('1');
118+
expect(el.getAttribute('data-swm')).toBe('1');
119+
expect(el.getAttribute('data-vm')).toBe('2');
120+
expect(el.getAttribute('data-total')).toBe('4');
121+
expect(el.getAttribute('data-current-layers')).toContain('urn:test:promoted:shared');
122+
expect(el.getAttribute('data-current-layers')).toContain('urn:test:full-pipeline:verified');
123+
expect(el.getAttribute('data-current-layers')).not.toContain('urn:test:object-only');
124+
});
125+
});

packages/node-ui/test/use-memory-entities-live-updates.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ function tripleBinding(subject: string, graph: string) {
4343
}
4444

4545
function bindingsForLayer(sparql: string, contextGraphId: string, revision: number) {
46-
if (!sparql.includes('/assertion/')) return [];
46+
const isVm = sparql.includes('_verified_memory_meta');
47+
const isSwm = !isVm && sparql.includes('STRENDS');
48+
if (isVm || isSwm) return [];
4749
return Array.from({ length: revision }, (_, i) =>
4850
tripleBinding(
4951
`urn:test:${contextGraphId}:wm-${i + 1}`,

0 commit comments

Comments
 (0)