Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion desktop/src/features/dkg-memory/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -12,6 +13,13 @@ import {
memoryProposalProgress,
normalizeMemoryProposalResponse,
} from "./proposalState";
import {
countQueries,
emptyLayerCounts,
parseCountBinding,
type EntityCounts,
type EntityKindKey,
} from "./subgraphCounts";

export {
explorerSource,
Expand Down Expand Up @@ -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<Record<"WMCount" | "SWMCount" | "VMCount", number>>;
decisions?: DecisionEntry[];
Expand Down Expand Up @@ -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<EntityCounts> {
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.";
Expand Down
49 changes: 34 additions & 15 deletions desktop/src/features/dkg-memory/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -100,13 +113,6 @@ export function useReputationSummary(
});
}

import {
fetchDiscoveryFromReceipts,
fetchEvidence,
fetchProfileNames,
fetchSubgraphGraph,
} from "./api";

export function useEvidence(
channelId: string | null,
cg: string | null | undefined,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
});
}
133 changes: 133 additions & 0 deletions desktop/src/features/dkg-memory/subgraphCounts.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
/<https:\/\/w3id\.org\/buzz-dkg\/buzz#DecisionCluster>/,
);
const agent = queries.find((q) => q.key === `agent:${"aa".repeat(32)}`);
assert.match(agent.sparql, /wasAttributedTo> <urn:nostr:pubkey:a{64}>/);
// 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 <http:\/\/www\.w3\.org\/1999\/02\/22-rdf-syntax-ns#type> \?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"^^<http://www.w3.org/2001/XMLSchema#integer>' },
]),
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);
});
Loading
Loading