From c8ff1bb18f18f0e22f87c22ffd1212a27c6ae049 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 18:23:43 +0000 Subject: [PATCH] feat(graperank): persist follower count and hop distance on trust cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4 --- cli/README.md | 16 ++-- .../amethyst/cli/commands/GrapeRankCommand.kt | 38 ++++++++- .../graperank/GrapeRankPublisher.kt | 72 +++++++++++----- .../experimental/graperank/TrustGraph.kt | 83 ++++++++++++++++++- .../graperank/TrustGraphBuilder.kt | 14 ++-- .../users/ContactCardEvent.kt | 2 + .../users/TagArrayBuilderExt.kt | 3 + .../users/TagArrayExt.kt | 3 + .../users/tags/HopsTag.kt | 45 ++++++++++ .../graperank/GrapeRankPublisherTest.kt | 49 +++++++++-- .../graperank/TrustGraphBuilderTest.kt | 72 ++++++++++++++++ 11 files changed, 354 insertions(+), 43 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/tags/HopsTag.kt diff --git a/cli/README.md b/cli/README.md index 468211a712..c78efa9d56 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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 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 302fc6d02e..36a5aa6b36 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 @@ -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( @@ -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( "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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisher.kt index 9ff48d83ef..89b90b79a4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisher.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisher.kt @@ -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>, + scored: List, 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 = Triple(card.rank(), card.followerCount(), card.hops()) + + /** The (rank, followers, hops) triple a [ScoredCard] would write. */ + private fun desiredValues(card: ScoredCard): Triple = 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>, + cards: List, 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() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/TrustGraph.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/TrustGraph.kt index ad78a6e56a..829ac7d3c2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/TrustGraph.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/TrustGraph.kt @@ -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 } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/TrustGraphBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/TrustGraphBuilder.kt index 7b3f12fbe4..a4bb31f632 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/TrustGraphBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/TrustGraphBuilder.kt @@ -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) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt index 6f276a3716..d2e38ed3ff 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt @@ -61,6 +61,8 @@ class ContactCardEvent( fun followerCount() = tags.followerCount() + fun hops() = tags.hops() + fun firstCreatedAt() = tags.firstCreatedAt() fun postCount() = tags.postCount() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayBuilderExt.kt index 88d87d7359..161912e25d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayBuilderExt.kt @@ -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.rank(rank: Int) = addUnique(RankTag.assemb fun TagArrayBuilder.followers(count: Int) = addUnique(FollowerCountTag.assemble(count)) +fun TagArrayBuilder.hops(hops: Int) = addUnique(HopsTag.assemble(hops)) + fun TagArrayBuilder.firstCreatedAt(timestamp: Long) = addUnique(FirstCreatedAtTag.assemble(timestamp)) fun TagArrayBuilder.postCount(count: Int) = addUnique(PostCountTag.assemble(count)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayExt.kt index fad3a43cc8..c406e73727 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayExt.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/tags/HopsTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/tags/HopsTag.kt new file mode 100644 index 0000000000..cd5c2d71ce --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/tags/HopsTag.kt @@ -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): 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()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisherTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisherTest.kt index cbd3fbb6de..a5ef8d76eb 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisherTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisherTest.kt @@ -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(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(provider))) + .filterIsInstance() + .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() } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/TrustGraphBuilderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/TrustGraphBuilderTest.kt index f27e064273..c7041264cf 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/TrustGraphBuilderTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/TrustGraphBuilderTest.kt @@ -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") + } }