mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
+10
-6
@@ -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,
|
||||
|
||||
+49
-23
@@ -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()
|
||||
|
||||
+81
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -61,6 +61,8 @@ class ContactCardEvent(
|
||||
|
||||
fun followerCount() = tags.followerCount()
|
||||
|
||||
fun hops() = tags.hops()
|
||||
|
||||
fun firstCreatedAt() = tags.firstCreatedAt()
|
||||
|
||||
fun postCount() = tags.postCount()
|
||||
|
||||
+3
@@ -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))
|
||||
|
||||
+3
@@ -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)
|
||||
|
||||
+45
@@ -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())
|
||||
}
|
||||
}
|
||||
+44
-5
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+72
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user