diff --git a/packages/extension/src/manager/correlation-model.ts b/packages/extension/src/manager/correlation-model.ts index bd3aef0..084aeb9 100644 --- a/packages/extension/src/manager/correlation-model.ts +++ b/packages/extension/src/manager/correlation-model.ts @@ -133,3 +133,67 @@ export function crossingWords(row: RankedFinding): string | null { return null; } } + +// ## A finding with no attribute is about faces +// +// The agent reports a value that only faces hold — typed into a face as an +// inline entry, or shown in place of an attribute by an override — with no +// `attributeId`, because there is no attribute. The map places findings on +// attribute cards, so without this such a finding was counted in the summary +// and drawn nowhere: "2 links" above one visible one, which is the numbers not +// closing, the thing `tallyContexts` exists to prevent one band up. + +/** One face a face-only finding names. */ +export interface NamedFace { + profileId: string; + /** The holder's own name for the face, where the console holds the record. */ + name: string | null; + /** The context a context-local face lives in. The console does not list + * those faces, so the context is how the holder finds it. */ + contextId: string | null; +} + +/** Findings the map cannot place on an attribute card. */ +export function faceOnly(ranked: readonly RankedFinding[]): RankedFinding[] { + return ranked.filter((r) => !r.finding.attributeId); +} + +/** + * The faces one finding names, each once, in the agent's order. + * + * A face appears in `sharedWith` once per context it is worn in, and the + * holder is being told *which faces*, so repeats collapse. A profile the + * console holds is named; one it does not is a context-local face, and its + * context is the only handle there is. + */ +export function facesNamed( + finding: CorrelationFinding, + profiles: readonly { profileId: string; name: string }[], +): NamedFace[] { + const out: NamedFace[] = []; + const seen = new Set(); + for (const s of (finding.sharedWith ?? []) as { profileId?: string; contextId?: string }[]) { + if (!s.profileId || seen.has(s.profileId)) continue; + seen.add(s.profileId); + const held = profiles.find((p) => p.profileId === s.profileId); + out.push({ + profileId: s.profileId, + name: held?.name ?? null, + contextId: held ? null : (s.contextId ?? null), + }); + } + return out; +} + +/** "Market and a face kept only in ctx-b" — names, never ids, where there are names. */ +export function facesWords(faces: readonly NamedFace[]): string { + const words = faces.map((f) => + f.name !== null + ? f.name + : f.contextId !== null + ? `a face kept only in ${f.contextId}` + : "a face this console cannot name", + ); + if (words.length <= 1) return words[0] ?? "No face"; + return `${words.slice(0, -1).join(", ")} and ${words[words.length - 1]}`; +} diff --git a/packages/extension/src/manager/panes/persona-map.tsx b/packages/extension/src/manager/panes/persona-map.tsx index 4ce40a7..3ebce3b 100644 --- a/packages/extension/src/manager/panes/persona-map.tsx +++ b/packages/extension/src/manager/panes/persona-map.tsx @@ -56,7 +56,15 @@ import type { PoolFacet } from "@openvtc/pnm-core/admin"; import { familyOf, familyStyle, FAMILY_ORDER, type Family } from "../attribute-family.js"; import { worldHue } from "../world-colour.js"; import { worldsOfAttribute, worldOfFace, movePlan } from "../world-model.js"; -import { rankFindings, tallyCrossings, crossingWords, type RankedFinding } from "../correlation-model.js"; +import { + rankFindings, + tallyCrossings, + crossingWords, + faceOnly, + facesNamed, + facesWords, + type RankedFinding, +} from "../correlation-model.js"; import { provenanceWords, labelSaysSomethingElse, staleWords } from "../attribute-words.js"; import { unappliedClaimTypes } from "@openvtc/pnm-core/persona"; import { @@ -900,7 +908,7 @@ export function IdentityMap({ {checking && checking !== "Asking your agent…" && {checking}} {findings && findings.length === 0 && ( - Your agent finds no two attributes holding the same value. That is an answer, not an empty result. + Your agent finds no value shown in two places — not by two attributes, and not typed into two faces. That is an answer, not an empty result. )} {findings && findings.length > 0 && (() => { // Counted from one place, so the numbers cannot disagree with the rows @@ -933,6 +941,18 @@ export function IdentityMap({ a world. Making some worlds is what answers that. )} + {/* A value only faces hold has no attribute card to sit on, so it + is listed here or it is counted above and shown nowhere. */} + {faceOnly(ranked).map((r, i) => { + const words = crossingWords(r); + return ( + + {facesWords(facesNamed(r.finding, profiles))} show the same + value, typed into each rather than drawn from your attributes.{" "} + {words ?? "Anyone who sees both knows they are the same person."} + + ); + })} ); diff --git a/packages/extension/tests/manager-correlation-model.test.mts b/packages/extension/tests/manager-correlation-model.test.mts index 77f8c14..364b0df 100644 --- a/packages/extension/tests/manager-correlation-model.test.mts +++ b/packages/extension/tests/manager-correlation-model.test.mts @@ -115,3 +115,28 @@ test("severity is never touched by any of this", () => { assert.equal(ranked.find((r) => r.crossing === "within")!.finding.severity, "high"); assert.equal(ranked.find((r) => r.crossing === "crosses")!.finding.severity, "low"); }); + +test("a finding with no attribute is kept out of the card map and names its faces", async () => { + const { faceOnly, facesNamed, facesWords } = await import("../src/manager/correlation-model.ts"); + const onCard = finding({ attributeId: "01A", why: "card" }); + const faces = finding({ + why: "faces", + sharedWith: [ + { profileId: "01F", contextId: "ctx-a", personaDid: "did:x" }, + { profileId: "01F", contextId: "ctx-c", personaDid: "did:y" }, + { profileId: "01L", contextId: "ctx-b" }, + ], + }); + const ranked = rankFindings([onCard, faces], WORLDS); + assert.deepEqual(faceOnly(ranked).map((r) => r.finding.why), ["faces"]); + + const named = facesNamed(faces, [{ profileId: "01F", name: "Market" }]); + assert.deepEqual(named, [ + // Worn in two contexts, named once: the holder is being told which faces. + { profileId: "01F", name: "Market", contextId: null }, + // Not a face the console lists — a context-local one — so its context is + // the handle. + { profileId: "01L", name: null, contextId: "ctx-b" }, + ]); + assert.equal(facesWords(named), "Market and a face kept only in ctx-b"); +});