feat(cli): skip republishing unchanged graperank cards

Previously `graperank --publish` rebuilt and rebroadcast a NIP-85 kind:30382
ContactCard for every scored user on every run, minting a new event id and
created_at even when the rank was identical — pure churn for a parameterized-
replaceable event.

Read back the ranks we last published from the account's own kind:30382 cards
in the local store (ctx.publish already persists them) and publish only the
targets whose rank is new or changed. Report the count left alone as
`skipped_unchanged`.

Verified against a local geode relay: first run publishes N cards
(skipped_unchanged=0); an immediate re-run with identical ranks publishes 0
(skipped_unchanged=N).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
This commit is contained in:
Claude
2026-07-06 15:11:28 +00:00
parent 7b7c553331
commit 9045235802
@@ -164,11 +164,22 @@ object GrapeRankCommand {
?.takeIf { it.isNotEmpty() }
?: ctx.outboxRelays()
val toPublish =
// Ranks we've already published (read back from the store, which
// holds our own prior cards) — keyed by target, newest per target.
// Lets us leave an unchanged card alone instead of churning it.
val publishedRanks = publishedCardRanks(ctx)
val candidates =
ranked
.filter { rankOf(it.value) >= minRank }
.take(publishLimit)
.map { it.key to rankOf(it.value) }
val changed = candidates.filter { (target, rank) -> publishedRanks[target] != rank }
val toPublish = changed.take(publishLimit)
result["skipped_unchanged"] = candidates.size - changed.size
if (changed.size > toPublish.size) {
result["publish_truncated"] = changed.size - toPublish.size
}
if (relays.isEmpty()) {
result["published"] = 0
@@ -240,6 +251,25 @@ object GrapeRankCommand {
}
}
/**
* The rank we last published for each target, read from the active account's
* own kind:30382 cards in the local store (newest card wins per target).
* `ctx.publish` stores every card it sends, so on repeat runs this reflects
* what's already out there and lets us skip targets whose rank is unchanged.
*/
private suspend fun publishedCardRanks(ctx: Context): Map<HexKey, Int> {
val self = ctx.identity.pubKeyHex
return ctx.store
.query<Event>(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(self)))
.filterIsInstance<ContactCardEvent>()
.groupBy { it.aboutUser() }
.mapNotNull { (target, cards) ->
val t = target ?: return@mapNotNull null
val rank = cards.maxByOrNull { it.createdAt }?.rank() ?: return@mapNotNull null
t to rank
}.toMap()
}
/** Build + publish one NIP-85 kind:30382 card per user, bounded-concurrently. */
private suspend fun publishCards(
ctx: Context,