diff --git a/desktop/src/features/dkg-memory/api.ts b/desktop/src/features/dkg-memory/api.ts index db370b2ba2..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,6 +235,48 @@ export async function fetchSemanticQuery( }); } +/** + * Entity counts per layer, per kind sub-graph, and per contributor sub-graph + * (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 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 = 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; + 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..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. @@ -210,3 +208,24 @@ export function useDiscoveryFallback( staleTime: 60 * 1000, }); } + +/** + * 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 boundedPubkeys = boundedContributorPubkeys(contributorPubkeys); + const pubkeyKey = boundedPubkeys.join(","); + return useQuery({ + queryKey: ["dkg-memory", "entity-counts", channelId, pubkeyKey], + 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 new file mode 100644 index 0000000000..3190082edd --- /dev/null +++ b/desktop/src/features/dkg-memory/subgraphCounts.test.mjs @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + MAX_CONTRIBUTOR_COUNT_QUERIES, + boundedContributorPubkeys, + 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/); + } + 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, + //, + ); + 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("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([ + { n: '"30"^^' }, + ]), + 30, + ); + 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 the net difference without assigning a cause", () => { + 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 complete kinds without claiming a partition", () => { + 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 from the prose but required for completeness. + assert.equal( + reconciliationLine(layer), + "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 typed entities · 312 listed type assignments: 30 decisions · 240 evidence · 11 people & agents · 30 capture runs · 1 claims", + ); + // 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 new file mode 100644 index 0000000000..4f579cd90b --- /dev/null +++ b/desktop/src/features/dkg-memory/subgraphCounts.ts @@ -0,0 +1,149 @@ +// Entity accounting for the memory panel (issue OriginTrail/buzz-dkg-beta#13). +// +// 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. 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: "commits", type: `${BUZZ}Commit` }, +] as const; + +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`; +} + +/** 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 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 a non-negative safe integer from a SPARQL aggregate binding. */ +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.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 { + typedTotal: number | null; + kinds: Partial>; + perAgent: Record; +} + +export interface EntityCounts { + SWM: LayerEntityCounts; + VM: LayerEntityCounts; +} + +export function emptyLayerCounts(): LayerEntityCounts { + return { typedTotal: null, kinds: {}, perAgent: {} }; +} + +/** + * 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; + 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 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]; + if (typeof count === "number" && count > 0) { + parts.push(`${count} ${kind.label}`); + } + } + 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/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..c700aa458a 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): 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; const showWebOfTrust = shouldShowDkgWebOfTrustUi(trustAvailable); return ( @@ -127,13 +133,30 @@ 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); + // 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 = + measuredTotal ?? + declaredCount ?? + (entries + ? entries.length === 0 + ? 0 + : `≥${entries.length}` + : null); + const countUnit = + measuredTotal !== null ? "typed entities" : "memory graphs"; const meta = LAYER_META[tag]; return (
@@ -149,9 +172,18 @@ 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} +

+ )} {latestDecision && ( @@ -182,7 +214,7 @@ 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 @@ -232,6 +264,11 @@ export function MemoryOverview({ const name = profiles.data?.[contributor.pubkey] ?? shortPk(contributor.pubkey); + // 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 ( ); diff --git a/desktop/tests/e2e/dkg-memory-fallback.spec.ts b/desktop/tests/e2e/dkg-memory-fallback.spec.ts index 091105f435..dc50459384 100644 --- a/desktop/tests/e2e/dkg-memory-fallback.spec.ts +++ b/desktop/tests/e2e/dkg-memory-fallback.spec.ts @@ -356,3 +356,90 @@ test("named subgraph lens queries the provider and keeps Graph available", async "engineering", ); }); + +test("entity accounting: bounded sub-graph counts report typed assignments", 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); + + // 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 typed entities · 13 listed type assignments: 3 decisions · 6 evidence · 1 people & agents · 3 capture runs", + { timeout: 15_000 }, + ); + // 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"); + await expect(chip).not.toContainText("99"); + await waitForAnimations(page); +});