refactor(cli): drop graperank --max-attempts, hardcode 3 retries

The per-user outbox retry bound doesn't need to be tunable — replace the
--max-attempts flag with a MAX_OUTBOX_ATTEMPTS = 3 constant. Same behaviour,
one fewer knob. Updates usage text, README, and the parity doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
This commit is contained in:
Claude
2026-07-06 18:51:01 +00:00
parent 4ec3da1e86
commit 3f503f0d04
3 changed files with 18 additions and 17 deletions
@@ -89,7 +89,7 @@ It is **data**, not math:
exactly the users our crawl discovers and whose kind 3/10000/1984 we fetch.
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).
user cap, retrying an unreachable outbox a few 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
@@ -104,7 +104,7 @@ It is **data**, not math:
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.
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
didn't arrive (a few times), 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`.
@@ -116,7 +116,7 @@ It is **data**, not math:
- **Keep the current DEFAULT params** — they are byte-for-byte the Brainstorm
DEFAULT preset. No change needed for parity.
- **The crawl is exhaustive by default** (no user cap; every reachable user's
outbox is checked, unreachable outboxes retried up to `--max-attempts`). An
outbox is checked, unreachable outboxes retried a few times). 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
@@ -529,12 +529,11 @@ private fun printUsage() {
|Web of Trust (GrapeRank):
| graperank [OBSERVER] compute subjective trust scores (0..1) for every
| [--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
| [--rigor X] [--attenuation X] Exhaustively crawls each user's kind:10002 outbox
| [--max-rounds N] for their latest kind:3/10000/1984 until every
| [--offline] [--timeout SECS] discovered user has been checked (no user cap).
| [--publish] [--min-rank N] OBSERVER: npub|nprofile|hex|name@domain (default:
| [--publish-limit N] [--publish-relay URL] 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).
@@ -60,8 +60,8 @@ import kotlin.math.roundToInt
* their kind:3 / kind:10000 / kind:1984 events are fetched from *their own*
* 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`.
* unreachable outbox is retried a few 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`
@@ -84,6 +84,10 @@ object GrapeRankCommand {
// Emit a scoring-progress line every this many worklist visits.
private const val SCORE_PROGRESS_STEP = 5_000
// Times we re-query an unreachable user's outbox before giving up on it, so
// the crawl still terminates on a finite graph.
private const val MAX_OUTBOX_ATTEMPTS = 3
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
@@ -103,10 +107,8 @@ object GrapeRankCommand {
val args = Args(rest)
val observerArg = args.positionalOrNull(0)
// 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.
// checked). --max-rounds is only a safety backstop.
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")
@@ -153,8 +155,8 @@ object GrapeRankCommand {
// 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.
// outbox stays unreachable is dropped after MAX_OUTBOX_ATTEMPTS tries
// so the crawl still terminates.
while (rounds < maxRounds) {
val pending = discovered.filterNot { it in done }
if (pending.isEmpty()) break
@@ -187,7 +189,7 @@ object GrapeRankCommand {
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
if (tries >= MAX_OUTBOX_ATTEMPTS) done += pk
}
}
System.err.println(