mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 19:53:08 +00:00
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
135 lines
7.2 KiB
Markdown
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.
|