diff --git a/cli/README.md b/cli/README.md index 88eeeea65d..de4afc3822 100644 --- a/cli/README.md +++ b/cli/README.md @@ -384,7 +384,7 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | `amy profile show [USER]` | Print kind:0 metadata. USER accepts npub/nprofile/hex/NIP-05; defaults to self. | | `amy profile edit --name … --about … --picture URL …` | Patch and re-publish your kind:0. | | `amy follow USER` / `amy unfollow USER` | Add/remove USER from your kind:3 contact list (fetches the freshest list first). | -| `amy graperank [OBSERVER] [--max-depth N] [--offline] [--publish]` | Compute GrapeRank web-of-trust scores (0..1) over the follow/mute/report graph, crawled via the outbox model; optionally publish results as NIP-85 kind:30382 cards (unchanged ranks are skipped). | +| `amy graperank [OBSERVER] [--offline] [--publish]` | Compute GrapeRank web-of-trust scores (0..1) over the follow/mute/report graph. Exhaustively crawls each user's kind:10002 outbox for their latest kind:3/10000/1984 until every discovered user is checked (no user cap); optionally publishes results as NIP-85 kind:30382 cards (unchanged ranks are skipped). | | `amy graperank register [PROVIDER] [--service KIND:TAG] [--relay URL]` | Declare a NIP-85 provider in your kind:10040 so clients can discover it (default: self as the `30382:rank` provider). | | `amy graperank providers [USER]` | List a user's declared NIP-85 trusted providers (public + your own private entries). | diff --git a/cli/plans/2026-07-06-graperank-brainstorm-parity.md b/cli/plans/2026-07-06-graperank-brainstorm-parity.md index 3571de22ec..b31e51975f 100644 --- a/cli/plans/2026-07-06-graperank-brainstorm-parity.md +++ b/cli/plans/2026-07-06-graperank-brainstorm-parity.md @@ -87,11 +87,13 @@ It is **data**, not math: the observer's trust graph) contributes **zero**. Only follows/mutes/reports authored by users *inside* the follow graph move a score — and those are exactly the users our crawl discovers and whose kind 3/10000/1984 we fetch. - So the effective scoring input is the same, provided the crawl runs to - convergence (our default) rather than a shallow `--max-depth`. -2. **Fringe users / crawl gaps.** Relay timeouts that drop a contact list, or a - `--max-users` cap, remove edges and shift nearby scores. The injector mitigates - this with a two-stage model mirroring the app's `pickRelaysToLoadUsers`: + So the effective scoring input is the same, as long as the crawl actually + checks every discovered user's outbox — which it now does exhaustively (no + user cap, retrying an unreachable outbox up to `--max-attempts` times). +2. **Fringe users / crawl gaps.** A relay timeout that drops a contact list + removes edges and shifts nearby scores. The injector mitigates this with a + two-stage model mirroring the app's `pickRelaysToLoadUsers`, plus a + completeness loop that retries until every user's outbox has been checked: - **Relay-list discovery** (kind:10002) queries the account's relays + bootstrap + event-finder + **indexer relays** (purplepag.es, coracle, …). Indexers aggregate kind:10002 (and kind:0) for the whole network, so this is @@ -101,9 +103,9 @@ It is **data**, not math: contact lists we crawl) and general-purpose relays as a best-effort fallback when the outbox is unknown/down. **Indexers are not used for content** — they don't serve those kinds; kind:3/mutes/reports live only on the user's outbox. - A per-hop **retry pass** re-queries any member whose contact list still didn't - arrive against that hint + general-relay set. Remaining mitigation levers: a - full crawl (default) and a generous `--timeout`. + The crawl loops round by round, retrying any member whose contact list still + didn't arrive (up to `--max-attempts`), until every discovered user's outbox + has been checked. Remaining mitigation lever: a generous `--timeout`. 3. **Convergence precision.** Both stop at delta 0.0001; residual error is < ~0.0001 in influence ⇒ < ~0.01 rank points ⇒ identical integer `rank`. 4. **Seeding.** Their hop-distance seed vs our zero seed — same fixed point, no @@ -113,8 +115,9 @@ It is **data**, not math: - **Keep the current DEFAULT params** — they are byte-for-byte the Brainstorm DEFAULT preset. No change needed for parity. -- **Crawl to convergence** (the default) rather than a small `--max-depth`; a - shallow crawl is the single biggest source of drift. +- **The crawl is exhaustive by default** (no user cap; every reachable user's + outbox is checked, unreachable outboxes retried up to `--max-attempts`). An + incomplete crawl is the single biggest source of drift, so avoid capping it. - **Optional, for fuller parity (not required for close scores):** - Add `--preset default|permissive|restrictive`. DEFAULT is confirmed; the PERMISSIVE / RESTRICTIVE numbers are DB-seeded in `brainstorm_server` (an diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 08667592ad..92f73a8a3e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -528,14 +528,16 @@ private fun printUsage() { | |Web of Trust (GrapeRank): | graperank [OBSERVER] compute subjective trust scores (0..1) for every - | [--max-depth N] [--max-users N] user reachable in the follow/mute/report graph, - | [--limit N] [--min-score X] crawled via the outbox model until no new users - | [--rigor X] [--attenuation X] appear (OBSERVER: npub|nprofile|hex|name@domain, - | [--offline] [--timeout SECS] default: active account). --offline scores from - | [--publish] [--min-rank N] the local store only. --publish writes NIP-85 - | [--publish-limit N] [--publish-relay URL] kind:30382 trusted-assertion cards - | (rank = round(score*100)) for each user at or - | above --min-rank (unchanged ranks are skipped). + | [--limit N] [--min-score X] user reachable in the follow/mute/report graph. + | [--rigor X] [--attenuation X] Crawls each user's kind:10002 outbox for their + | [--max-attempts N] [--max-rounds N] latest kind:3/10000/1984 until every discovered + | [--offline] [--timeout SECS] user has been checked (no user cap; --max-attempts + | [--publish] [--min-rank N] bounds retries of an unreachable outbox, default 3). + | [--publish-limit N] [--publish-relay URL] OBSERVER: npub|nprofile|hex|name@domain (default: + | active account). --offline scores from the local + | store only. --publish writes NIP-85 kind:30382 + | cards (rank = round(score*100)) for each user at + | or above --min-rank (unchanged ranks skipped). | graperank register [PROVIDER] declare a NIP-85 provider in your kind:10040 so | [--service KIND:TAG] [--relay URL] clients can discover it (default: self as the | [--private] 30382:rank provider at your first outbox relay). diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt index 4d125e3592..78eaebb322 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt @@ -58,8 +58,10 @@ import kotlin.math.roundToInt * observer has full self-trust). It crawls the follow graph outward using the * outbox model — each user's kind:10002 write relays are located first, then * their kind:3 / kind:10000 / kind:1984 events are fetched from *their own* - * relays — until no new users appear (typically ~8 hops), then runs the scoring - * engine in `commons/wot`. + * relays. The crawl is exhaustive: it keeps going, with no user cap, until every + * discovered user's outbox has been checked and their contact list pulled (an + * unreachable outbox is retried up to `--max-attempts` times), then runs the + * scoring engine in `commons/wot`. * * Prints a ranked list (text, or one JSON object under `--json`). With * `--publish`, results are also published as NIP-85 kind:30382 `ContactCardEvent` @@ -100,8 +102,11 @@ object GrapeRankCommand { ): Int { val args = Args(rest) val observerArg = args.positionalOrNull(0) - val maxDepth = args.intFlag("max-depth", 8) - val maxUsers = args.intFlag("max-users", 50_000) + // Crawl to full convergence by default (every reachable user's outbox + // checked). --max-rounds is only a safety backstop; --max-attempts bounds + // how many times we re-try an unreachable user's outbox before giving up. + val maxRounds = args.intFlag("max-rounds", Int.MAX_VALUE) + val maxAttempts = args.intFlag("max-attempts", 3) val limit = args.intFlag("limit", 100) val minScore = args.flag("min-score")?.toDoubleOrNull() ?: 0.0 val offline = args.bool("offline") @@ -123,7 +128,8 @@ object GrapeRankCommand { val graphKinds = listOf(ContactListEvent.KIND, MuteListEvent.KIND, ReportEvent.KIND) - var depthReached = 0 + var rounds = 0 + var relaysContactedCount = 0 val events: List if (offline) { @@ -132,60 +138,71 @@ object GrapeRankCommand { } else { val collected = mutableListOf() val discovered = hashSetOf(observer) - // Per-user relay hints harvested from the `p`-tag relay hints in - // the contact lists we crawl (A's follow of B says where B writes). - // A second discovery tier below each user's kind:10002 outbox. + // Per-user relay hints harvested from the `p`-tag relay hints in the + // contact lists we crawl (A's follow of B says where B writes) — a + // discovery tier below each user's kind:10002 outbox. val relayHints = HashMap>() - var frontier: Set = setOf(observer) + // Users we're finished with: their outbox was queried and we either + // downloaded their kind:3 or ran out of retry attempts. Growing this + // set toward `discovered` is what drives the crawl to completion. + val done = hashSetOf() + val attempts = HashMap() + // The pool of relays we actually route outbox queries to, grown as + // more users' kind:10002 outboxes are discovered. + val relaysContacted = hashSetOf() - for (hop in 0 until maxDepth) { - if (frontier.isEmpty()) break - depthReached = hop + 1 + // Loop until every discovered user has had their outbox checked and + // their kind:3/10000/1984 pulled from it — no user cap. A user whose + // outbox stays unreachable is dropped after --max-attempts tries so + // the crawl still terminates. + while (rounds < maxRounds) { + val pending = discovered.filterNot { it in done } + if (pending.isEmpty()) break + rounds++ - // 1. Locate each frontier member's kind:10002 write relays. - ensureRelayLists(ctx, frontier, relayHints, timeoutMs) + // 1. Resolve kind:10002 outboxes for pending users missing them. + ensureRelayLists(ctx, pending.toSet(), relayHints, timeoutMs) - // 2. Fetch their follows/mutes/reports from those relays. - val filters = routeByOutbox(ctx, frontier, relayHints, graphKinds) + // 2. Pull kind:3/10000/1984 from each pending user's own outbox + // (hints + general relays only when the outbox is unknown). + val before = collected.size + val filters = routeByOutbox(ctx, pending.toSet(), relayHints, graphKinds) + relaysContacted += filters.keys collected += ctx.drain(filters, timeoutMs).map { it.second } - // 3. Completeness retry: any member whose contact list still - // didn't arrive (no kind:10002, or its outbox was down) gets - // re-queried against its relay hints plus the general-purpose - // fallback relays. kind:3/10000/1984 live on the user's own - // outbox — NOT on indexers (those only aggregate kind:10002) — - // so this is best-effort recovery from general relays that may - // hold a copy, not a guaranteed find. - val stillMissing = frontier.filter { ctx.contactsOf(it) == null } - if (stillMissing.isNotEmpty()) { - val retryRelays = contentFallbackRelays(ctx) + stillMissing.flatMap { relayHints[it].orEmpty() } - val retry = - retryRelays.associateWith { - stillMissing.chunked(AUTHORS_PER_FILTER).map { chunk -> Filter(kinds = graphKinds, authors = chunk) } + // 3. Mark done / retry, harvest hints, expand the follow graph. + var downloaded = 0 + var newUsers = 0 + for (pk in pending) { + val contacts = ctx.contactsOf(pk) + if (contacts != null) { + done += pk + downloaded++ + for (tag in contacts.follows()) { + tag.relayUri?.let { relayHints.getOrPut(tag.pubKey) { HashSet() }.add(it) } + if (discovered.add(tag.pubKey)) newUsers++ } - collected += ctx.drain(retry, timeoutMs).map { it.second } - } - - // 4. Harvest relay hints + expand the follow frontier. - val next = hashSetOf() - for (pk in frontier) { - val contacts = ctx.contactsOf(pk) ?: continue - for (tag in contacts.follows()) { - tag.relayUri?.let { relayHints.getOrPut(tag.pubKey) { HashSet() }.add(it) } - if (discovered.size < maxUsers && discovered.add(tag.pubKey)) next += tag.pubKey + } else { + val tries = (attempts[pk] ?: 0) + 1 + attempts[pk] = tries + // Give up once we've exhausted retries: either the user has + // no contact list, or their outbox is unreachable. + if (tries >= maxAttempts) done += pk } } - val recovered = stillMissing.count { ctx.contactsOf(it) != null } System.err.println( - "[graperank] hop ${hop + 1}: frontier=${frontier.size}, recovered=$recovered/${stillMissing.size}, new=${next.size}, total=${discovered.size}", + "[graperank] round $rounds: queried=${pending.size}, +events=${collected.size - before}, " + + "downloaded=$downloaded, newUsers=$newUsers, discovered=${discovered.size}, done=${done.size}", ) - - if (discovered.size >= maxUsers) { - System.err.println("[graperank] reached --max-users=$maxUsers cap; stopping crawl") - break - } - frontier = next } + + relaysContactedCount = relaysContacted.size + val unreached = discovered.count { it !in done || ctx.contactsOf(it) == null } + System.err.println( + "[graperank] crawl complete: ${discovered.size} users discovered, " + + "${discovered.size - unreached} contact lists downloaded, $unreached without one, " + + "$relaysContactedCount relays contacted, $rounds rounds", + ) events = collected } @@ -215,7 +232,8 @@ object GrapeRankCommand { val result = linkedMapOf( "observer" to observer, - "depth_reached" to depthReached, + "crawl_rounds" to rounds, + "relays_contacted" to relaysContactedCount, "graph_users" to graph.users.size, "graph_edges" to graph.edgeCount(), "users_scored" to scores.size,