Files
amethyst/cli/plans/2026-07-06-graperank-brainstorm-parity.md
T
Claude 3f503f0d04 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
2026-07-06 18:51:01 +00:00

135 lines
7.2 KiB
Markdown

# GrapeRank score parity with NosFabrica Brainstorm
Goal: `amy graperank` should output scores **numerically very close** to
NosFabrica's Brainstorm service, the reference GrapeRank implementation.
Sources analysed:
- `NosFabrica/brainstorm_graperank_algorithm` — the Java scoring worker.
- `NosFabrica/brainstorm_server` — the Python orchestration server.
## How Brainstorm builds its service
A four-stage pipeline:
1. **Ingest.** `app/nostr_event_transferer/nostr_event_transferer.py` copies raw
social-graph events — **kinds 0, 3, 10000, 1984** (profiles, follows, mutes,
reports) — from a strfry relay into the server. Same four kinds we crawl.
2. **Graph.** Events land in **Neo4j** as a directed graph of follow / mute /
report edges between pubkeys. Redis + Postgres back the job queue and config.
3. **Score.** The Java worker (`grape/GrapeRankAlgorithm.java`) runs GrapeRank
from an observer, producing a **`ScoreCard`** per user
(`rank/ScoreCard.java`): `observer, observee, hops, averageScore, input,
confidence, influence, verified, trustedFollowers, trustedReporters`.
**There is no `rank` field — the trust value is `influence` ∈ [0,1].**
4. **Serve / publish.** Presets are tunable per deployment
(`DEFAULT` / `PERMISSIVE` / `RESTRICTIVE`, `graperank_preset` table, validated
by `GrapeRankPresetParams`). Java `GrapeRankParams` mirrors the Python model
field-for-field; the README states Python is the source of truth and both
repos must stay in sync.
## The algorithm (their `grape/GrapeRankAlgorithm.java`)
```
rigority = -log(rigor)
confidence(sumWeights) = 1 - exp(-sumWeights * rigority) # weight -> confidence
per edge: weight = edgeConfidence * influenceOfRater * attenuationFactor
wxr = weight * edgeRating
averageScore = sumWxR / sumWeights (0 if sumWeights == 0)
influence = max(averageScore * confidence(sumWeights), 0)
```
- Observer seeded at `influence = 1.0` (fixed authority).
- Non-observers seeded by hop distance, then **iterated until every user's
influence delta < 0.0001** (`loopBreakDelta`). Seeding only affects the
starting guess; attenuation < 1 makes the update a contraction, so the fixed
point is unique.
- The rater weight uses the rater's **`influence`**, and
`influence = max(weightToConfidence(sumW) * sumWR/sumW, 0)`.
## Side-by-side: Brainstorm DEFAULT vs `commons/wot`
`Constants.java` `DEFAULT_PARAMS` (== the Pydantic `GrapeRankPresetParams`
DEFAULT) against our `GrapeRankParams` defaults:
| Brainstorm field | value | our field | value | match |
|---|---|---|---|---|
| `attenuationFactor` | 0.85 | `attenuation` | 0.85 | ✅ |
| `rigor` | 0.5 | `rigor` | 0.5 | ✅ |
| `followRating` | 1.0 | `FOLLOW.rating` | 1.0 | ✅ |
| `muteRating` | -0.1 | `MUTE.rating` | -0.1 | ✅ |
| `reportRating` | -0.1 | `REPORT.rating` | -0.1 | ✅ |
| `followConfidenceOfObserver` | 0.5 | `directFollowConfidence` | 0.5 | ✅ |
| `followConfidence` | 0.03 | `indirectFollowConfidence` | 0.03 | ✅ |
| `muteConfidence` | 0.5 | `muteConfidence` | 0.5 | ✅ |
| `reportConfidence` | 0.5 | `reportConfidence` | 0.5 | ✅ |
| `loopBreakDelta` | 0.0001 | `convergence` | 0.0001 | ✅ |
The three `verified*InfluenceCutoff`s (followers 0.02, reporters 0.1,
muters 0.01) only flag a derived `verified` boolean; they do **not** affect the
score.
**Conclusion: our formula is identical and every scoring parameter matches
DEFAULT.** Our `score` *is* their `influence`
(`max(weightToConfidence(sumW) * sumWR/sumW, 0)`), propagated as the rater
weight — the exact same quantity. On the same input graph the two produce the
same influence to floating-point precision. Our published `rank = round(score *
100)` is a presentation choice on top of that influence (their `ScoreCard`
exposes `influence` as a raw float via the API).
## Where divergence can still come from — and why it's small
It is **data**, not math:
1. **Graph completeness.** Brainstorm ingests the whole strfry graph into Neo4j;
we crawl outward from the observer via the outbox model. **This matters less
than it seems:** a mute/report contributes `confidence * influenceOfRater *
attenuation`, so a signal from a user with **zero influence** (someone outside
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, as long as the crawl actually
checks every discovered user's outbox — which it now does exhaustively (no
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
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
where a stranger's outbox is found — the biggest completeness lever.
- **Content** (kind:3/10000/1984/0) is fetched from each user's **own outbox**
write relays, with harvested **relay hints** (from the `p`-tag hints in
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.
The crawl loops round by round, retrying any member whose contact list still
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`.
4. **Seeding.** Their hop-distance seed vs our zero seed — same fixed point, no
effect on the result.
## Recommendations
- **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 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
PERMISSIVE / RESTRICTIVE numbers are DB-seeded in `brainstorm_server` (an
alembic seed migration) and were not extractable from the public tree —
pull them from a running instance before hard-coding.
- Optionally expose `influence` as a raw float alongside `rank` in `--json`,
and compute the `verified` flag from the cutoffs, to mirror their
`ScoreCard` shape for interop diffing.
## Verification idea
Point `amy graperank <observer> --offline` at a store seeded from the same
strfry snapshot Brainstorm ingested, and diff our `score` against their
`ScoreCard.influence` for the same observer. Expect agreement to ~1e-4.