mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
feat(cli): graperank scores always persist locally; add publish + rank verbs
Every `amy graperank` / `graperank score` run now reconciles its result into the local event store as NIP-85 kind:30382 cards signed by the per-observer service key (cutoff --min-rank, default 2): changed ranks are re-signed, unchanged ones skipped (no event-id churn), dropped targets retracted with a kind:5 the store applies on insert. The store is the source of truth, so the score of a run is durable and reusable instead of ephemeral stdout. New verbs complete the crawl -> score -> publish pipeline: - `graperank publish [OBSERVER] [--relay URL[,URL...]]` — transport only: one NIP-77 up-only reconcile per operator relay over the provider key's kind:30382 + kind:5, converging the relay to the local set (deletions propagate, lost cards restored, full-set publish fallback for relays without negentropy); also refreshes the observer's kind:10040 pointer. - `graperank rank USER [--provider P] [--refresh]` — the consumer side: newest card per provider from the local store, with a relay drain on miss. GrapeRankPublisher (quartz) is reworked accordingly: reconcileAndPublish is replaced by reconcileLocal (sign+insert+retract against the store) and syncToRelays (NegentropyStoreSync up-only + blastPublish fallback), with a commonTest covering upsert/skip/retract and re-carding a retracted target. BREAKING (--json / flags): `--publish`, `--publish-limit`, `--publish-relay` and `--bench-sign` are removed from the score command. Its JSON drops published/publish_rejected/deleted/delete_rejected/skipped_unchanged/ publish_truncated/published_kind/published_to/publish_error/observer_10040/ bench_signed/bench_sign_ms and gains provider_pubkey/min_rank/cards_total/ cards_signed/cards_unchanged/cards_retracted/cards_ms. Publishing moved to the new `graperank publish` verb. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
This commit is contained in:
@@ -611,17 +611,30 @@ private fun printUsage() {
|
||||
| [--rigor X] [--attenuation X] Exhaustively crawls each user's kind:10002 outbox
|
||||
| [--max-rounds N] [--max-hops N] for their latest kind:3/10000/1984 until every
|
||||
| [--offline] [--timeout SECS] discovered user has been checked (no user cap;
|
||||
| [--diagnose] --max-hops bounds follow distance, e.g. 8;
|
||||
| [--diagnose] [--min-rank N] --max-hops bounds follow distance, e.g. 8;
|
||||
| --diagnose dumps per-relay telemetry: outcome
|
||||
| mix, yield, latency, and a LIVE/DEAD + limits
|
||||
| classification table of every relay contacted).
|
||||
| [--publish] [--min-rank N] OBSERVER: npub|nprofile|hex|name@domain (default:
|
||||
| [--publish-limit N] [--publish-relay URL] active account). --offline scores from the local
|
||||
| store only. --publish reconciles NIP-85 kind:30382
|
||||
| cards signed by a per-observer service key: sends
|
||||
| new/changed ranks >= --min-rank (default 2), skips
|
||||
| unchanged, and retracts (kind:5) any card whose
|
||||
| target left the graph or fell below the cutoff.
|
||||
| OBSERVER: npub|nprofile|hex|name@domain (default:
|
||||
| active account). --offline scores from the local
|
||||
| store only. EVERY score run persists its result
|
||||
| locally as NIP-85 kind:30382 cards signed by a
|
||||
| per-observer service key (ranks >= --min-rank,
|
||||
| default 2): changed ranks re-signed, unchanged
|
||||
| skipped, dropped targets retracted (kind:5).
|
||||
| `graperank publish` pushes that set to relays.
|
||||
| graperank score [OBSERVER] local only: build the graph from the store and
|
||||
| score (same as bare --offline; same flags). Fast
|
||||
| and param-tunable without re-crawling.
|
||||
| graperank publish [OBSERVER] transport only: make the operator relay(s) match
|
||||
| [--relay URL[,URL…]] [--timeout SECS] the local card set via one NIP-77 up-only
|
||||
| [--relay-concurrency N] reconcile per relay (nothing re-scored or
|
||||
| re-signed; full-set publish fallback when a relay
|
||||
| can't reconcile). Also refreshes the observer's
|
||||
| kind:10040 pointer when we hold their key.
|
||||
| graperank rank USER [--provider PUBKEY] read the kind:30382 cards about USER — one rank
|
||||
| [--refresh] [--timeout SECS] per provider, local store first, --refresh drains
|
||||
| the operator/declared/bootstrap relays.
|
||||
| graperank crawl [OBSERVER] network only: crawl the WoT graph (kind 3/10000/
|
||||
| [--max-hops N] [--preconnect-cap N] 1984/10002) into the local store without scoring.
|
||||
| [--no-preconnect] Pre-connects every known-live relay in one parallel
|
||||
|
||||
@@ -35,11 +35,9 @@ import com.vitorpamplona.quartz.experimental.graperank.TrustGraphBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
@@ -53,13 +51,8 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ProviderTypes
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceType
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.RankTag
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
@@ -80,26 +73,33 @@ import kotlin.math.roundToInt
|
||||
* unreachable outbox is retried a few times), then runs the scoring engine in
|
||||
* `commons/wot`.
|
||||
*
|
||||
* Prints a ranked list (text, or one JSON object under `--json`). With
|
||||
* `--publish`, results are also published as NIP-85 kind:30382 `ContactCardEvent`
|
||||
* trusted assertions (one per scored user, `rank = round(score*100)`).
|
||||
*
|
||||
* The crawl and the computation are separable, because the crawl persists every
|
||||
* event it fetches to the store and the score is a pure function over it:
|
||||
* The pipeline is three separable stages, each with its own verb, and the local
|
||||
* store is the source of truth between them (the crawl persists every event it
|
||||
* fetches; the score is a pure function over the store; every score run persists
|
||||
* its result as locally-signed NIP-85 kind:30382 cards):
|
||||
* - `amy graperank crawl [OBSERVER]` — network only: crawl the reachable graph's
|
||||
* kind 3/10000/1984/10002 into the local store (aliased as the former `sync`).
|
||||
* Idempotent and cumulative, so run it a few times to make sure everything is
|
||||
* loaded. Scores nothing.
|
||||
* - `amy graperank score [OBSERVER]` — local only: build the graph from the store
|
||||
* and score (same as bare `--offline`). Instant and param-tunable; repeat with
|
||||
* different `--rigor`/`--attenuation`/cutoffs without re-crawling.
|
||||
* - `amy graperank score [OBSERVER]` — local only: build the graph from the store,
|
||||
* score (same as bare `--offline`), and ALWAYS reconcile the result into the
|
||||
* store as kind:30382 [ContactCardEvent] cards signed by the observer's
|
||||
* per-observer service key (`rank = round(score*100)`, cutoff `--min-rank`):
|
||||
* changed ranks are re-signed, unchanged ones skipped, dropped targets
|
||||
* retracted with a kind:5. That persisted card set is what `publish` and
|
||||
* `rank` reuse — scores are never ephemeral.
|
||||
* - `amy graperank publish [OBSERVER]` — transport only: make the operator
|
||||
* relay(s) converge to the local card set via a NIP-77 up-only reconcile
|
||||
* (nothing is re-signed or re-scored), and refresh the observer's kind:10040
|
||||
* pointer when we hold their key.
|
||||
* - `amy graperank probe` — the relay census: mass-connect every relay the store
|
||||
* knows and record live/dead + measured RTT into the reachability cache, so the
|
||||
* next crawl skips the dead and pre-connects the living in one parallel storm.
|
||||
* - bare `amy graperank [OBSERVER]` — the convenience combo: crawl then score.
|
||||
*
|
||||
* Sub-verbs complete the NIP-85 provider experience — the discovery layer that
|
||||
* lets clients find and consume those assertions:
|
||||
* Sub-verbs complete the NIP-85 experience — discovery and consumption:
|
||||
* - `amy graperank rank USER` — read the kind:30382 cards about USER (local
|
||||
* store first, `--refresh` to drain providers' relays): the consumer side.
|
||||
* - `amy graperank register` — advertise a `30382:rank` provider in the
|
||||
* account's kind:10040 [TrustProviderListEvent] (defaults to self, so a
|
||||
* provider publishing ranks announces where to find them).
|
||||
@@ -204,6 +204,8 @@ object GrapeRankCommand {
|
||||
"probe" -> probe(dataDir, tail.drop(1).toTypedArray())
|
||||
"update" -> update(dataDir, tail.drop(1).toTypedArray())
|
||||
"score" -> run(dataDir, tail.drop(1).toTypedArray(), forceOffline = true)
|
||||
"publish" -> publish(dataDir, tail.drop(1).toTypedArray())
|
||||
"rank" -> rank(dataDir, tail.drop(1).toTypedArray())
|
||||
else -> run(dataDir, tail)
|
||||
}
|
||||
|
||||
@@ -227,17 +229,11 @@ object GrapeRankCommand {
|
||||
// the result JSON, so keep local copies for that.
|
||||
val parkTimeoutMs = args.longFlag("park-timeout", 40L) * 1000
|
||||
val insertBatch = args.intFlag("insert-batch", 500)
|
||||
val doPublish = args.bool("publish")
|
||||
// Publish cutoff: only cards with rank >= this are published; existing
|
||||
// cards for targets below it (or gone from the graph) are retracted. Rank
|
||||
// is round(score*100), so 2 drops the ~0.015-and-below barely-trusted tail.
|
||||
// Card cutoff: only scores with rank >= this get a local kind:30382 card;
|
||||
// existing cards for targets below it (or gone from the graph) are
|
||||
// retracted. Rank is round(score*100), so 2 drops the ~0.015-and-below
|
||||
// barely-trusted tail.
|
||||
val minRank = args.intFlag("min-rank", 2)
|
||||
val publishLimit = args.intFlag("publish-limit", 500)
|
||||
val publishRelaysArg = args.flag("publish-relay")
|
||||
// Benchmark: build + sign one kind:30382 card per scored user (rank >=
|
||||
// --min-rank) with a throwaway key and time it, WITHOUT publishing.
|
||||
// Measures the id-hash + Schnorr-sign cost of emitting the full card set.
|
||||
val benchSign = args.bool("bench-sign")
|
||||
|
||||
val params =
|
||||
GrapeRankParams(
|
||||
@@ -348,84 +344,43 @@ object GrapeRankCommand {
|
||||
},
|
||||
)
|
||||
|
||||
if (doPublish) {
|
||||
// The cards for THIS observer are signed by a dedicated, stable
|
||||
// per-observer service key derived from the machine's operator
|
||||
// master (see OperatorKeys) — not the account key. Same key across
|
||||
// runs means re-signing a card replaces the addressable prior one.
|
||||
val opKeys = ctx.dataDir.operatorKeys()
|
||||
val serviceKey = opKeys.serviceKey(observer)
|
||||
val serviceSigner = NostrSignerInternal(serviceKey)
|
||||
val providerPubkey = serviceKey.pubKey.toHexKey()
|
||||
result["provider_pubkey"] = providerPubkey
|
||||
// Every score run persists its result: the desired card set (every user
|
||||
// at or above the rank cutoff) is reconciled into the LOCAL store as
|
||||
// kind:30382 cards — signed by a dedicated, stable per-observer service
|
||||
// key derived from the machine's operator master (see OperatorKeys),
|
||||
// not the account key. Changed ranks are re-signed (the addressable
|
||||
// card is replaced), unchanged ones skipped, dropped targets retracted
|
||||
// with a kind:5. `graperank publish` and `graperank rank` reuse this
|
||||
// set; no relay is touched here.
|
||||
val opKeys = ctx.dataDir.operatorKeys()
|
||||
val serviceKey = opKeys.serviceKey(observer)
|
||||
val providerPubkey = serviceKey.pubKey.toHexKey()
|
||||
val desiredCards =
|
||||
rankedIds
|
||||
.filter { rankOf(scores[it]) >= minRank }
|
||||
.map { graph.pubkeyOf(it) to rankOf(scores[it]) }
|
||||
|
||||
// Cards go to the operator's own relay(s), where the whole
|
||||
// trusted-assertion set lives; --publish-relay overrides.
|
||||
val relays =
|
||||
publishRelaysArg
|
||||
?.split(",")
|
||||
?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) }
|
||||
?.toSet()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: opKeys.operatorRelays()
|
||||
val cardsStart = System.nanoTime()
|
||||
val local =
|
||||
GrapeRankPublisher(ctx.store) { System.err.println(it) }
|
||||
.reconcileLocal(
|
||||
providerSigner = NostrSignerInternal(serviceKey),
|
||||
providerPubkey = providerPubkey,
|
||||
scored = desiredCards,
|
||||
)
|
||||
val cardsMs = (System.nanoTime() - cardsStart) / 1_000_000
|
||||
System.err.println(
|
||||
"[graperank] local cards: ${local.signed} signed, ${local.unchanged} unchanged, " +
|
||||
"${local.retracted} retracted in $cardsMs ms — `amy graperank publish` pushes them to the operator relay",
|
||||
)
|
||||
|
||||
if (relays.isEmpty()) {
|
||||
result["published"] = 0
|
||||
result["publish_error"] = "no operator relay configured — run `amy graperank operator relay <url>` or pass --publish-relay"
|
||||
} else {
|
||||
// The scorer's desired card set: every user at or above the rank
|
||||
// cutoff, as (target, rank). GrapeRankPublisher reconciles this
|
||||
// against what this provider key already published and upserts /
|
||||
// retracts the difference.
|
||||
val publishable =
|
||||
rankedIds
|
||||
.filter { rankOf(scores[it]) >= minRank }
|
||||
.map { graph.pubkeyOf(it) to rankOf(scores[it]) }
|
||||
|
||||
val publisher = GrapeRankPublisher(ctx.store) { event, to -> ctx.publish(event, to) }
|
||||
val pub =
|
||||
publisher.reconcileAndPublish(
|
||||
providerSigner = serviceSigner,
|
||||
providerPubkey = providerPubkey,
|
||||
scored = publishable,
|
||||
relays = relays,
|
||||
publishLimit = publishLimit,
|
||||
)
|
||||
|
||||
result["skipped_unchanged"] = pub.skippedUnchanged
|
||||
if (pub.truncated > 0) result["publish_truncated"] = pub.truncated
|
||||
result["published"] = pub.published
|
||||
result["publish_rejected"] = pub.publishRejected
|
||||
result["deleted"] = pub.deleted
|
||||
result["delete_rejected"] = pub.deleteRejected
|
||||
result["published_kind"] = ContactCardEvent.KIND
|
||||
result["published_to"] = relays.map { it.url }
|
||||
|
||||
// Help the observer point clients at this provider: publish their
|
||||
// kind:10040 (30382:rank -> providerPubkey @ operator relay) to
|
||||
// their outbox — but only when we actually hold their key.
|
||||
maybePublishObserverProviderList(ctx, observer, providerPubkey, relays.first())?.let {
|
||||
result["observer_10040"] = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (benchSign) {
|
||||
// Throwaway key — these cards are for timing only and never leave
|
||||
// the process, so no real identity signs them.
|
||||
val tempSigner = NostrSignerInternal(KeyPair())
|
||||
val cards =
|
||||
rankedIds
|
||||
.filter { rankOf(scores[it]) >= minRank }
|
||||
.map { graph.pubkeyOf(it) to rankOf(scores[it]) }
|
||||
val signStart = System.nanoTime()
|
||||
val signed = signCards(cards, tempSigner)
|
||||
val signMs = (System.nanoTime() - signStart) / 1_000_000
|
||||
val perSec = if (signMs > 0) signed * 1000L / signMs else 0
|
||||
System.err.println("[graperank] signed $signed kind:30382 cards in $signMs ms ($perSec/s, temp key, not published)")
|
||||
result["bench_signed"] = signed
|
||||
result["bench_sign_ms"] = signMs
|
||||
}
|
||||
result["provider_pubkey"] = providerPubkey
|
||||
result["min_rank"] = minRank
|
||||
result["cards_total"] = desiredCards.size
|
||||
result["cards_signed"] = local.signed
|
||||
result["cards_unchanged"] = local.unchanged
|
||||
result["cards_retracted"] = local.retracted
|
||||
result["cards_ms"] = cardsMs
|
||||
|
||||
Output.emit(result)
|
||||
return 0
|
||||
@@ -780,37 +735,201 @@ object GrapeRankCommand {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build + sign one kind:30382 [ContactCardEvent] per (target, rank), fanned
|
||||
* out across CPU cores (id-hash + Schnorr sign is CPU-bound). The signed
|
||||
* events are discarded — this only exists to time card generation. Returns
|
||||
* the number signed.
|
||||
* `amy graperank publish [OBSERVER] [--relay URL[,URL…]] [--relay-concurrency N] [--timeout SECS]`
|
||||
*
|
||||
* Transport only: make the operator relay(s) converge to the local card set
|
||||
* that `graperank score` persisted for OBSERVER (default: the active account).
|
||||
* One NIP-77 up-only reconcile per relay over the provider service key's
|
||||
* kind:30382 cards + kind:5 retractions — nothing is re-scored or re-signed,
|
||||
* and a card the relay lost is restored. A relay that can't reconcile gets the
|
||||
* full local set blast-published instead. Also refreshes the observer's
|
||||
* kind:10040 provider pointer when we hold their key.
|
||||
*/
|
||||
private suspend fun signCards(
|
||||
cards: List<Pair<HexKey, Int>>,
|
||||
signer: NostrSigner,
|
||||
private suspend fun publish(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (cards.isEmpty()) return 0
|
||||
val cores = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
|
||||
val chunkSize = ((cards.size + cores - 1) / cores).coerceAtLeast(1)
|
||||
return coroutineScope {
|
||||
cards
|
||||
.chunked(chunkSize)
|
||||
.map { chunk ->
|
||||
async(Dispatchers.Default) {
|
||||
for ((target, rank) in chunk) {
|
||||
ContactCardEvent.create(
|
||||
targetUser = target,
|
||||
signer = signer,
|
||||
publicInitializer = { add(RankTag.assemble(rank)) },
|
||||
val args = Args(rest)
|
||||
val observerArg = args.positionalOrNull(0)
|
||||
val relayArg = args.flag("relay")
|
||||
val relayConcurrency = args.intFlag("relay-concurrency", 4)
|
||||
val idleTimeoutMs = args.longFlag("timeout", 30L) * 1000
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val observer = observerArg?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex
|
||||
val opKeys = ctx.dataDir.operatorKeys()
|
||||
val providerPubkey = opKeys.serviceKey(observer).pubKey.toHexKey()
|
||||
|
||||
// Cards live on the operator's own relay(s); --relay overrides.
|
||||
val relays =
|
||||
relayArg
|
||||
?.split(",")
|
||||
?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) }
|
||||
?.toSet()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: opKeys.operatorRelays()
|
||||
if (relays.isEmpty()) {
|
||||
return Output.error("no_relays", "no operator relay configured — run `amy graperank operator relay <url>` or pass --relay")
|
||||
}
|
||||
|
||||
val publisher = GrapeRankPublisher(ctx.store) { System.err.println(it) }
|
||||
val sync =
|
||||
publisher.syncToRelays(
|
||||
client = ctx.client,
|
||||
providerPubkey = providerPubkey,
|
||||
relays = relays,
|
||||
relayConcurrency = relayConcurrency,
|
||||
idleTimeoutMs = idleTimeoutMs,
|
||||
)
|
||||
|
||||
if (sync.cards == 0 && sync.deletions == 0) {
|
||||
Output.emit(
|
||||
linkedMapOf<String, Any?>(
|
||||
"observer" to observer,
|
||||
"provider_pubkey" to providerPubkey,
|
||||
"cards" to 0,
|
||||
"note" to "no local cards for this observer — run `amy graperank score` first",
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
// Help the observer point clients at this provider: publish their
|
||||
// kind:10040 (30382:rank -> providerPubkey @ operator relay) to
|
||||
// their outbox — but only when we actually hold their key.
|
||||
val observer10040 = maybePublishObserverProviderList(ctx, observer, providerPubkey, relays.first())
|
||||
|
||||
Output.emit(
|
||||
linkedMapOf<String, Any?>(
|
||||
"observer" to observer,
|
||||
"provider_pubkey" to providerPubkey,
|
||||
"cards" to sync.cards,
|
||||
"deletions" to sync.deletions,
|
||||
"relays" to sync.perRelay.size,
|
||||
"relays_ok" to sync.perRelay.count { it.ok },
|
||||
"relays_failed" to sync.perRelay.count { !it.ok },
|
||||
"uploaded" to sync.perRelay.sumOf { it.uploaded },
|
||||
"fallback_published" to sync.perRelay.sumOf { it.fallbackPublished },
|
||||
"per_relay" to
|
||||
sync.perRelay.map {
|
||||
linkedMapOf<String, Any?>(
|
||||
"relay" to it.relay.url,
|
||||
"uploaded" to it.uploaded,
|
||||
"fallback_published" to it.fallbackPublished,
|
||||
"fallback_rejected" to it.fallbackRejected,
|
||||
"error" to it.error,
|
||||
)
|
||||
}
|
||||
chunk.size
|
||||
}
|
||||
}.awaitAll()
|
||||
.sum()
|
||||
},
|
||||
"observer_10040" to observer10040,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `amy graperank rank USER [--provider PUBKEY] [--refresh] [--timeout SECS]`
|
||||
*
|
||||
* The consumer side of NIP-85: read the kind:30382 cards about USER and print
|
||||
* one rank per provider (newest card each). Cache-first — a `graperank score`
|
||||
* run on this machine already left its cards in the store — falling back to a
|
||||
* relay drain on a miss or with `--refresh` (sources: the operator relays, the
|
||||
* relays declared in the account's kind:10040, and the bootstrap set).
|
||||
* `--provider` narrows to one provider key.
|
||||
*/
|
||||
private suspend fun rank(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val userArg =
|
||||
args.positionalOrNull(0)
|
||||
?: return Output.error("bad_args", "usage: amy graperank rank USER [--provider PUBKEY] [--refresh] [--timeout SECS]")
|
||||
val providerArg = args.flag("provider")
|
||||
val refresh = args.bool("refresh")
|
||||
val timeoutMs = args.longFlag("timeout", 8L) * 1000
|
||||
|
||||
Context.openOrAnonymous(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val user = ctx.requireUserHex(userArg)
|
||||
val provider = providerArg?.let { ctx.requireUserHex(it) }
|
||||
val cardFilter =
|
||||
Filter(
|
||||
kinds = listOf(ContactCardEvent.KIND),
|
||||
tags = mapOf("d" to listOf(user)),
|
||||
authors = provider?.let { listOf(it) },
|
||||
)
|
||||
|
||||
suspend fun localCards(): List<ContactCardEvent> = ctx.store.query<Event>(cardFilter).filterIsInstance<ContactCardEvent>()
|
||||
|
||||
var cards = localCards()
|
||||
if (refresh || cards.isEmpty()) {
|
||||
val relays = rankSourceRelays(ctx, provider)
|
||||
if (relays.isNotEmpty()) {
|
||||
ctx.drain(relays.associateWith { listOf(cardFilter.copy(limit = 50)) }, timeoutMs)
|
||||
cards = localCards()
|
||||
}
|
||||
}
|
||||
|
||||
// Newest card per provider key, strongest assertion first.
|
||||
val newest =
|
||||
cards
|
||||
.groupBy { it.pubKey }
|
||||
.mapNotNull { (_, list) -> list.maxByOrNull { it.createdAt } }
|
||||
.sortedWith(compareByDescending<ContactCardEvent> { it.rank() ?: -1 }.thenByDescending { it.createdAt })
|
||||
|
||||
// A provider key this machine's operator master derived maps back to
|
||||
// the observer whose subjective view the rank expresses.
|
||||
val providerToObserver =
|
||||
ctx.dataDir
|
||||
.operatorKeys()
|
||||
.providers()
|
||||
.entries
|
||||
.associate { (observer, rec) -> rec.providerPubKey to observer }
|
||||
|
||||
Output.emit(
|
||||
linkedMapOf<String, Any?>(
|
||||
"user" to user,
|
||||
"found" to newest.isNotEmpty(),
|
||||
"cards" to
|
||||
newest.map { card ->
|
||||
linkedMapOf<String, Any?>(
|
||||
"provider" to card.pubKey,
|
||||
"rank" to card.rank(),
|
||||
"observer" to providerToObserver[card.pubKey],
|
||||
"created_at" to card.createdAt,
|
||||
"event_id" to card.id,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Relays worth draining for someone's kind:30382 cards: the machine's own
|
||||
* operator relay(s), every relay the account's kind:10040 declares for a
|
||||
* 30382 service (narrowed to [provider] when given), and the bootstrap set.
|
||||
*/
|
||||
private suspend fun rankSourceRelays(
|
||||
ctx: Context,
|
||||
provider: HexKey?,
|
||||
): Set<NormalizedRelayUrl> {
|
||||
val declared =
|
||||
if (!ctx.anonymous) {
|
||||
providerListOf(ctx, ctx.identity.pubKeyHex)
|
||||
?.serviceProviders()
|
||||
?.filter { it.service.kind == ContactCardEvent.KIND && (provider == null || it.pubkey == provider) }
|
||||
?.map { it.relayUrl }
|
||||
.orEmpty()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
return ctx.dataDir.operatorKeys().operatorRelays() + declared + ctx.bootstrapRelays() + Constants.eventFinderRelays
|
||||
}
|
||||
|
||||
/**
|
||||
* `amy graperank operator [status | relay <url>… | providers]`
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user