From 1d4b7fbb87b8baf14a02c366eb7b224190ba7a80 Mon Sep 17 00:00:00 2001 From: Ziga Drev Date: Fri, 14 Aug 2026 11:05:25 +0200 Subject: [PATCH 1/2] feat(desktop): entity-true sub-graph counts that reconcile to layer totals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every chip in the memory panel is conceptually a SUB-GRAPH of the channel's Context Graph — a kind of entity, one participant's contributions, code. This makes the numbers those chips carry mean exactly that, and makes the sums check out (buzz-dkg-beta#13): - subgraphCounts.ts: a battery of budget-safe semantic aggregates (exact type/predicate IRIs, one aggregate per query, LIMIT everywhere — the relay's query gate rejects GROUP BY, GRAPH clauses, and unbound patterns) counting each kind sub-graph (decisions, evidence events, people & agents, capture runs, claims, code), each contributor's sub-graph via prov:wasAttributedTo, and the layer's typed-entity total. "Entity" follows the DKG node UI's own semantics (packages/node-ui buildEntities): entities of every kind, not decisions. - The dashboard gains a reconciliation line — e.g. "312 entities = 30 decisions · 240 evidence · 11 people & agents · 30 capture runs" — and any mismatch is SHOWN ("n other" for unclassified subjects, "n cross-typed" for double-counted ones), never hidden. The invariant is verified in the UI, not assumed. - Layer cards never present a display-bounded list length as a total again: an uncapped *Count from the gateway wins, a measured entity total is next, and a bare list renders only as a lower bound (≥n). - The All-decisions chip carries the true decision count; each People & agents chip carries that agent's sub-graph entity count (falling back to raw event count until measured, with the unit named in the tooltip). - Lens overlay legends now say "in this view" — a per-lens node tally can no longer impersonate a channel-wide layer total. Counting never gates the panel: every query failure degrades to null and the previous behavior. Covered by unit tests for the query builder, binding parser, and reconciliation math, plus an e2e that stubs the gateway aggregates and asserts the reconciliation line, the honest layer card, and the per-agent chip end to end. Co-Authored-By: Claude Fable 5 Signed-off-by: Ziga Drev --- desktop/src/features/dkg-memory/api.ts | 45 ++++++ desktop/src/features/dkg-memory/hooks.ts | 22 +++ .../dkg-memory/subgraphCounts.test.mjs | 102 +++++++++++++ .../src/features/dkg-memory/subgraphCounts.ts | 134 ++++++++++++++++++ .../features/dkg-memory/ui/GraphOverlay.tsx | 9 +- .../features/dkg-memory/ui/MemoryOverview.tsx | 52 ++++++- desktop/tests/e2e/dkg-memory-fallback.spec.ts | 84 +++++++++++ 7 files changed, 441 insertions(+), 7 deletions(-) create mode 100644 desktop/src/features/dkg-memory/subgraphCounts.test.mjs create mode 100644 desktop/src/features/dkg-memory/subgraphCounts.ts diff --git a/desktop/src/features/dkg-memory/api.ts b/desktop/src/features/dkg-memory/api.ts index db370b2ba2..f0ad2fcd66 100644 --- a/desktop/src/features/dkg-memory/api.ts +++ b/desktop/src/features/dkg-memory/api.ts @@ -227,6 +227,51 @@ export async function fetchSemanticQuery( }); } +import { + countQueries, + emptyLayerCounts, + parseCountBinding, + type EntityCounts, + type EntityKindKey, +} from "./subgraphCounts"; + +/** + * Entity counts per layer, per kind sub-graph, and per contributor sub-graph + * (issue buzz-dkg-beta#13). Fired as a battery of budget-safe aggregates; + * every failure degrades to null — counting never gates the panel. + */ +export async function fetchEntityCounts( + channelId: string, + contributorPubkeys: string[], +): Promise { + const queries = countQueries(contributorPubkeys); + const settled = await Promise.allSettled( + queries.map((query) => fetchSemanticQuery(channelId, query.sparql)), + ); + const counts: EntityCounts = { + SWM: emptyLayerCounts(), + VM: emptyLayerCounts(), + }; + queries.forEach((query, index) => { + const result = settled[index]; + if (result?.status !== "fulfilled") return; + for (const layer of result.value.layers ?? []) { + if (layer.layer !== "SWM" && layer.layer !== "VM") continue; + const value = parseCountBinding(layer.bindings); + if (value === null) continue; + const bucket = counts[layer.layer]; + if (query.key === "typedTotal") { + bucket.typedTotal = value; + } else if (query.key.startsWith("agent:")) { + bucket.perAgent[query.key.slice("agent:".length)] = value; + } else { + bucket.kinds[query.key as EntityKindKey] = value; + } + } + }); + return counts; +} + function diagnosticError(cause: unknown): string { if (cause instanceof Error && cause.message.trim()) return cause.message; return "The check did not return a usable response."; diff --git a/desktop/src/features/dkg-memory/hooks.ts b/desktop/src/features/dkg-memory/hooks.ts index 8c479ce78d..66a45f9a22 100644 --- a/desktop/src/features/dkg-memory/hooks.ts +++ b/desktop/src/features/dkg-memory/hooks.ts @@ -210,3 +210,25 @@ export function useDiscoveryFallback( staleTime: 60 * 1000, }); } + +import { fetchEntityCounts } from "./api"; + +/** + * Entity counts for the panel's sub-graph chips and layer cards (issue + * buzz-dkg-beta#13). Keyed by the contributor set so a membership change + * refreshes the per-agent battery; cached because the counts only move when + * something is captured. + */ +export function useEntityCounts( + channelId: string | null, + contributorPubkeys: string[], +) { + const pubkeyKey = [...contributorPubkeys].sort().join(","); + return useQuery({ + queryKey: ["dkg-memory", "entity-counts", channelId, pubkeyKey], + queryFn: () => + fetchEntityCounts(channelId as string, [...contributorPubkeys].sort()), + enabled: Boolean(channelId), + staleTime: 60 * 1000, + }); +} diff --git a/desktop/src/features/dkg-memory/subgraphCounts.test.mjs b/desktop/src/features/dkg-memory/subgraphCounts.test.mjs new file mode 100644 index 0000000000..ad63bceff5 --- /dev/null +++ b/desktop/src/features/dkg-memory/subgraphCounts.test.mjs @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + countQueries, + emptyLayerCounts, + parseCountBinding, + reconcile, + reconciliationLine, +} from "./subgraphCounts.ts"; + +test("query battery uses exact IRIs, LIMITs, and per-agent attribution", () => { + const queries = countQueries(["aa".repeat(32), "bb".repeat(32)]); + // total + 6 kinds + 2 agents + assert.equal(queries.length, 9); + for (const q of queries) { + assert.match(q.sparql, /LIMIT 25$/); + // Budget rules: no GROUP BY, no GRAPH, no ORDER BY. + assert.doesNotMatch(q.sparql, /GROUP BY|GRAPH|ORDER BY/); + } + const decisions = queries.find((q) => q.key === "decisions"); + assert.match( + decisions.sparql, + //, + ); + const agent = queries.find((q) => q.key === `agent:${"aa".repeat(32)}`); + assert.match(agent.sparql, /wasAttributedTo> /); + // The typed-total query is the only one with a variable object. + const total = queries.find((q) => q.key === "typedTotal"); + assert.match( + total.sparql, + /\?s \?t/, + ); +}); + +test("count parsing handles typed-literal, object, and absent shapes", () => { + assert.equal( + parseCountBinding([ + { n: '"30"^^' }, + ]), + 30, + ); + assert.equal(parseCountBinding([{ n: { value: "312" } }]), 312); + assert.equal(parseCountBinding([{ n: "0" }]), 0); + assert.equal(parseCountBinding([]), null); + assert.equal(parseCountBinding(undefined), null); +}); + +test("reconcile reports exact, unclassified, and cross-typed deltas", () => { + const exact = emptyLayerCounts(); + exact.typedTotal = 312; + exact.kinds = { + decisions: 30, + evidence: 240, + agents: 11, + activities: 30, + claims: 1, + }; + assert.deepEqual(reconcile(exact), { sum: 312, delta: 0 }); + + const unclassified = emptyLayerCounts(); + unclassified.typedTotal = 315; + unclassified.kinds = { ...exact.kinds }; + assert.deepEqual(reconcile(unclassified), { sum: 312, delta: 3 }); + + const crossTyped = emptyLayerCounts(); + crossTyped.typedTotal = 310; + crossTyped.kinds = { ...exact.kinds }; + assert.deepEqual(reconcile(crossTyped), { sum: 312, delta: -2 }); + + const unknownTotal = emptyLayerCounts(); + unknownTotal.kinds = { decisions: 30 }; + assert.deepEqual(reconcile(unknownTotal), { sum: 30, delta: null }); +}); + +test("reconciliation line names the kinds and surfaces any delta", () => { + const layer = emptyLayerCounts(); + layer.typedTotal = 312; + layer.kinds = { + decisions: 30, + evidence: 240, + agents: 11, + activities: 30, + claims: 0, + code: 0, + }; + // Zero-count kinds are omitted; the 1-entity shortfall is shown, not hidden. + assert.equal( + reconciliationLine(layer), + "312 entities = 30 decisions · 240 evidence · 11 people & agents · 30 capture runs · 1 other", + ); + layer.kinds.claims = 1; + assert.equal( + reconciliationLine(layer), + "312 entities = 30 decisions · 240 evidence · 11 people & agents · 30 capture runs · 1 claims", + ); + // No total → no line; no measured kinds → no line. + const empty = emptyLayerCounts(); + assert.equal(reconciliationLine(empty), null); + empty.typedTotal = 10; + assert.equal(reconciliationLine(empty), null); +}); diff --git a/desktop/src/features/dkg-memory/subgraphCounts.ts b/desktop/src/features/dkg-memory/subgraphCounts.ts new file mode 100644 index 0000000000..8497220fed --- /dev/null +++ b/desktop/src/features/dkg-memory/subgraphCounts.ts @@ -0,0 +1,134 @@ +// Entity accounting for the memory panel (issue OriginTrail/buzz-dkg-beta#13). +// +// The model: every chip the panel shows — a decision kind, an agent, code — +// is a SUB-GRAPH of the channel's Context Graph, and the number it carries is +// that sub-graph's entity count. Kind sub-graphs partition the typed-entity +// space, so their counts SUM to the layer's entity total; when the data +// breaks that invariant (multi-typed or unclassified subjects) the delta is +// reported, never hidden. "Entity" follows the DKG node UI's own semantics +// (`packages/node-ui` buildEntities): all sorts of entities, not decisions. +// +// Everything here must stay inside the relay's semantic-query budget: exact +// type/predicate IRIs only, one aggregate per query, no GROUP BY, no GRAPH +// clauses, LIMIT on every SELECT — which is why the battery is many small +// queries instead of one breakdown query. +const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +const BUZZ = "https://w3id.org/buzz-dkg/buzz#"; +const NOSTR = "https://w3id.org/buzz-dkg/nostr#"; +const PROV = "http://www.w3.org/ns/prov#"; + +export const ENTITY_KINDS = [ + { key: "decisions", label: "decisions", type: `${BUZZ}DecisionCluster` }, + { key: "evidence", label: "evidence", type: `${NOSTR}Event` }, + { key: "agents", label: "people & agents", type: `${PROV}Agent` }, + { key: "activities", label: "capture runs", type: `${BUZZ}Distillation` }, + { key: "claims", label: "claims", type: `${BUZZ}Claim` }, + { key: "code", label: "code", type: `${BUZZ}Commit` }, +] as const; + +export type EntityKindKey = (typeof ENTITY_KINDS)[number]["key"]; + +export interface CountQuery { + key: string; + sparql: string; +} + +function countByType(typeIri: string): string { + return `SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE { ?s <${RDF}type> <${typeIri}> } LIMIT 25`; +} + +/** + * The bounded query battery: one aggregate per kind, the typed-entity total, + * and one attribution aggregate per contributor (their sub-graph size). + */ +export function countQueries(contributorPubkeys: string[]): CountQuery[] { + const queries: CountQuery[] = [ + { + key: "typedTotal", + sparql: `SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE { ?s <${RDF}type> ?t } LIMIT 25`, + }, + ...ENTITY_KINDS.map((kind) => ({ + key: kind.key, + sparql: countByType(kind.type), + })), + ]; + for (const pubkey of contributorPubkeys) { + queries.push({ + key: `agent:${pubkey}`, + sparql: `SELECT (COUNT(DISTINCT ?e) AS ?n) WHERE { ?e <${PROV}wasAttributedTo> } LIMIT 25`, + }); + } + return queries; +} + +/** Extract the integer from a SPARQL aggregate binding in any wire shape. */ +export function parseCountBinding( + bindings: Record[] | undefined, +): number | null { + const raw = bindings?.[0]?.n; + const text = + typeof raw === "string" + ? raw + : raw && typeof raw === "object" && "value" in raw + ? String((raw as { value: unknown }).value) + : null; + if (text === null) return null; + const match = text.match(/-?\d+/); + return match ? Number.parseInt(match[0], 10) : null; +} + +export interface LayerEntityCounts { + typedTotal: number | null; + kinds: Partial>; + perAgent: Record; +} + +export interface EntityCounts { + SWM: LayerEntityCounts; + VM: LayerEntityCounts; +} + +export function emptyLayerCounts(): LayerEntityCounts { + return { typedTotal: null, kinds: {}, perAgent: {} }; +} + +/** + * Kind counts vs the layer total. `delta > 0` means subjects no kind claims + * (unclassified types); `delta < 0` means subjects counted by more than one + * kind. Either way the caller shows it — the invariant is verified in the + * UI, not assumed. + */ +export function reconcile(layer: LayerEntityCounts): { + sum: number; + delta: number | null; +} { + const sum = Object.values(layer.kinds).reduce( + (total, count) => total + (count ?? 0), + 0, + ); + return { + sum, + delta: layer.typedTotal === null ? null : layer.typedTotal - sum, + }; +} + +/** Human line for the dashboard: "312 entities = 30 decisions · …". */ +export function reconciliationLine(layer: LayerEntityCounts): string | null { + if (layer.typedTotal === null) return null; + const parts: string[] = []; + for (const kind of ENTITY_KINDS) { + const count = layer.kinds[kind.key]; + if (typeof count === "number" && count > 0) { + parts.push(`${count} ${kind.label}`); + } + } + if (parts.length === 0) return null; + const { delta } = reconcile(layer); + const tail = + delta === 0 + ? "" + : delta && delta > 0 + ? ` · ${delta} other` + : ` · ${-(delta ?? 0)} cross-typed`; + return `${layer.typedTotal} entities = ${parts.join(" · ")}${tail}`; +} diff --git a/desktop/src/features/dkg-memory/ui/GraphOverlay.tsx b/desktop/src/features/dkg-memory/ui/GraphOverlay.tsx index 7f16a0feb0..c0f35e0ef0 100644 --- a/desktop/src/features/dkg-memory/ui/GraphOverlay.tsx +++ b/desktop/src/features/dkg-memory/ui/GraphOverlay.tsx @@ -211,6 +211,10 @@ function ProviderBadge() { ); } +// Lens legends count NODES IN THIS VIEW — a different unit from the +// dashboard's layer cards (channel-wide entity totals). The qualifier is +// load-bearing: without it the same chip design silently means two things +// (buzz-dkg-beta#13). function LayerCountsLegend({ counts, }: { @@ -222,7 +226,7 @@ function LayerCountsLegend({ {tag} @@ -231,6 +235,9 @@ function LayerCountsLegend({ ))} + + in this view + ); } diff --git a/desktop/src/features/dkg-memory/ui/MemoryOverview.tsx b/desktop/src/features/dkg-memory/ui/MemoryOverview.tsx index 9c91f79cc7..c9a525005c 100644 --- a/desktop/src/features/dkg-memory/ui/MemoryOverview.tsx +++ b/desktop/src/features/dkg-memory/ui/MemoryOverview.tsx @@ -7,7 +7,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import type { ChannelMemory } from "../api"; import { explorerSource, nodeUiDeepLink } from "../api"; import { shouldShowDkgWebOfTrustUi } from "../featureFlags"; -import { useProfileNames } from "../hooks"; +import { useEntityCounts, useProfileNames } from "../hooks"; +import { reconciliationLine } from "../subgraphCounts"; import { EvidenceCard } from "./EvidenceCard"; import { GraphOverlay, type GraphOverlayTarget } from "./GraphOverlay"; import { openExternal } from "./openExternal"; @@ -56,6 +57,11 @@ export function MemoryOverview({ const topicCount = (data.subgraphs ?? []).filter( (subgraph) => subgraph.entityCount > 0, ).length; + // Entity accounting (buzz-dkg-beta#13): kind and per-agent sub-graph + // counts whose sum reconciles against the layer's typed-entity total. + const entityCounts = useEntityCounts(channelId, contributorPubkeys); + const swmCounts = entityCounts.data?.SWM ?? null; + const swmReconciliation = swmCounts ? reconciliationLine(swmCounts) : null; const showWebOfTrust = shouldShowDkgWebOfTrustUi(trustAvailable); return ( @@ -127,13 +133,29 @@ export function MemoryOverview({
{(["WM", "SWM", "VM"] as const).map((tag) => { const entries = data.layers?.[tag]; - const count = data.layers?.[`${tag}Count`] ?? entries?.length; + const declaredCount = data.layers?.[`${tag}Count`]; + const measuredTotal = + tag === "WM" + ? null + : (entityCounts.data?.[tag]?.typedTotal ?? null); + // Never present a display-bounded list length as a total + // (buzz-dkg-beta#13): an uncapped count wins; a measured + // entity total is next; a bare list only ever renders as a + // lower bound. + const count = + declaredCount ?? + measuredTotal ?? + (entries + ? entries.length === 0 + ? 0 + : `≥${entries.length}` + : null); const meta = LAYER_META[tag]; return (
@@ -152,6 +174,15 @@ export function MemoryOverview({ {sortedDecisions.length} decisions · {topicCount} named topics ·{" "} {(data.contributors ?? []).length} people & agents

+ {swmReconciliation && ( +

+ Shared memory: {swmReconciliation} +

+ )} {latestDecision && ( @@ -187,7 +218,7 @@ export function MemoryOverview({ > All decisions - {sortedDecisions.length} + {swmCounts?.kinds.decisions ?? sortedDecisions.length} @@ -232,6 +263,11 @@ export function MemoryOverview({ const name = profiles.data?.[contributor.pubkey] ?? shortPk(contributor.pubkey); + // The chip is this agent's sub-graph; its number is the + // count of entities attributed to them in shared memory, + // falling back to raw event count until measured. + const agentEntities = + swmCounts?.perAgent[contributor.pubkey] ?? null; return ( ); diff --git a/desktop/tests/e2e/dkg-memory-fallback.spec.ts b/desktop/tests/e2e/dkg-memory-fallback.spec.ts index 091105f435..2edf08c505 100644 --- a/desktop/tests/e2e/dkg-memory-fallback.spec.ts +++ b/desktop/tests/e2e/dkg-memory-fallback.spec.ts @@ -356,3 +356,87 @@ test("named subgraph lens queries the provider and keeps Graph available", async "engineering", ); }); + +test("entity accounting: chips carry sub-graph counts that reconcile to the layer total", async ({ + page, +}) => { + const pubkey = "c9f4f94b87273745cc34b9d8b15847b27afb90eb9bb7c8a4363703821a0"; + await page.addInitScript((cg) => { + window.localStorage.setItem("dkg-memory-cg-override", cg); + }, CG); + await page.route("http://127.0.0.1:9295/**", (route) => { + const url = route.request().url(); + if (url.includes("/api/channel-memory")) { + return route.fulfill({ + json: { + ...FLAT_MEMORY, + contributors: [{ pubkey, events: 99, latest: 1_786_363_200 }], + }, + }); + } + return route.fulfill({ json: { gate: "ok" } }); + }); + // Community-gateway semantic queries: answer each bounded aggregate with a + // fixture that reconciles exactly (13 = 3 decisions + 6 evidence + 1 agent + // + 3 capture runs), echoing the request's own channel/operation so the + // provider envelope validation passes. + await page.route("**/api/dkg/query", (route) => { + const body = route.request().postDataJSON() as { + channelId: string; + operation: string; + arguments?: { sparql?: string }; + }; + const sparql = body.arguments?.sparql ?? ""; + const count = sparql.includes("DecisionCluster") + ? 3 + : sparql.includes("nostr#Event") + ? 6 + : sparql.includes("prov#Agent") + ? 1 + : sparql.includes("Distillation") + ? 3 + : sparql.includes("wasAttributedTo") + ? 6 + : sparql.includes( + "?s ?t", + ) + ? 13 + : 0; + return route.fulfill({ + json: { + ok: true, + channelId: body.channelId, + operation: body.operation, + cg: CG, + result: { + queryType: "select", + scope: { type: "current_channel" }, + layers: [ + { + layer: "SWM", + bindings: [ + { n: `"${count}"^^` }, + ], + }, + { layer: "VM", bindings: [{ n: '"0"' }] }, + ], + }, + }, + }); + }); + const panel = await openMemoryPanel(page); + + // The reconciliation line proves the invariant: kind sub-graphs sum to the + // layer's entity total, with zero-count kinds omitted. + await expect(panel.getByTestId("dkg-entity-reconciliation")).toHaveText( + "Shared memory: 13 entities = 3 decisions · 6 evidence · 1 people & agents · 3 capture runs", + { timeout: 15_000 }, + ); + // SWM layer card prefers the gateway's uncapped count (fixture sends 3). + await expect(panel.getByText("3", { exact: true }).first()).toBeVisible(); + // The agent chip carries its sub-graph's entity count (6), not raw events (99). + const chip = panel.getByTestId(`dkg-contributor-${pubkey}`); + await expect(chip).toContainText("6"); + await expect(chip).not.toContainText("99"); + await waitForAnimations(page); +}); From 289dce9fe7a2d32e178b5e5fc6cbb6f1f0356d6a Mon Sep 17 00:00:00 2001 From: branarakic Date: Fri, 14 Aug 2026 12:22:19 +0200 Subject: [PATCH 2/2] fix(desktop): bound entity accounting queries Signed-off-by: branarakic --- desktop/src/features/dkg-memory/api.ts | 39 +++++---- desktop/src/features/dkg-memory/hooks.ts | 37 ++++----- .../dkg-memory/subgraphCounts.test.mjs | 43 ++++++++-- .../src/features/dkg-memory/subgraphCounts.ts | 79 +++++++++++-------- .../features/dkg-memory/ui/MemoryOverview.tsx | 35 ++++---- desktop/tests/e2e/dkg-memory-fallback.spec.ts | 15 ++-- 6 files changed, 150 insertions(+), 98 deletions(-) diff --git a/desktop/src/features/dkg-memory/api.ts b/desktop/src/features/dkg-memory/api.ts index f0ad2fcd66..36e835d590 100644 --- a/desktop/src/features/dkg-memory/api.ts +++ b/desktop/src/features/dkg-memory/api.ts @@ -4,6 +4,7 @@ // community relay's authenticated DKG provider. Receipts remain a discovery // and display fallback; their Context Graph id is never sent as remote // authorization input. +import { collectWithConcurrency } from "@/shared/api/concurrency"; import { relayClient } from "@/shared/api/relayClient"; import { getRelayHttpUrl, signRelayEvent } from "@/shared/api/tauri"; import { fetchDkgMemoryCapabilities } from "./capabilities"; @@ -12,6 +13,13 @@ import { memoryProposalProgress, normalizeMemoryProposalResponse, } from "./proposalState"; +import { + countQueries, + emptyLayerCounts, + parseCountBinding, + type EntityCounts, + type EntityKindKey, +} from "./subgraphCounts"; export { explorerSource, @@ -54,7 +62,7 @@ export interface SubGraphEntry { export interface ChannelMemory { gate: MemoryGate; cg?: string; - /** Layer graph lists are display-bounded; the *Count fields are uncapped. */ + /** Layer graph lists are display-bounded; the *Count fields count graphs. */ layers?: Record<"WM" | "SWM" | "VM", LayerEntry[] | null> & Partial>; decisions?: DecisionEntry[]; @@ -227,35 +235,32 @@ export async function fetchSemanticQuery( }); } -import { - countQueries, - emptyLayerCounts, - parseCountBinding, - type EntityCounts, - type EntityKindKey, -} from "./subgraphCounts"; - /** * Entity counts per layer, per kind sub-graph, and per contributor sub-graph - * (issue buzz-dkg-beta#13). Fired as a battery of budget-safe aggregates; - * every failure degrades to null — counting never gates the panel. + * (issue buzz-dkg-beta#13). Queries are capped and concurrency-bounded so an + * open panel cannot saturate the DKG gateway. Every failure degrades to null; + * counting never gates the panel. */ export async function fetchEntityCounts( channelId: string, contributorPubkeys: string[], ): Promise { const queries = countQueries(contributorPubkeys); - const settled = await Promise.allSettled( - queries.map((query) => fetchSemanticQuery(channelId, query.sparql)), - ); + const results = await collectWithConcurrency(queries, 4, async (query) => { + try { + return await fetchSemanticQuery(channelId, query.sparql, query.view); + } catch { + return null; + } + }); const counts: EntityCounts = { SWM: emptyLayerCounts(), VM: emptyLayerCounts(), }; queries.forEach((query, index) => { - const result = settled[index]; - if (result?.status !== "fulfilled") return; - for (const layer of result.value.layers ?? []) { + const result = results[index]; + if (!result) return; + for (const layer of result.layers ?? []) { if (layer.layer !== "SWM" && layer.layer !== "VM") continue; const value = parseCountBinding(layer.bindings); if (value === null) continue; diff --git a/desktop/src/features/dkg-memory/hooks.ts b/desktop/src/features/dkg-memory/hooks.ts index 66a45f9a22..23d884e4c2 100644 --- a/desktop/src/features/dkg-memory/hooks.ts +++ b/desktop/src/features/dkg-memory/hooks.ts @@ -4,10 +4,23 @@ import { deriveContextGraphId, fetchChannelMemory, fetchContributorTrail, + fetchDecisionsEvidence, + fetchDiscoveryFromReceipts, + fetchEntityCounts, + fetchEvidence, + fetchProfileNames, fetchReputationSummary, + fetchSubgraphGraph, fetchTrustNetwork, + type DecisionEntry, } from "./api"; import { fetchDkgMemoryCapabilities } from "./capabilities"; +import { + buildContributorGraph, + buildDecisionsGraph, + type LensGraph, +} from "./lensGraphs"; +import { boundedContributorPubkeys } from "./subgraphCounts"; const CAPABILITY_RETRY_DELAYS_MS = [250, 1_000, 5_000] as const; @@ -100,13 +113,6 @@ export function useReputationSummary( }); } -import { - fetchDiscoveryFromReceipts, - fetchEvidence, - fetchProfileNames, - fetchSubgraphGraph, -} from "./api"; - export function useEvidence( channelId: string | null, cg: string | null | undefined, @@ -144,14 +150,6 @@ export function useSubgraphGraph( }); } -import type { DecisionEntry } from "./api"; -import { fetchDecisionsEvidence } from "./api"; -import { - buildContributorGraph, - buildDecisionsGraph, - type LensGraph, -} from "./lensGraphs"; - /** * Enrich the all-decisions lens with evidence envelopes. Keyed by the decision * URI set so a re-render with the same lens does not refetch the fan-out. @@ -211,8 +209,6 @@ export function useDiscoveryFallback( }); } -import { fetchEntityCounts } from "./api"; - /** * Entity counts for the panel's sub-graph chips and layer cards (issue * buzz-dkg-beta#13). Keyed by the contributor set so a membership change @@ -223,12 +219,13 @@ export function useEntityCounts( channelId: string | null, contributorPubkeys: string[], ) { - const pubkeyKey = [...contributorPubkeys].sort().join(","); + const boundedPubkeys = boundedContributorPubkeys(contributorPubkeys); + const pubkeyKey = boundedPubkeys.join(","); return useQuery({ queryKey: ["dkg-memory", "entity-counts", channelId, pubkeyKey], - queryFn: () => - fetchEntityCounts(channelId as string, [...contributorPubkeys].sort()), + queryFn: () => fetchEntityCounts(channelId as string, boundedPubkeys), enabled: Boolean(channelId), staleTime: 60 * 1000, + refetchInterval: 120 * 1000, }); } diff --git a/desktop/src/features/dkg-memory/subgraphCounts.test.mjs b/desktop/src/features/dkg-memory/subgraphCounts.test.mjs index ad63bceff5..3190082edd 100644 --- a/desktop/src/features/dkg-memory/subgraphCounts.test.mjs +++ b/desktop/src/features/dkg-memory/subgraphCounts.test.mjs @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + MAX_CONTRIBUTOR_COUNT_QUERIES, + boundedContributorPubkeys, countQueries, emptyLayerCounts, parseCountBinding, @@ -18,6 +20,8 @@ test("query battery uses exact IRIs, LIMITs, and per-agent attribution", () => { // Budget rules: no GROUP BY, no GRAPH, no ORDER BY. assert.doesNotMatch(q.sparql, /GROUP BY|GRAPH|ORDER BY/); } + assert.ok(queries.slice(0, 7).every((query) => query.view === "both")); + assert.ok(queries.slice(7).every((query) => query.view === "shared")); const decisions = queries.find((q) => q.key === "decisions"); assert.match( decisions.sparql, @@ -33,6 +37,27 @@ test("query battery uses exact IRIs, LIMITs, and per-agent attribution", () => { ); }); +test("contributor counts reject unsafe keys, deduplicate, and cap fan-out", () => { + const valid = Array.from({ length: 20 }, (_, index) => + index.toString(16).padStart(64, "0"), + ); + const selected = boundedContributorPubkeys([ + valid[0], + valid[0], + ...valid.slice(1), + "A".repeat(64), + `${"b".repeat(63)}> ?s ?p ?o`, + ]); + + assert.equal(selected.length, MAX_CONTRIBUTOR_COUNT_QUERIES); + assert.equal(new Set(selected).size, selected.length); + assert.ok(selected.every((pubkey) => /^[0-9a-f]{64}$/.test(pubkey))); + assert.equal( + countQueries([...valid, ...valid]).length, + 7 + MAX_CONTRIBUTOR_COUNT_QUERIES, + ); +}); + test("count parsing handles typed-literal, object, and absent shapes", () => { assert.equal( parseCountBinding([ @@ -42,11 +67,15 @@ test("count parsing handles typed-literal, object, and absent shapes", () => { ); assert.equal(parseCountBinding([{ n: { value: "312" } }]), 312); assert.equal(parseCountBinding([{ n: "0" }]), 0); + assert.equal(parseCountBinding([{ n: "-1" }]), null); + assert.equal(parseCountBinding([{ n: "12.5" }]), null); + assert.equal(parseCountBinding([{ n: "about 30" }]), null); + assert.equal(parseCountBinding([{ n: "9007199254740992" }]), null); assert.equal(parseCountBinding([]), null); assert.equal(parseCountBinding(undefined), null); }); -test("reconcile reports exact, unclassified, and cross-typed deltas", () => { +test("reconcile reports the net difference without assigning a cause", () => { const exact = emptyLayerCounts(); exact.typedTotal = 312; exact.kinds = { @@ -73,7 +102,7 @@ test("reconcile reports exact, unclassified, and cross-typed deltas", () => { assert.deepEqual(reconcile(unknownTotal), { sum: 30, delta: null }); }); -test("reconciliation line names the kinds and surfaces any delta", () => { +test("reconciliation line names complete kinds without claiming a partition", () => { const layer = emptyLayerCounts(); layer.typedTotal = 312; layer.kinds = { @@ -84,19 +113,21 @@ test("reconciliation line names the kinds and surfaces any delta", () => { claims: 0, code: 0, }; - // Zero-count kinds are omitted; the 1-entity shortfall is shown, not hidden. + // Zero-count kinds are omitted from the prose but required for completeness. assert.equal( reconciliationLine(layer), - "312 entities = 30 decisions · 240 evidence · 11 people & agents · 30 capture runs · 1 other", + "312 typed entities · 311 listed type assignments: 30 decisions · 240 evidence · 11 people & agents · 30 capture runs", ); layer.kinds.claims = 1; assert.equal( reconciliationLine(layer), - "312 entities = 30 decisions · 240 evidence · 11 people & agents · 30 capture runs · 1 claims", + "312 typed entities · 312 listed type assignments: 30 decisions · 240 evidence · 11 people & agents · 30 capture runs · 1 claims", ); - // No total → no line; no measured kinds → no line. + // No total or a partial query battery → no misleading reconciliation. const empty = emptyLayerCounts(); assert.equal(reconciliationLine(empty), null); empty.typedTotal = 10; assert.equal(reconciliationLine(empty), null); + empty.kinds = { decisions: 10 }; + assert.equal(reconciliationLine(empty), null); }); diff --git a/desktop/src/features/dkg-memory/subgraphCounts.ts b/desktop/src/features/dkg-memory/subgraphCounts.ts index 8497220fed..4f579cd90b 100644 --- a/desktop/src/features/dkg-memory/subgraphCounts.ts +++ b/desktop/src/features/dkg-memory/subgraphCounts.ts @@ -1,29 +1,30 @@ // Entity accounting for the memory panel (issue OriginTrail/buzz-dkg-beta#13). // -// The model: every chip the panel shows — a decision kind, an agent, code — -// is a SUB-GRAPH of the channel's Context Graph, and the number it carries is -// that sub-graph's entity count. Kind sub-graphs partition the typed-entity -// space, so their counts SUM to the layer's entity total; when the data -// breaks that invariant (multi-typed or unclassified subjects) the delta is -// reported, never hidden. "Entity" follows the DKG node UI's own semantics -// (`packages/node-ui` buildEntities): all sorts of entities, not decisions. +// The model: each count describes a typed or attributed slice of the channel's +// Context Graph. The typed slices are not assumed to be a partition: an entity +// may have multiple listed types or only types outside this list. "Entity" +// follows the DKG node UI's own semantics (`packages/node-ui` buildEntities): +// all sorts of entities, not decisions. // // Everything here must stay inside the relay's semantic-query budget: exact // type/predicate IRIs only, one aggregate per query, no GROUP BY, no GRAPH -// clauses, LIMIT on every SELECT — which is why the battery is many small -// queries instead of one breakdown query. +// clauses, LIMIT on every SELECT. Aggregate LIMITs satisfy the relay policy but +// do not bound scan work, so contributor queries are validated and capped. const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; const BUZZ = "https://w3id.org/buzz-dkg/buzz#"; const NOSTR = "https://w3id.org/buzz-dkg/nostr#"; const PROV = "http://www.w3.org/ns/prov#"; +/** Maximum participant-specific aggregates in one panel refresh. */ +export const MAX_CONTRIBUTOR_COUNT_QUERIES = 16; + export const ENTITY_KINDS = [ { key: "decisions", label: "decisions", type: `${BUZZ}DecisionCluster` }, { key: "evidence", label: "evidence", type: `${NOSTR}Event` }, { key: "agents", label: "people & agents", type: `${PROV}Agent` }, { key: "activities", label: "capture runs", type: `${BUZZ}Distillation` }, { key: "claims", label: "claims", type: `${BUZZ}Claim` }, - { key: "code", label: "code", type: `${BUZZ}Commit` }, + { key: "code", label: "commits", type: `${BUZZ}Commit` }, ] as const; export type EntityKindKey = (typeof ENTITY_KINDS)[number]["key"]; @@ -31,37 +32,50 @@ export type EntityKindKey = (typeof ENTITY_KINDS)[number]["key"]; export interface CountQuery { key: string; sparql: string; + view: "both" | "shared"; } function countByType(typeIri: string): string { return `SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE { ?s <${RDF}type> <${typeIri}> } LIMIT 25`; } -/** - * The bounded query battery: one aggregate per kind, the typed-entity total, - * and one attribution aggregate per contributor (their sub-graph size). - */ +/** Return canonical contributor keys that are safe to interpolate into IRIs. */ +export function boundedContributorPubkeys( + contributorPubkeys: string[], +): string[] { + return [...new Set(contributorPubkeys)] + .filter((pubkey) => /^[0-9a-f]{64}$/.test(pubkey)) + .sort() + .slice(0, MAX_CONTRIBUTOR_COUNT_QUERIES); +} + +/** Build the bounded aggregate battery for types and contributor slices. */ export function countQueries(contributorPubkeys: string[]): CountQuery[] { const queries: CountQuery[] = [ { key: "typedTotal", sparql: `SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE { ?s <${RDF}type> ?t } LIMIT 25`, + view: "both", }, ...ENTITY_KINDS.map((kind) => ({ key: kind.key, sparql: countByType(kind.type), + view: "both" as const, })), ]; - for (const pubkey of contributorPubkeys) { + for (const pubkey of boundedContributorPubkeys(contributorPubkeys)) { queries.push({ key: `agent:${pubkey}`, sparql: `SELECT (COUNT(DISTINCT ?e) AS ?n) WHERE { ?e <${PROV}wasAttributedTo> } LIMIT 25`, + // Contributor chips describe shared channel memory. Avoid an unused VM + // traversal for every participant. + view: "shared", }); } return queries; } -/** Extract the integer from a SPARQL aggregate binding in any wire shape. */ +/** Extract a non-negative safe integer from a SPARQL aggregate binding. */ export function parseCountBinding( bindings: Record[] | undefined, ): number | null { @@ -73,8 +87,11 @@ export function parseCountBinding( ? String((raw as { value: unknown }).value) : null; if (text === null) return null; - const match = text.match(/-?\d+/); - return match ? Number.parseInt(match[0], 10) : null; + const match = text.trim().match(/^(?:"([0-9]+)"(?:\^\^<[^>]+>)?|([0-9]+))$/); + const digits = match?.[1] ?? match?.[2]; + if (!digits) return null; + const value = Number(digits); + return Number.isSafeInteger(value) ? value : null; } export interface LayerEntityCounts { @@ -93,10 +110,9 @@ export function emptyLayerCounts(): LayerEntityCounts { } /** - * Kind counts vs the layer total. `delta > 0` means subjects no kind claims - * (unclassified types); `delta < 0` means subjects counted by more than one - * kind. Either way the caller shows it — the invariant is verified in the - * UI, not assumed. + * Compare distinct typed entities with assignments to the listed types. + * `delta` is only a net difference; it cannot distinguish unlisted types from + * overlapping listed types, which can offset one another. */ export function reconcile(layer: LayerEntityCounts): { sum: number; @@ -112,9 +128,14 @@ export function reconcile(layer: LayerEntityCounts): { }; } -/** Human line for the dashboard: "312 entities = 30 decisions · …". */ +/** Human line that does not claim the listed types form an exact partition. */ export function reconciliationLine(layer: LayerEntityCounts): string | null { if (layer.typedTotal === null) return null; + if ( + !ENTITY_KINDS.every((kind) => typeof layer.kinds[kind.key] === "number") + ) { + return null; + } const parts: string[] = []; for (const kind of ENTITY_KINDS) { const count = layer.kinds[kind.key]; @@ -122,13 +143,7 @@ export function reconciliationLine(layer: LayerEntityCounts): string | null { parts.push(`${count} ${kind.label}`); } } - if (parts.length === 0) return null; - const { delta } = reconcile(layer); - const tail = - delta === 0 - ? "" - : delta && delta > 0 - ? ` · ${delta} other` - : ` · ${-(delta ?? 0)} cross-typed`; - return `${layer.typedTotal} entities = ${parts.join(" · ")}${tail}`; + const { sum } = reconcile(layer); + const breakdown = parts.length > 0 ? `: ${parts.join(" · ")}` : ""; + return `${layer.typedTotal} typed entities · ${sum} listed type assignments${breakdown}`; } diff --git a/desktop/src/features/dkg-memory/ui/MemoryOverview.tsx b/desktop/src/features/dkg-memory/ui/MemoryOverview.tsx index c9a525005c..c700aa458a 100644 --- a/desktop/src/features/dkg-memory/ui/MemoryOverview.tsx +++ b/desktop/src/features/dkg-memory/ui/MemoryOverview.tsx @@ -57,8 +57,8 @@ export function MemoryOverview({ const topicCount = (data.subgraphs ?? []).filter( (subgraph) => subgraph.entityCount > 0, ).length; - // Entity accounting (buzz-dkg-beta#13): kind and per-agent sub-graph - // counts whose sum reconciles against the layer's typed-entity total. + // Entity accounting (buzz-dkg-beta#13): typed and attributed slices are + // compared without assuming that the listed types form a partition. const entityCounts = useEntityCounts(channelId, contributorPubkeys); const swmCounts = entityCounts.data?.SWM ?? null; const swmReconciliation = swmCounts ? reconciliationLine(swmCounts) : null; @@ -138,24 +138,25 @@ export function MemoryOverview({ tag === "WM" ? null : (entityCounts.data?.[tag]?.typedTotal ?? null); - // Never present a display-bounded list length as a total - // (buzz-dkg-beta#13): an uncapped count wins; a measured - // entity total is next; a bare list only ever renders as a - // lower bound. + // Provider *Count fields count layer graphs, not entities. Use + // the measured typed-entity total when available and keep the + // graph-count fallback explicit in the tooltip. const count = - declaredCount ?? measuredTotal ?? + declaredCount ?? (entries ? entries.length === 0 ? 0 : `≥${entries.length}` : null); + const countUnit = + measuredTotal !== null ? "typed entities" : "memory graphs"; const meta = LAYER_META[tag]; return (
@@ -171,14 +172,14 @@ export function MemoryOverview({ })}

- {sortedDecisions.length} decisions · {topicCount} named topics ·{" "} - {(data.contributors ?? []).length} people & agents + {sortedDecisions.length} loaded decisions · {topicCount} named + topics · {(data.contributors ?? []).length} people & agents

{swmReconciliation && (

Shared memory: {swmReconciliation}

@@ -213,12 +214,12 @@ export function MemoryOverview({ decisions: sortedDecisions, }) } - title="Open all captured decisions as a traces timeline" + title="Open the loaded decisions as a traces timeline" data-testid="dkg-subgraph-all-decisions" > All decisions - {swmCounts?.kinds.decisions ?? sortedDecisions.length} + {sortedDecisions.length} @@ -263,9 +264,9 @@ export function MemoryOverview({ const name = profiles.data?.[contributor.pubkey] ?? shortPk(contributor.pubkey); - // The chip is this agent's sub-graph; its number is the - // count of entities attributed to them in shared memory, - // falling back to raw event count until measured. + // Prefer the measured shared-memory entity slice. When a + // contributor was outside the bounded count battery, keep + // the existing event count and label that fallback honestly. const agentEntities = swmCounts?.perAgent[contributor.pubkey] ?? null; return ( @@ -284,7 +285,7 @@ export function MemoryOverview({ title={ agentEntities !== null ? `${agentEntities} entities in ${name}'s sub-graph — open as Traces & Graph` - : `Open ${name}'s decisions and evidence as Traces & Graph` + : `${contributor.events} captured events — open ${name}'s loaded decisions and evidence as Traces & Graph` } data-testid={`dkg-contributor-${contributor.pubkey}`} > diff --git a/desktop/tests/e2e/dkg-memory-fallback.spec.ts b/desktop/tests/e2e/dkg-memory-fallback.spec.ts index 2edf08c505..dc50459384 100644 --- a/desktop/tests/e2e/dkg-memory-fallback.spec.ts +++ b/desktop/tests/e2e/dkg-memory-fallback.spec.ts @@ -357,7 +357,7 @@ test("named subgraph lens queries the provider and keeps Graph available", async ); }); -test("entity accounting: chips carry sub-graph counts that reconcile to the layer total", async ({ +test("entity accounting: bounded sub-graph counts report typed assignments", async ({ page, }) => { const pubkey = "c9f4f94b87273745cc34b9d8b15847b27afb90eb9bb7c8a4363703821a0"; @@ -426,14 +426,17 @@ test("entity accounting: chips carry sub-graph counts that reconcile to the laye }); const panel = await openMemoryPanel(page); - // The reconciliation line proves the invariant: kind sub-graphs sum to the - // layer's entity total, with zero-count kinds omitted. + // Complete kind counts are compared with the entity total without claiming + // the types form a partition. Zero-count kinds are omitted from the prose. await expect(panel.getByTestId("dkg-entity-reconciliation")).toHaveText( - "Shared memory: 13 entities = 3 decisions · 6 evidence · 1 people & agents · 3 capture runs", + "Shared memory: 13 typed entities · 13 listed type assignments: 3 decisions · 6 evidence · 1 people & agents · 3 capture runs", { timeout: 15_000 }, ); - // SWM layer card prefers the gateway's uncapped count (fixture sends 3). - await expect(panel.getByText("3", { exact: true }).first()).toBeVisible(); + // SWM layer card prefers the measured entity total over the provider's + // SWMCount, which counts graphs rather than entities. + await expect( + panel.getByTitle("Shared: channel memory — typed entities"), + ).toContainText("13"); // The agent chip carries its sub-graph's entity count (6), not raw events (99). const chip = panel.getByTestId(`dkg-contributor-${pubkey}`); await expect(chip).toContainText("6");