diff --git a/AGENTS.md b/AGENTS.md index ddb9edb..7a8681e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2565,14 +2565,19 @@ kind-10040 provider designation** (`useSetTrustProvider`: custom npub/hex + `wss://` relay, or clear to fall back to the community provider). The local (non-provider) ladder is computed **once per viewer** by `useViewerGraph` and shared, so `TrustBadge` no longer fires a per-card `useTrustScore` graph query. +The local ladder has a **confidence gate**: `useViewerGraph` counts distinct +follows per friend-of-a-friend and `deriveLocalScore` only verifies at +`FOF_MIN_RATERS` (2) or more, so a lone endorsement is not trust; provider +assertions are unaffected. **Mute landed:** `useMuteList` + `useMuteUser` (NIP-51 kind-10000, optimistic via `lib/localMutes.ts`) feed `useAdminModeration`'s new `mutedPubkeys`, so the shared `filterAdminHidden` drops muted authors across every list; the flagged notice has a Mute action. **Alerts landed:** `useNetworkAlerts` + `/alerts` (`pages/NetworkAlertsPage.tsx`) list accounts you follow that trusted people reported/muted, with Ignore/Unfollow/Mute/Report; Settings → Trust links to it -with a count (ignored set in `skatespots:trust:alerts-ignored`). Still open: the -confidence-gated scoring model. Check +with a count (ignored set in `skatespots:trust:alerts-ignored`). Still open: +running our own GrapeRank-style scorer in `cvm-server` — the full confidence +model is provider-side today. Check `TRUST_SCORE.md` before touching `useTrustScore` / `useRelatrScore` / `trustFilter`. diff --git a/TRUST_SCORE.md b/TRUST_SCORE.md index 5f93771..1dd3937 100644 --- a/TRUST_SCORE.md +++ b/TRUST_SCORE.md @@ -84,7 +84,16 @@ flagged notice's reporter breakdown, and Settings → Trust. `useMuteUser`, `ReportDialog`) — inform, never auto-act. - Settings → Trust links to it with a live count. -Still open: the confidence-gated scoring model. +## Sixth pass — local confidence gate (landed) + +- The fallback local ladder now gates a friend-of-a-friend behind a confidence + threshold: `useViewerGraph` counts distinct follows per friend-of-a-friend, and + `deriveLocalScore` returns `verified` only at `FOF_MIN_RATERS` (2) or more. A + lone endorsement no longer reads as trust. +- Provider assertions are unaffected: the full GrapeRank confidence model lives + in the provider and we consume it via NIP-85. Running our own scorer (planned + for `cvm-server`, publishing kind-30382) remains the open item — it is the only + way to get the real confidence gate and a server-published score. diff --git a/src/hooks/useViewerGraph.ts b/src/hooks/useViewerGraph.ts index 71eda2d..210a560 100644 --- a/src/hooks/useViewerGraph.ts +++ b/src/hooks/useViewerGraph.ts @@ -9,11 +9,14 @@ const HOUSE_GRAPH_PUBKEY = '20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2d const MAX_FOLLOWS = 100; +/** Distinct follows that must agree before a friend-of-a-friend counts as a vouch. */ +const FOF_MIN_RATERS = 2; + export interface ViewerGraph { /** Pubkeys the graph owner follows. */ following: Set; - /** Friends-of-friends: pubkeys followed by someone the owner follows. */ - fof: Set; + /** Friends-of-friends, keyed by how many of the owner's follows follow them. */ + fofCounts: Map; } /** @@ -44,28 +47,33 @@ export function useViewerGraph() { if (tag[0] === 'p' && tag[1]) following.add(tag[1]); } - const fof = new Set(); + const fofCounts = new Map(); if (following.size > 0) { const fofEvents = onlyVerified(await nostr.query( [{ kinds: [3], authors: [...following].slice(0, MAX_FOLLOWS), limit: MAX_FOLLOWS }], { signal }, )); for (const event of fofEvents) { + // Count DISTINCT raters: guard against a malformed list repeating a p tag. + const seen = new Set(); for (const [name, value] of event.tags) { - if (name === 'p' && value) fof.add(value); + if (name !== 'p' || !value || seen.has(value)) continue; + seen.add(value); + fofCounts.set(value, (fofCounts.get(value) ?? 0) + 1); } } } - return { following, fof }; + return { following, fofCounts }; }, }); } /** - * The same 100 / 90 / 60 / 0 ladder the trust badges used, minus the NIP-05 tier - * (30) which classified as `unknown` anyway. `self` is the real viewer only — - * never the house graph owner. + * The 100 / 90 / 60 / 0 ladder the trust badges used, minus the NIP-05 tier + * (30) which classified as `unknown` anyway, and with a confidence gate: a + * friend-of-a-friend counts only when at least `FOF_MIN_RATERS` distinct follows + * agree. `self` is the real viewer only — never the house graph owner. */ export function deriveLocalScore( graph: ViewerGraph | undefined, @@ -76,6 +84,8 @@ export function deriveLocalScore( if (viewer && pubkey === viewer) return 100; if (!graph) return undefined; if (graph.following.has(pubkey)) return 90; - if (graph.fof.has(pubkey)) return 60; + // Confidence gate: a single friend-of-a-friend is not a network vouch. Require + // at least FOF_MIN_RATERS distinct follows to agree before calling it verified. + if ((graph.fofCounts.get(pubkey) ?? 0) >= FOF_MIN_RATERS) return 60; return 0; }