feat(graperank): persist follower count and hop distance on trust cards

Each kind:30382 GrapeRank card now carries two more public tags alongside
`rank`:

- `followers` — the number of the target's followers whose own score clears a
  threshold (`--followers-threshold`, default 0.02), mirroring Brainstorm's
  trusted-follower cutoff.
- `hops` — the shortest follow-graph distance from the observer (1 = a direct
  follow), matching the `hops` field on Brainstorm's ScoreCard.

New `HopsTag` (the `followers`/`FollowerCountTag` already existed) is wired
through the ContactCardEvent tag accessors/builders. TrustGraph gains
`hopsFrom` (a follow-only BFS over the compact int-CSR) and
`trustedFollowerCounts`; the out-CSR now packs the relation code so a forward
walk can filter FOLLOW edges. The publisher's `reconcileLocal` takes a richer
`ScoredCard` and diffs the full (rank, followers, hops) triple, so a card
re-signs when any of them moves and older cards migrate onto the new tags once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
This commit is contained in:
Claude
2026-07-17 19:34:06 +00:00
parent f380d92498
commit c8ff1bb18f
11 changed files with 354 additions and 43 deletions
+10 -6
View File
@@ -389,7 +389,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] [--offline] [--min-rank N]` | Crawl + score: compute GrapeRank web-of-trust scores (0..1) over the follow/mute/report graph, then persist the result. Exhaustively crawls each user's kind:10002 outbox for their latest kind:3/10000/1984 until every discovered user is checked (no user cap), dropping reports the author retracted via NIP-09. **Every score run persists its result locally**: the ranks (cutoff `--min-rank`, default 2) are reconciled into the shared store as NIP-85 kind:30382 cards signed by a per-observer **service key**changed ranks re-signed, unchanged skipped (no event-id churn), dropped targets retracted (kind:5). `--offline` skips the crawl. |
| `amy graperank [OBSERVER] [--offline] [--min-rank N]` | Crawl + score: compute GrapeRank web-of-trust scores (0..1) over the follow/mute/report graph, then persist the result. Exhaustively crawls each user's kind:10002 outbox for their latest kind:3/10000/1984 until every discovered user is checked (no user cap), dropping reports the author retracted via NIP-09. **Every score run persists its result locally**: the cards (rank cutoff `--min-rank`, default 2) are reconciled into the shared store as NIP-85 kind:30382 cards signed by a per-observer **service key**each carries `rank`, `followers` (trusted-follower count, cutoff `--followers-threshold`, default 0.02) and `hops` (follow distance from the observer); changed cards re-signed, unchanged skipped (no event-id churn), dropped targets retracted (kind:5). `--offline` skips the crawl. |
| `amy graperank crawl [OBSERVER] [--max-hops N] [--no-preconnect]` | Pipeline stage 1 — network only: crawl the follow/mute/report graph (kind 3/10000/1984/10002) into the local store, no scoring. Idempotent and cumulative: run it a few times to load everything, then `score`. |
| `amy graperank score [OBSERVER]` | Pipeline stage 2 — local only: score from the store and persist the cards (identical to bare `--offline`; same scoring flags). No network, so re-run with different `--rigor`/`--attenuation`/`--min-rank` without re-crawling. |
| `amy graperank publish [OBSERVER] [--relay URL[,URL…]]` | Pipeline stage 3 — transport only: make the operator relay(s) converge to the locally persisted card set — one NIP-77 up-only reconcile per relay over the service key's kind:30382 + kind:5 (nothing is re-scored or re-signed; a relay that can't reconcile gets the full set published instead). Also refreshes the observer's kind:10040 pointer when we hold their key. |
@@ -420,12 +420,16 @@ means re-signing **replaces** a target's card instead of orphaning it — and
losing everything but the master seed still re-derives every key.
**Every score run persists its cards.** After scoring, Amy reconciles the result
into the local store: new or changed ranks (`--min-rank`, default 2) are
signed; unchanged ranks are skipped (no new event id); and any card whose target
into the local store: new or changed cards (rank `--min-rank`, default 2) are
signed; unchanged cards are skipped (no new event id); and any card whose target
dropped out of the graph or fell below the cutoff is **retracted** with a kind:5
(the store applies it; the tombstone is kept). The local store is the source of
truth — `graperank rank USER` reads it offline, and `graperank publish` mirrors
it out:
(the store applies it; the tombstone is kept). Each card carries three public
tags: `rank` (`round(score*100)`), `followers` — the number of the target's
followers whose own score clears `--followers-threshold` (default 0.02, matching
Brainstorm's trusted-follower cutoff) — and `hops`, the shortest follow-graph
distance from the observer (1 = a direct follow). A change to *any* of the three
re-signs the card. The local store is the source of truth — `graperank rank USER`
reads it offline, and `graperank publish` mirrors it out:
```bash
amy graperank operator relay wss://relay.example.com # where all cards live
@@ -146,6 +146,10 @@ object GrapeRankCommand {
private const val PROBE_TIMEOUT_MS = 2000
// Default score cutoff for counting a follower as "trusted" (the `followers`
// tag). Matches NosFabrica Brainstorm's verifiedFollowersInfluenceCutoff.
private const val DEFAULT_FOLLOWERS_THRESHOLD = 0.02
// args.bool on a mistyped flag silently returns false, so the one flag read from
// three different functions goes through a compile-time-checked name.
private const val NO_REACHABILITY_CACHE_FLAG = "no-reachability-cache"
@@ -248,6 +252,10 @@ object GrapeRankCommand {
// retracted. Rank is round(score*100), so 2 drops the ~0.015-and-below
// barely-trusted tail.
val minRank = args.intFlag("min-rank", 2)
// A "trusted follower" (the `followers` tag) is a follower whose own score is
// at or above this. Mirrors Brainstorm's verifiedFollowersInfluenceCutoff
// (0.02), which is the same 0.02 score == rank 2 line as the default min-rank.
val followersThreshold = args.flag("followers-threshold")?.toDoubleOrNull() ?: DEFAULT_FOLLOWERS_THRESHOLD
val params =
GrapeRankParams(
@@ -327,6 +335,14 @@ object GrapeRankCommand {
val scoringMs = (System.nanoTime() - scoreStart) / 1_000_000
System.err.println("[graperank] scored ${rankedIds.size} users in $scoringMs ms")
// Two derived per-user metrics persisted alongside the rank on each card:
// - trusted-follower count: how many of a user's followers score at or
// above the cutoff (Brainstorm's trustedFollowers).
// - hops: shortest follow-graph distance from the observer (1 = direct
// follow). Both are pure functions over the same graph + scores.
val followerCounts = graph.trustedFollowerCounts(scores, followersThreshold)
val hops = graph.hopsFrom(observer)
val hopHistogram = crawlStats?.hopHistogram.orEmpty()
val result =
linkedMapOf<String, Any?>(
@@ -352,9 +368,16 @@ object GrapeRankCommand {
"graph_build_ms" to buildMs,
"scoring_ms" to scoringMs,
"scoring_sweeps" to sweeps,
"followers_threshold" to followersThreshold,
"scores" to
rankedIds.take(limit).map {
mapOf("pubkey" to graph.pubkeyOf(it), "score" to scores[it], "rank" to rankOf(scores[it]))
mapOf(
"pubkey" to graph.pubkeyOf(it),
"score" to scores[it],
"rank" to rankOf(scores[it]),
"followers" to followerCounts[it],
"hops" to hops[it],
)
},
)
@@ -372,7 +395,16 @@ object GrapeRankCommand {
val desiredCards =
rankedIds
.filter { rankOf(scores[it]) >= minRank }
.map { graph.pubkeyOf(it) to rankOf(scores[it]) }
.map { id ->
GrapeRankPublisher.ScoredCard(
target = graph.pubkeyOf(id),
rank = rankOf(scores[id]),
followers = followerCounts[id],
// A scored user always has a follow path from the observer,
// so hops is ≥ 1; guard the UNREACHABLE sentinel just in case.
hops = hops[id].takeIf { it >= 1 },
)
}
val cardsStart = System.nanoTime()
val local =
@@ -908,6 +940,8 @@ object GrapeRankCommand {
linkedMapOf<String, Any?>(
"provider" to card.pubKey,
"rank" to card.rank(),
"followers" to card.followerCount(),
"hops" to card.hops(),
"observer" to providerToObserver[card.pubKey],
"created_at" to card.createdAt,
"event_id" to card.id,
@@ -31,6 +31,11 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.followerCount
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.hops
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.rank
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.FollowerCountTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.HopsTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.RankTag
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
@@ -62,6 +67,21 @@ class GrapeRankPublisher(
private val store: IEventStore,
private val log: (String) -> Unit = {},
) {
/**
* One desired kind:30382 card: the GrapeRank result for [target] the caller
* wants persisted. [rank] (`round(score*100)`) is always written; [followers]
* (trusted-follower count) and [hops] (follow-graph distance from the observer)
* are optional a `null` omits that tag, so a caller that only knows the rank
* still produces a valid card, and older cards missing those tags reconcile
* cleanly against a run that now supplies them.
*/
class ScoredCard(
val target: HexKey,
val rank: Int,
val followers: Int? = null,
val hops: Int? = null,
)
/** Outcome of one [reconcileLocal]: what was signed, retracted, and left alone. */
class LocalResult(
/** New or rank-changed cards signed and inserted into the store. */
@@ -73,25 +93,27 @@ class GrapeRankPublisher(
)
/**
* Reconcile the desired [scored] `(target, rank)` set (the caller has already
* applied any rank cutoff) into the local store, signed by [providerSigner]:
* upsert the changed cards, retract the stale ones, skip the unchanged rest.
* Every card and retraction is stamped [createdAt], so a replacement is
* strictly newer than what it displaces.
* Reconcile the desired [scored] card set (the caller has already applied any
* rank cutoff) into the local store, signed by [providerSigner]: upsert the
* changed cards, retract the stale ones, skip the unchanged rest. Every card and
* retraction is stamped [createdAt], so a replacement is strictly newer than what
* it displaces.
*/
suspend fun reconcileLocal(
providerSigner: NostrSigner,
providerPubkey: HexKey,
scored: List<Pair<HexKey, Int>>,
scored: List<ScoredCard>,
createdAt: Long = TimeUtils.now(),
): LocalResult {
val existing = existingCards(providerPubkey)
val desiredTargets = scored.mapTo(HashSet()) { it.first }
val desiredTargets = scored.mapTo(HashSet()) { it.target }
// Upsert targets whose rank tag STRING would change (or that have no card
// yet). RankTag.assemble writes rank.toString(), so we diff that exact
// string — an unchanged score never produces a new signature.
val changed = scored.filter { (target, rank) -> existing[target]?.let(::rankTagValue) != rank.toString() }
// Upsert targets whose card values would change (or that have no card yet).
// We diff every tag we write rank, follower count, and hops — so a card
// re-signs when any of them moves, but an all-unchanged run never churns an
// event id. A card predating the follower/hops tags reads null for them and
// so re-signs once to pick them up.
val changed = scored.filter { card -> existing[card.target]?.let(::cardValues) != desiredValues(card) }
// Retract cards whose target is no longer desired — it dropped out of the
// graph, or fell below the caller's cutoff. No stale assertion is left standing.
@@ -203,15 +225,15 @@ class GrapeRankPublisher(
}.toMap()
/**
* The raw `rank` tag value string on a card exactly what a client diffs, so an
* unchanged score never produces a new signature. Our cards carry only a `rank`
* tag (plus the d-tag target), so this one value decides whether a re-sign
* would differ.
* The (rank, followers, hops) triple stored on an existing card the values a
* re-sign would overwrite. Compared against [desiredValues] so an unchanged run
* produces no new signature; a missing tag reads null and so differs from a
* desired non-null value (the one-time migration onto the new tags).
*/
private fun rankTagValue(card: ContactCardEvent): String? =
card.tags.firstNotNullOfOrNull { tag ->
if (tag.size > 1 && tag[0] == RankTag.TAG_NAME) tag[1] else null
}
private fun cardValues(card: ContactCardEvent): Triple<Int?, Int?, Int?> = Triple(card.rank(), card.followerCount(), card.hops())
/** The (rank, followers, hops) triple a [ScoredCard] would write. */
private fun desiredValues(card: ScoredCard): Triple<Int?, Int?, Int?> = Triple(card.rank, card.followers, card.hops)
/**
* Build + sign one kind:30382 card per (target, rank) fanned out on
@@ -221,7 +243,7 @@ class GrapeRankPublisher(
*/
private suspend fun signAndInsertCards(
signer: NostrSigner,
cards: List<Pair<HexKey, Int>>,
cards: List<ScoredCard>,
createdAt: Long,
): Int {
if (cards.isEmpty()) return 0
@@ -230,13 +252,17 @@ class GrapeRankPublisher(
val signedBatch =
coroutineScope {
batch
.map { (target, rank) ->
.map { card ->
async(Dispatchers.Default) {
ContactCardEvent.create(
targetUser = target,
targetUser = card.target,
signer = signer,
createdAt = createdAt,
publicInitializer = { add(RankTag.assemble(rank)) },
publicInitializer = {
add(RankTag.assemble(card.rank))
card.followers?.let { add(FollowerCountTag.assemble(it)) }
card.hops?.let { add(HopsTag.assemble(it)) }
},
)
}
}.awaitAll()
@@ -60,9 +60,11 @@ class TrustGraph internal constructor(
// each packing source id (low 29 bits) + relation code (top bits).
internal val inOffsets: IntArray,
internal val inPacked: IntArray,
// CSR by source: out-neighbour targets of node s are outTargets[outOffsets[s] until outOffsets[s+1]].
// CSR by source: out-edges of node s are outPacked[outOffsets[s] until outOffsets[s+1]],
// each packing the neighbour (target) id (low 29 bits) + relation code (top bits) —
// same layout as inPacked, so a forward walk can filter by relation.
internal val outOffsets: IntArray,
internal val outTargets: IntArray,
internal val outPacked: IntArray,
) {
/** Node id for [pubkey], or `-1` if it never appeared in the graph. */
fun idOf(pubkey: HexKey): Int = ids[pubkey] ?: -1
@@ -72,10 +74,87 @@ class TrustGraph internal constructor(
fun edgeCount(): Int = inPacked.size
/**
* Hop distance from [observer] to every node along **FOLLOW edges only** a
* plain BFS over the follow graph (mutes/reports are not reachability). Indexed
* by node id: the observer is `0`, a user the observer follows directly is `1`,
* a user followed by a 1-hop user is `2`, and so on. A node with no follow path
* from the observer stays [UNREACHABLE]. Returns an all-[UNREACHABLE] array (bar
* a missing observer's own 0) when the observer isn't in the graph.
*
* BFS over the compact int-CSR: an `IntArray` ring queue and the distance array
* itself as the visited set, so a whole-network graph costs one int per node
* plus the queue no boxed collections.
*/
fun hopsFrom(observer: HexKey): IntArray {
val hops = IntArray(nodeCount) { UNREACHABLE }
val observerId = idOf(observer)
if (observerId < 0) return hops
hops[observerId] = 0
// Ring buffer sized to the node count — BFS enqueues each node at most once.
val queue = IntArray(nodeCount)
var head = 0
var tail = 0
queue[tail++] = observerId
while (head < tail) {
val node = queue[head++]
val nextHop = hops[node] + 1
var i = outOffsets[node]
val end = outOffsets[node + 1]
while (i < end) {
val packed = outPacked[i]
if ((packed ushr SOURCE_BITS) == TrustRelation.FOLLOW.code) {
val target = packed and SOURCE_MASK
if (hops[target] == UNREACHABLE) {
hops[target] = nextHop
queue[tail++] = target
}
}
i++
}
}
return hops
}
/**
* For every node, how many of its **followers** score at or above [minScore] in
* [scores] the "trusted follower" count Brainstorm records on its `ScoreCard`
* (its `verifiedFollowersInfluenceCutoff` defaults to 0.02). A follower is the
* source of an incoming FOLLOW edge; mutes/reports don't count. Indexed by node
* id. [scores] must be the array [GrapeRank.compute] returned for this graph.
*/
fun trustedFollowerCounts(
scores: DoubleArray,
minScore: Double,
): IntArray {
val counts = IntArray(nodeCount)
var target = 0
while (target < nodeCount) {
var count = 0
var i = inOffsets[target]
val end = inOffsets[target + 1]
while (i < end) {
val packed = inPacked[i]
if ((packed ushr SOURCE_BITS) == TrustRelation.FOLLOW.code) {
val source = packed and SOURCE_MASK
if (scores[source] >= minScore) count++
}
i++
}
counts[target] = count
target++
}
return counts
}
companion object {
const val SOURCE_BITS = 29
const val SOURCE_MASK = (1 shl SOURCE_BITS) - 1
const val MAX_NODES = SOURCE_MASK // ids must fit in the low 29 bits
/** [hopsFrom] distance for a node with no follow path from the observer. */
const val UNREACHABLE = -1
}
}
@@ -108,20 +108,24 @@ class TrustGraphBuilder {
inPacked[inCursor[t]++] = edgeSourcesPacked.get(i)
}
// Outgoing CSR (by source).
// Outgoing CSR (by source). Each entry packs the target id + relation code
// in the same layout as the incoming CSR, so a forward walk (e.g. the
// follow-only BFS in TrustGraph.hopsFrom) can filter edges by relation.
val outOffsets = IntArray(n + 1)
for (i in 0 until m) {
val s = edgeSourcesPacked.get(i) and TrustGraph.SOURCE_MASK
outOffsets[s + 1]++
}
for (i in 1..n) outOffsets[i] += outOffsets[i - 1]
val outTargets = IntArray(m)
val outPacked = IntArray(m)
val outCursor = outOffsets.copyOf()
for (i in 0 until m) {
val s = edgeSourcesPacked.get(i) and TrustGraph.SOURCE_MASK
outTargets[outCursor[s]++] = edgeTargets.get(i)
val packedSource = edgeSourcesPacked.get(i)
val s = packedSource and TrustGraph.SOURCE_MASK
val relationCode = packedSource ushr TrustGraph.SOURCE_BITS
outPacked[outCursor[s]++] = edgeTargets.get(i) or (relationCode shl TrustGraph.SOURCE_BITS)
}
return TrustGraph(n, pubkeys.toTypedArray(), ids, inOffsets, inPacked, outOffsets, outTargets)
return TrustGraph(n, pubkeys.toTypedArray(), ids, inOffsets, inPacked, outOffsets, outPacked)
}
}
@@ -61,6 +61,8 @@ class ContactCardEvent(
fun followerCount() = tags.followerCount()
fun hops() = tags.hops()
fun firstCreatedAt() = tags.firstCreatedAt()
fun postCount() = tags.postCount()
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ActiveHoursEnd
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ActiveHoursStartTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.FirstCreatedAtTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.FollowerCountTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.HopsTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PetNameTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PostCountTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.RankTag
@@ -45,6 +46,8 @@ fun TagArrayBuilder<ContactCardEvent>.rank(rank: Int) = addUnique(RankTag.assemb
fun TagArrayBuilder<ContactCardEvent>.followers(count: Int) = addUnique(FollowerCountTag.assemble(count))
fun TagArrayBuilder<ContactCardEvent>.hops(hops: Int) = addUnique(HopsTag.assemble(hops))
fun TagArrayBuilder<ContactCardEvent>.firstCreatedAt(timestamp: Long) = addUnique(FirstCreatedAtTag.assemble(timestamp))
fun TagArrayBuilder<ContactCardEvent>.postCount(count: Int) = addUnique(PostCountTag.assemble(count))
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ActiveHoursEnd
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ActiveHoursStartTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.FirstCreatedAtTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.FollowerCountTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.HopsTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PetNameTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PostCountTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.RankTag
@@ -46,6 +47,8 @@ fun TagArray.rank() = fastFirstNotNullOfOrNull(RankTag::parse)
fun TagArray.followerCount() = fastFirstNotNullOfOrNull(FollowerCountTag::parse)
fun TagArray.hops() = fastFirstNotNullOfOrNull(HopsTag::parse)
fun TagArray.firstCreatedAt() = fastFirstNotNullOfOrNull(FirstCreatedAtTag::parse)
fun TagArray.postCount() = fastFirstNotNullOfOrNull(PostCountTag::parse)
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The number of follow hops from the observer to the card's target the length
* of the shortest follow path in the observer's web of trust. A user the observer
* follows directly is 1 hop; a user followed by someone the observer follows is 2;
* and so on. Mirrors the `hops` field on Brainstorm's GrapeRank `ScoreCard`.
*/
class HopsTag {
companion object {
const val TAG_NAME = "hops"
fun parse(tag: Array<String>): Int? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toIntOrNull()
}
fun assemble(hops: Int) = arrayOf(TAG_NAME, hops.toString())
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.experimental.graperank
import com.vitorpamplona.quartz.experimental.graperank.GrapeRankPublisher.ScoredCard
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -64,7 +65,7 @@ class GrapeRankPublisherTest {
val c = hexKey(0xC)
// First run: every card is new.
val r1 = publisher.reconcileLocal(signer, provider, listOf(a to 50, b to 10), createdAt = 1_000L)
val r1 = publisher.reconcileLocal(signer, provider, listOf(ScoredCard(a, 50), ScoredCard(b, 10)), createdAt = 1_000L)
assertEquals(2, r1.signed)
assertEquals(0, r1.unchanged)
assertEquals(0, r1.retracted)
@@ -76,7 +77,7 @@ class GrapeRankPublisherTest {
.toSet()
// Same ranks again: nothing is re-signed, no event id churns.
val r2 = publisher.reconcileLocal(signer, provider, listOf(a to 50, b to 10), createdAt = 2_000L)
val r2 = publisher.reconcileLocal(signer, provider, listOf(ScoredCard(a, 50), ScoredCard(b, 10)), createdAt = 2_000L)
assertEquals(0, r2.signed)
assertEquals(2, r2.unchanged)
assertEquals(0, r2.retracted)
@@ -88,7 +89,7 @@ class GrapeRankPublisherTest {
assertEquals(firstIds, secondIds)
// a's rank moved, b dropped out, c is new: a + c signed, b retracted.
val r3 = publisher.reconcileLocal(signer, provider, listOf(a to 60, c to 5), createdAt = 3_000L)
val r3 = publisher.reconcileLocal(signer, provider, listOf(ScoredCard(a, 60), ScoredCard(c, 5)), createdAt = 3_000L)
assertEquals(2, r3.signed)
assertEquals(0, r3.unchanged)
assertEquals(1, r3.retracted)
@@ -113,16 +114,54 @@ class GrapeRankPublisherTest {
val a = hexKey(0xA)
publisher.reconcileLocal(signer, provider, listOf(a to 40), createdAt = 1_000L)
publisher.reconcileLocal(signer, provider, listOf(ScoredCard(a, 40)), createdAt = 1_000L)
publisher.reconcileLocal(signer, provider, emptyList(), createdAt = 2_000L)
assertEquals(emptyMap(), cardsByTarget(store, provider))
// A NEWER card outranks the older kind:5 (NIP-09 deletions only cover
// versions up to their created_at), so the target comes back cleanly.
val r = publisher.reconcileLocal(signer, provider, listOf(a to 45), createdAt = 3_000L)
val r = publisher.reconcileLocal(signer, provider, listOf(ScoredCard(a, 45)), createdAt = 3_000L)
assertEquals(1, r.signed)
assertEquals(mapOf(a to 45), cardsByTarget(store, provider))
store.close()
}
@Test
fun writesFollowerAndHopTagsAndReSignsWhenTheyMove() =
runBlocking {
val store = EventStore(null)
val signer = NostrSignerInternal(KeyPair())
val provider = signer.pubKey
val publisher = GrapeRankPublisher(store)
val a = hexKey(0xA)
suspend fun cardFor(target: String): ContactCardEvent =
store
.query<Event>(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(provider)))
.filterIsInstance<ContactCardEvent>()
.first { it.aboutUser() == target }
// A card carrying rank + followers + hops persists all three tags.
publisher.reconcileLocal(signer, provider, listOf(ScoredCard(a, rank = 50, followers = 7, hops = 2)), createdAt = 1_000L)
cardFor(a).let {
assertEquals(50, it.rank())
assertEquals(7, it.followerCount())
assertEquals(2, it.hops())
}
// Rank unchanged but the follower count moved → the card re-signs.
val moved = publisher.reconcileLocal(signer, provider, listOf(ScoredCard(a, rank = 50, followers = 9, hops = 2)), createdAt = 2_000L)
assertEquals(1, moved.signed)
assertEquals(0, moved.unchanged)
assertEquals(9, cardFor(a).followerCount())
// Everything identical → no re-sign.
val same = publisher.reconcileLocal(signer, provider, listOf(ScoredCard(a, rank = 50, followers = 9, hops = 2)), createdAt = 3_000L)
assertEquals(0, same.signed)
assertEquals(1, same.unchanged)
store.close()
}
}
@@ -104,4 +104,76 @@ class TrustGraphBuilderTest {
assertEquals(3, graph.nodeCount, "alice, bob, carol interned once each")
assertEquals(3, graph.edgeCount())
}
private fun TrustGraph.hop(
observer: HexKey,
target: HexKey,
): Int {
val id = idOf(target)
return if (id < 0) TrustGraph.UNREACHABLE else hopsFrom(observer)[id]
}
@Test
fun hopsCountFollowDistanceFromTheObserver() {
val b = TrustGraphBuilder()
b.addFollows(alice, listOf(bob)) // alice -> bob (1 hop)
b.addFollows(bob, listOf(carol)) // bob -> carol (2 hops)
b.addFollows(carol, listOf(dave)) // carol -> dave (3 hops)
val graph = b.build()
assertEquals(0, graph.hop(alice, alice), "the observer is 0 hops from itself")
assertEquals(1, graph.hop(alice, bob), "a direct follow is 1 hop")
assertEquals(2, graph.hop(alice, carol))
assertEquals(3, graph.hop(alice, dave))
}
@Test
fun hopsTakeTheShortestFollowPath() {
val b = TrustGraphBuilder()
b.addFollows(alice, listOf(bob, dave)) // dave is also a direct follow…
b.addFollows(bob, listOf(carol))
b.addFollows(carol, listOf(dave)) // …as well as reachable at 3 hops
val graph = b.build()
assertEquals(1, graph.hop(alice, dave), "BFS keeps the shortest of two follow paths")
}
@Test
fun hopsIgnoreMuteAndReportEdges() {
val b = TrustGraphBuilder()
b.addMutes(alice, listOf(bob)) // a mute is not reachability
b.addReports(alice, listOf(carol)) // neither is a report
val graph = b.build()
assertEquals(TrustGraph.UNREACHABLE, graph.hop(alice, bob), "a muted user is not reachable by follows")
assertEquals(TrustGraph.UNREACHABLE, graph.hop(alice, carol), "a reported user is not reachable by follows")
}
@Test
fun hopsAreUnreachableWithNoFollowPath() {
val b = TrustGraphBuilder()
b.addFollows(alice, listOf(bob))
b.addFollows(carol, listOf(dave)) // a separate island
val graph = b.build()
assertEquals(TrustGraph.UNREACHABLE, graph.hop(alice, dave))
}
@Test
fun trustedFollowerCountsOnlyCountFollowersAboveTheThreshold() {
val b = TrustGraphBuilder()
// carol is followed by alice, bob and dave (three follow edges in).
b.addFollows(alice, listOf(carol))
b.addFollows(bob, listOf(carol))
b.addFollows(dave, listOf(carol))
// dave also MUTES carol — a mute must never count as a follower.
b.addMutes(dave, listOf(carol))
val graph = b.build()
val carolId = graph.idOf(carol)
val scores = DoubleArray(graph.nodeCount)
scores[graph.idOf(alice)] = 0.9 // trusted
scores[graph.idOf(bob)] = 0.01 // below the 0.02 cutoff
scores[graph.idOf(dave)] = 0.5 // trusted
val counts = graph.trustedFollowerCounts(scores, minScore = 0.02)
assertEquals(2, counts[carolId], "only alice and dave clear the cutoff; bob is too low and dave's mute doesn't count")
}
}