At hop-8 the crawl dials into thousands of dead relay hints from old accounts.
Most fail slowly: a silently-dropping host has no RST to receive, so the WS
connect just hangs to the 7s connectTimeout. First-strike eviction pays that once
per host, but with ~3,000 dead hosts that's ~80s of connect-setup serialized
through the dispatcher.
Add a background reachability culler: a cheap raw TCP connect (one round trip, 2s
timeout) over the learned relays COLD-TAIL FIRST, dropping the unreachable ones
into deadHosts before the WS path pays its 7s. The key property is that a tight
TCP timeout is safe where a tight WS timeout is not — a busy-but-alive relay
accepts the SYN instantly at the kernel level and only stalls at the app layer, so
the probe separates "unreachable" from "slow" and never false-kills the busy. It
only ever marks dead and probes each authority once; a host the WS path already
resolved (isDead) is skipped, and a live host passes the probe, so the WS verdict
always wins. Injected as an optional Config.reachabilityProbe (JVM: java.net.Socket
in the CLI; --no-probe disables); writeRelayFreq becomes concurrent so the culler
can read it while routeByOutbox writes.
Also: when there's no Tor transport (Config.torEnabled=false), isDead skips every
.onion relay on sight — no socket, no wasted connect.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
Dropping the per-batch awaitAll (previous commit) made the rounds faster but a
hop-3 A/B regressed total wall (727s vs 532s): the Semaphore(1024) throttled
concurrent relay drains to ~249 parked at peak vs the old batch model's ~4,438,
so slow-relay park windows that the old model absorbed during the rounds spilled
into a long serial finishing drain. Raise the default so the parked work drains
inside the rounds again. Value under validation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
Phase B drained users in 256-user batches: a worker called drainGated for the
whole batch and awaitAll'd every relay in it, so one slow relay held the worker
(and the batch's already-finished fast relays' contact lists) for the full 10s
fast window before anything was ingested. With 24 workers all waiting out their
batches' slowest relay at once, progress dropped to 0 lists/sec in waves.
Restructure to drain each relay independently and stream its result the instant
it resolves — no per-batch join. A per-user counter (relaysLeft) tracks how many
of a user's relays are still outstanding; the single-writer consumer finalizes a
user (ingest, or count a failed outbox attempt) only when the last of its relays
resolves, so correctness is unchanged. Concurrency is now a semaphore over
relay-units rather than an implicit batches×fan-out product; drainConcurrency
becomes "concurrent relay drains" (default 1024, ~the old 24-batch fan-out).
A straggler the outbox model routes nowhere is finalized directly as a miss.
Fast relays' lists are now ingested immediately instead of behind a batch's
slowest relay, removing the 0/s stalls on slow-relay-heavy rounds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
Per-relay attribution on observer 460c25e6 showed two big general relays hold
kind:3 for a chunk of the missing authors that no profile indexer has:
nostr.oxtr.dev (76 distinct) and nos.lol (72). Add both to the aggregator set
so the patient kind:3-only recovery pass sweeps them alongside the indexers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
The outbox model fetches a user's kind:3 only from their own kind:10002 write
relays (and the write-frequency backbone). But a large tail of reachable users
have no kind:3 on their own advertised outbox at all — it's dead, or they never
published one there — while a network-wide aggregator (user.kindpag.es, …) that
scrapes the whole network holds it. Those aggregators were queried only for
kind:10002 relay lists in ensureRelayLists, never for kind:3 content, so the
crawl structurally could not find these lists no matter how many rounds it ran.
Add Config.contentAggregatorRelays and fold it into routeByOutbox for stragglers
— users whose own outbox already failed (attempts > 0) or is unknown. The CLI
wires the profile indexers (kindpag/purplepag/coracle/yabu/nostr1) plus the
ActivityPub bridges (ditto/momostr/mostr, which host bridged users' lists);
--no-aggregators disables it.
Measured offline on observer 460c25e6 (max-hops 3): of ~2.2k users the crawl
left without a contact list, querying the aggregators for kind:3 recovers ~500
(user.kindpag.es alone ~180) — lifting coverage from ~89% toward ~92%. The
remainder have no kind:3 retrievable on any relay we know (bridged / inactive /
never-published) — a data-absence floor, not a crawl deficiency.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
classifyDrainFailure deliberately treats every timeout — connect timeout or
park idle-cut — as "busy, retry" and never dead, so a relay that connects but
never answers a REQ gets re-routed through every straggler's outbox, every
round, each visit burning the full timeout + park window for zero data. The
outbox model makes this worse: one dead server (e.g. filter.nostr.wine) is
advertised as hundreds of distinct per-user path URLs, so a per-URL counter
never reaches a threshold on any single one.
Count unproductive-timeout strikes per relay AUTHORITY (host[:port]) and evict
the whole host after Config.timeoutEvictStrikes (default 3; CLI --timeout-evict,
0 disables). Any clean EOSE or delivered event clears the authority, so only
never-productive hosts are evicted; a multi-path relay where some paths are
slow but others deliver stays live. Authority is host-only and never folds a
filter. subdomain into its parent, so an open bare host is untouched when its
sibling filter host is shed. Purely behavior-driven — no NIP-11.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
Speeds up a from-scratch GrapeRank crawl ~25-30% at equal completeness on a
drift-controlled A/B, by:
- lowering the per-relay concurrent-sub cap 100→16 — the old 100 drowned popular
relays (damus/nos.lol) in concurrent giant REQs, driving them to time out; 16
restores their responsiveness (damus yield 0%→14%) and is still generous for
the single-user fetches other amy commands do,
- shedding proven-dead relays from the kind:10002 discovery sweep instead of
re-hammering refusing indexers every round,
- trimming the sharded backbone sweep 6→2 rotations (Phase A was ~36% of the
crawl at half Phase B's per-list efficiency; 2 clears the bulk with no
completeness loss).
Also adds relay observability under --diagnose to document how relays reply to
our queries: per-relay telemetry (outcome mix, yield, latency, worst time-sinks),
a LIVE / THROTTLED / UNREACHABLE classification table with the limits we settled
on per relay, and per-round Phase-A/Phase-B timing; plus contact_lists_by_hop in
the sync result for per-hop completeness.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The crawl persists everything to the store and the score is a pure function
over it, so separate them: `amy graperank sync` crawls the reachable graph into
the store (idempotent + cumulative — run it a few times to be sure it's loaded)
and reports what it loaded without scoring; `amy graperank score` builds from the
store and scores instantly, repeatable with different params and no re-crawl
(same as bare `--offline`). Bare `amy graperank` stays the sync+score combo.
Extract the shared crawler wiring into newCrawler(); score() just forces the
offline path, and sync() runs a persist-only crawl (null builder).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
amy gated every non-primitive verb behind a chosen account: `DataDir.resolve`
threw when `~/.amy/` had no unambiguous account, and every networked command
called `Context.open`, which requires an identity — even though queries only
read relays and the shared store and never sign. `store` maintenance and the
local `offer`/`debit info` decoders were caught by the same gate despite
touching no account state.
Reads now work anonymously; only signing needs an account:
- `DataDir.resolveOptional` hands back an accountless dir (`hasAccount = false`)
pointing only at the shared event store when there is no unambiguous account,
instead of throwing.
- `Context.openOrAnonymous` uses the resolved account when present, else an
ephemeral key-less `Identity.anonymous()` — can read, can't sign. Marmot
stores are now lazy and run-state isn't persisted for anonymous runs, so an
accountless read leaves `~/.amy/shared/` clean.
- `Context.open` (signing path) re-asserts the requirement with the "which
account?" hint, so ambiguous/no-account signing verbs still exit 2.
- Main resolves optionally for every verb except the identity-lifecycle ones
(`init`/`create`/`login`/`logoff`/`whoami`), which still need a concrete
account. `offer info` / `debit info` join the stateless primitive block.
- Read subverbs (fetch, subscribe, count, publish, outbox, search, sync,
store, profile/git/podcast/podcast20 reads, nsite/napplet fetch·serve·list,
blossom download·check) switch to `openOrAnonymous`.
No `--json` shapes change. Docs updated (help text, README, DEVELOPMENT).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRaoqGod5LUeSwq4GHRSNF
A cross-account, read-only snapshot of everything amy holds under
`~/.amy/`, built for the returning user: which accounts exist, which
one is pinned as current, each signer type (local keychain/ncryptsec/
plaintext, NIP-46 bunker, or read-only) and whether it can still sign,
the per-account local footprint (aliases, Marmot groups, published
KeyPackage bundle, Cashu wallet, sync cursors), and the shared event
store's size.
Like `use`, it dispatches before account resolution so it works with
zero, one, or many accounts. Strictly metadata-only: it never unlocks a
private key (no keychain prompt / NIP-49 passphrase) and never touches
the network.
Factors the on-disk event-store walk into a shared `StoreStats` helper
reused by both `status` and `store stat`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3GJb11JkvopP61ETWAVyy
The CLI ran at the library's default Log.minLevel = DEBUG, so quartz internal
chatter (relay-auth init, MLS restore, URL-rejection, throttle notices) leaked
onto stderr around every command's real output. Set Log.minLevel = WARN at
startup, before dispatch, so a normal run shows only warnings/errors plus the
command's own progress. A new global --verbose / -v flag restores full DEBUG;
it's parsed with the other global flags so subcommands never see it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
Adds `amy logoff [--yes] [--keep-events]`, the CLI counterpart to logging
out: it removes everything an account left on the machine.
- the identity file and any backend-held secret (keychain / ncryptsec /
plaintext), via DataDir.deleteIdentity
- the rest of the per-account directory ~/.amy/<account>/ (run-state
cursors, aliases, cashu counters, all Marmot/MLS state)
- the ~/.amy/current pin, when it points at this account
- the account's events in the SHARED ~/.amy/shared/events-store/
The event store is shared across accounts, so logoff does not wipe it
wholesale — it deletes only the events that involve this account: those it
authored plus those addressed to it via a #p tag (gift wraps, nutzaps,
reactions, mentions). Other accounts' cached events are left untouched.
`--keep-events` skips the shared-cache purge entirely.
The public key is read straight from identity.json (never unlocking the
private key), so logoff needs no passphrase and pops no keychain prompt.
Destructive and irreversible, so it follows the `marmot reset` precedent:
`--yes` is required to execute; without it the command prints a dry run of
what would be deleted and exits 2.
Thin-assembly only — event deletion is quartz's FsEventStore.delete; this
just resolves the account, counts, and wires the filesystem teardown.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PH3rqz5KaA7CYFPAtxgoz1
- Suppress DEPRECATION on REASONABLE_SIGN_KINDS, which intentionally lists the
deprecated TorrentCommentEvent kind.
- Replace deprecated readLine() with readlnOrNull() in SecureKeyStorage.
- Drop unnecessary !! non-null assertions in KeyCommands and NostrConnect where
the receiver is already smart-cast to non-null.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nqdy4VTLKidUWzGTJPja9
The crawl was round-synchronised: each hop drained all its relays and only
started the next hop after the slowest one reached EOSE or the timeout. That
made waiting for slow-but-alive relays expensive — every hop paid its slow
tail before the next hop's fast relays could begin — so a long timeout for
completeness cost ~2x wall-clock (measured), and a short one dropped the slow
relays' data.
Diagnostics on a ~190k-user crawl showed the genuinely-slow set is a stable
~30 relays that DO reach EOSE, just in 5-25s. So decouple the two concerns:
- drainGated now drains on the FAST `timeoutMs` that sets the round cadence. A
relay still streaming when it elapses is not cut but PARKED: it hands its
open subscription to a background scope (releasing its AdaptiveRelayLimiter
permit so the round moves on), keeps receiving for up to the new
`parkTimeoutMs`, and its late events are persisted + its late contact lists
pushed to a crawl-wide lateHarvest channel.
- The round loop folds late harvest into the graph between rounds and won't
converge until the frontier is empty AND no relay is still parked — so the
crawl waits for slow relays for completeness without paying that wait in each
round's wall-clock.
Graph state stays single-writer: parked coroutines only touch the store,
seenIds, and the channel — never hopOf/done/builder. Persistence moved from a
single per-drain consumer to a shared `persist()` that fast and parked units
both call; crawl-wide dedup is now race-safe via ConcurrentSet.add's atomic
test-and-set (an id is added only after a good signature, so no duplicate
reaches the store's UNIQUE constraint and a forged copy can't suppress the
genuine one).
Also carries the --diagnose slow-relay logging (relay + filter + elapsed for
every slow/parked REQ, so a human can replay it) and keeps --drain-concurrency
at the validated default of 24 (an A/B at 64 was ~2x slower with more dead
relays). New --park-timeout flag (default 40s; set <= --timeout to disable).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
Add a --diagnose slow-relay log: every content drain that reaches its terminal
(EOSE or timeout) slower than SLOW_DRAIN_LOG_MS, or times out entirely, is
recorded with the offending relay URL, the failure/EOSE reason, elapsed ms, and
the exact filter shape (kinds + author count + first authors). This lets a human
replay that precise REQ later to understand why the relay lags. Gated on
--diagnose so there is no per-group timing/collection overhead otherwise.
Make the content-drain fan-out configurable via a new --drain-concurrency flag
(Config.drainConcurrency), replacing the DRAIN_CONCURRENCY constant. Default
stays at the validated 24: an A/B at 64 ran ~2x slower with more dead relays
(a higher global fan-out re-floods busy hubs faster than the per-relay demotion
catches up), so the flag is a probe knob, not a speedup. Client WebSocket pings
were also tried and reverted — busy-but-alive relays don't reliably pong while
their query handler runs, so pinging just cut them as dead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
Mirror the crawler extraction on the emit side: the NIP-85 kind:30382 card
reconcile + publish logic (existingCards read-back, rank-diff upsert, stale-card
kind:5 retraction batched under the 64KB event cap) moves out of GrapeRankCommand
into a reusable GrapeRankPublisher in quartz experimental/graperank. It takes an
IEventStore for the prior-card read-back and an injected publish function
(event + relays -> per-relay ack), so the store/relay wiring stays in the app
while the reconcile logic is reusable (e.g. by the Android app).
GrapeRankCommand is now a thin orchestrator: crawl (GrapeRankDataCrawler) ->
score (GrapeRank) -> publish (GrapeRankPublisher). The account-specific bits stay
in the CLI: operator-key derivation, the observer's kind:10040 discovery pointer,
and the operator/register/providers sub-verbs.
The crawl re-verified and re-inserted the same event many times: the outbox
model mirrors each event (especially kind:10002 relay lists) across relays,
indexers, and rounds, but dedup lived in a per-drain SeenIds, so only the copies
within one drain were caught. Add a crawl-wide seen-set (thread-safe ConcurrentSet
of event ids, shared across all 24 concurrent drains and every round), checked
before verify and added only after verify so a forged copy can't suppress the
genuine one. Group-commit the store writes via IEventStore.batchInsert instead of
one transaction per event.
Measured on a from-scratch --max-hops 3 crawl: events actually verified+stored
dropped ~34% (112k -> 74k) and verify time fell in lockstep. The write path now
also reports verify/insert timing + events_stored in Stats, exposed as verify_ms/
insert_ms/events_stored on the CLI, and takes an --insert-batch knob.
Finding: with the work reduced, inserts serialize on SQLite's single writer
mutex rather than transaction count, and the crawl's wall-clock ceiling is the
drain-timeout retry tail on dead outboxes, not the disk.
Cleanups from a reuse/simplification/efficiency/altitude review of the crawler
extraction:
- Collapse the redundant `discovered` set into `hopOf` — a user is discovered
iff it has a hop stamp, so the two always held the same key set. The frontier
is now `hopOf.keys`; one fewer collection to keep in sync.
- Drop the unused `Stats.discovered` / `Stats.deadRelays` fields (no reader —
the CLI reports rounds / relaysContacted / hopHistogram / downloadMs).
- Extract a single shared verify-then-store sink, `IEventStore.verifyAndInsert`,
and route both the crawler and `Context.verifyAndStore` through it instead of
each carrying its own verify + insert + UNIQUE-swallow copy.
- Fast-path the present-key hit in `ConcurrentMap.getOrPut` (jvmAndroid) so the
crawl's hot relay-hint accumulation stops allocating a mapping-function closure
on every call.
- Hoist the repeated `crawlStats?.hopHistogram` null-plumbing in GrapeRankCommand.
The web-of-trust crawl (~400 lines: outbox routing, sharded backbone sweep,
Phase-B worker pool, relay-list discovery, report-deletion fetch, warm pool)
was making the CLI's GrapeRankCommand unmaintainably large. Move it into a
reusable, KMP-portable GrapeRankDataCrawler in quartz commonMain.
The crawler takes a NostrClient + IEventStore + AdaptiveRelayLimiter, injected
relay policy (discovery + content-fallback sets, since those defaults live in
app code, not the protocol library), and a log callback; it streams contact
lists into a TrustGraphBuilder and returns crawl Stats. GrapeRankCommand shrinks
to arg-parsing + offline load + scoring + publish + sub-verbs, delegating the
online path to the crawler.
To reach commonMain (portable to every target, incl. iOS):
- Add ConcurrentMap / ConcurrentSet expect classes under utils/concurrent, with
jvmAndroid actuals (java.util.concurrent) and native actuals (copy-on-write
over kotlin.concurrent.atomics.AtomicReference, mirroring ConcurrentHashCache).
commonMain has no ConcurrentHashMap, and the crawl's producer/consumer/drain-
worker state needs atomic getOrPut/merge plus a concurrent set.
- Move AdaptiveRelayLimiter and DrainFailure/classifyDrainFailure from cli to
quartz commonMain (java atomics -> kotlin.concurrent.atomics, ConcurrentHashMap
-> ConcurrentMap, System.currentTimeMillis -> TimeUtils.nowMillis, stderr -> Log).
- The gated drain (REQ-size splitting, per-relay permits, verify+store) moves into
the crawler; Context.drain loses its now-unused gatePerRelay path.
Net: cli -1077 lines; the crawler + relay machinery are now reusable by the
Android app. Adds ConcurrentCollectionsTest; verified via JVM + commonMain
metadata compile, the wot/graperank suites, and a bounded live crawl.
Each addressable coordinate is ~130 bytes, so 500 pushed the deletion event to
~65KB — over the 64KB event-size cap many relays enforce (stricter than the
256KB message cap). Drop DELETE_PER_EVENT to 400 (~52KB).
README + amy usage: add the `graperank operator [status|relay|providers]`
sub-verb, update the `graperank --publish` description to the per-observer
service-key model (sign with a derived key, publish to the operator relay,
reconcile new/changed/skip/retract, cutoff rank>=2, NIP-09-drop retracted
reports), and add a 'Publishing GrapeRank scores' section explaining the
operator master, deterministic per-observer key derivation, and the kind:10040
discovery wiring.
Rewire `graperank --publish` onto the operator-key model:
- Sign each observer's kind:30382 cards with the dedicated service key derived
for that observer (OperatorKeys), not the account key — a stable per-observer
identity so re-signing replaces the addressable prior card.
- Publish to the operator's configured relay(s) (new `graperank operator relay
<url>` sub-verb; --publish-relay still overrides). Errors clearly if unset.
- Three-way reconciliation against what the provider key already published:
upsert cards whose rank tag string changed (or are new), skip unchanged, and
RETRACT (kind:5, same service key, addressable `a`-tag, chunked under the
message cap) any existing card whose target is no longer publishable — dropped
from the graph, or below the cutoff.
- Raise the default publish cutoff to rank >= 2 (drops the barely-trusted tail);
the retract rule removes any now-sub-cutoff cards.
- When we hold the observer's key (observer == active account), publish/refresh
their kind:10040 pointing 30382:rank -> providerPubkey at the operator relay,
to their outbox — the pointer clients follow to find the cards.
Adds `operator [status|relay|providers]` for managing the machine's operator.
A machine holds one operator master seed, independent of any amy account, stored
under ~/.amy/operator/ via the same SecretStore backend the accounts use. From it
OperatorKeys deterministically derives one service key per observer —
serviceKey(observer) = sha256(masterPriv || "graperank-provider:" || observerHex)
— which will sign that observer's kind:30382 rank cards and their retractions.
Deterministic derivation gives a stable per-observer identity (so re-signing a
card replaces the addressable prior one instead of orphaning it) and one-secret
backup (every service key re-derives from the master alone). The manifest
(operator.json) records the master pubkey, operator relay(s), and observer ->
provider-pubkey mapping — public data; only the master rides the SecretStore.
Exposed via DataDir.operatorKeys(). Wiring into publish comes next.
A REQ carries all of a subscription's filters in one frame, so a popular relay
routed thousands of authors produced a multi-MB frame that most relays reject
outright ("message too large (2MB > 256KB)"), silently dropping every author
in it. The gated drain now splits each relay's filters into REQ-sized groups by
total entry count (authors + ids + tag values), MAX_REQ_ENTRIES=2500 (~167KB,
under the common 256KB cap), and opens one gated subscription per group. A relay
with more authors simply gets several smaller REQs instead of one rejected huge
one. Each group carries its own subId/listener/terminal signal; per-relay
failure classification takes HARD over TRANSIENT across a relay's groups.
The outbox model — and especially the wide relay-list broadcast — delivers the
same event from many relays at once, and the gated drain ran a Schnorr verify
(and a store insert) on every copy before the store's UNIQUE constraint dropped
it. On a fan-out that asks hundreds of relays for the same kind:10002s, that is
hundreds of redundant verifications per event and pegged a core.
Add a per-drain SeenIds skip-before-verify to the consumer, mirroring
drainAllPages: an id is marked seen only after it verifies, so a forged copy
(valid id, bad signature) delivered first can't suppress the genuine one. Cuts
the redundant verification across the whole crawl, not just the wide sweep.
Replace the hand-rolled report-id/author matching with quartz's DeletionIndex —
the same NIP-09 indexer the Android app's LocalCache uses. It keys each deletion
under the deleter's pubkey, so hasBeenDeleted(report) is authoritative only when
the report's own author deleted it, and it also handles created_at ordering (and
addressable events, for free). Deletions come from the store, which already
verified them, so they're added as pre-verified.
A report the author has since deleted should not count as a negative trust
edge. After the crawl, ask each reporter's outbox for kind:5 deletion requests
that cite the reports we gathered — #e-filtered to those report ids, so we pull
only the deletions that affect our reports, not every deletion the user ever
made. When building the graph, a report is dropped iff a kind:5 in the store
cites its id AND is signed by the report's own author (NIP-09: a deletion is
authoritative only from the event's author). Reports the reporter never
retracted are unaffected. The run reports reports_deleted.
Gate publishing on the exact rank TAG VALUE STRING, not a re-parsed Int. A
card carries only a `rank` tag (plus the d-tag target), and RankTag.assemble
writes `rank.toString()`, so we diff that string against the one on the newest
kind:30382 card the signing key already published (read back from the store).
An unchanged score is skipped — no new signature, no new event id — so a client
that syncs the provider's cards by id only ever downloads the ranks that
actually moved. Replaces the prior Int comparison with a faithful
what-would-be-written string diff.
Three crawl fixes:
1. Fetch kind:10002 alongside content. The content query asked only for
3/10000/1984, so a user's freshest relay list — which lives on their own
outbox — was never pulled from there; we trusted a possibly-stale indexer
copy. Fold 10002 into the same fetch. The store keeps newest-by-created_at,
so pulling it from popular relays too can't stale it.
2. Widen ensureRelayLists Tier 2. It only asked the top-30 backbone for a
still-missing 10002. A stray relay list can sit on any one relay, so Tier 2
now asks EVERY relay we've seen work — fired fire-and-forget on a background
scope so the large fan-out never blocks the round; results enrich routing
for later rounds.
3. Classify dead relays instead of striking everything the same. A connect
TIMEOUT is a busy relay — retried, never marked dead. A HARD failure (bad
domain, TLS misconfig, dead HTTP code — see DrainFailure/classifyDrainFailure,
keyed on the exception type now in the failure message) is dropped on the
first strike. Transient failures (refused/reset/unreachable, 429/5xx) keep
the multi-strike leniency. Connect timeout raised 5s -> 7s so slow-but-alive
relays finish the handshake.
A relay pushes back for two different reasons that need two different fixes,
and treating them the same mishandles the relay:
- a subscription-COUNT cap ("too many subscriptions", "maximum concurrent
subscription count") is fixed by fewer CONCURRENT subs — demote the
per-relay concurrency cap (100 -> 20 -> 10), as before;
- a RATE limit ("rate-limited: too many messages", "burst exhausted") is
too many subscription CHANGES per second — fewer concurrent subs don't
help; the fix is to SPACE the REQs out in time.
AdaptiveRelayLimiter now routes each complaint to its own actuator by matching
the notice text, and adds a per-relay rate gate: a growing minimum interval
between subscription opens (250ms -> 500ms -> 1s -> 2s), enforced in withPermit
before the concurrency permit. A relay can be under both controls at once. The
snapshot reports each dimension separately.
Phase B drained DRAIN_CONCURRENCY batches, waited for the SLOWEST (a dead
relay's full timeout), ingested, then started the next group — so every
batch's long tail idled the whole pool, and connections were torn down and
rebuilt between groups. Replace the chunked awaitAll barriers with a
continuous producer -> workers -> consumer pipeline:
- Producer (1 coroutine) routes each author-batch by outbox and feeds a
bounded queue (keeps writeRelayFreq single-writer, backpressured so we
don't precompute every filter map at once).
- DRAIN_CONCURRENCY workers pull a batch, drain it, and grab the next the
instant the drain returns — no worker waits on a slow sibling, and hot
relays stay connected because some worker is always subscribed to them.
- Consumer (1 coroutine) ingests serially (discovered/done/builder/hopOf
stay single-writer), now overlapped with draining instead of blocked
behind each batch.
The four structures now crossed between producer/worker/consumer
(relayHints, attempts, deadRelays, relayStrikes) become concurrent; all
graph mutation stays single-writer on the consumer. Removes the dead-relay
timeout stalls that were serializing the crawl and cuts reconnect churn.
The crawl's client ran on OkHttp defaults: Dispatcher.maxRequests=64 and a
10s connectTimeout. Every relay WS-upgrade handshake is an async call through
that shared dispatcher, so 64 caps the connection-ramp width — and a dead
relay squats on a slot for the full connectTimeout, starving live relays
queued behind it (observed: only ~150 sockets open at once during an active
wave touching hundreds of relays). Raise maxRequests to 256 /
maxRequestsPerHost to 16 and drop connectTimeout to 5s so unreachable relays
release their slot fast.
This is orthogonal to REQ concurrency (bounded per-relay by
AdaptiveRelayLimiter on already-open sockets), so it can't trip a relay's
REQ rate-limit — it only speeds connection setup. The dispatcher's executor
pool grows threads on demand, and FD headroom is ample (4096 limit vs ~150
in use), so the wider cap just lets more short-lived handshakes run at once.
Measured 48 against the per-relay adaptive limiter (hop-4 A/B): it demoted
the right hubs and cut rate-limited CLOSEDs further (1433 -> 501), but the
higher fan-out re-floods busy relays faster than demotion catches up — a
new dominant complaint ("max concurrent subscription count reached")
appeared and download_ms regressed ~11% vs the 24 baseline. Keep the
adaptive per-relay cap (it targets the misbehaving relays precisely) but
return the global fan-out to 24, where the 20/10 ladder still bites below
the global bound and wall-time stays at its best-observed value.
The GrapeRank engine, TrustGraph (compact int-CSR) and TrustGraphBuilder
are pure Nostr-social-graph computation over HexKeys — no UI, no Compose,
and no commons-only dependency. They're a utility for implementing the
NIP-85 rank assertions quartz already models, so they belong in quartz
rather than commons. Move commons/wot -> quartz experimental/graperank
(package com.vitorpamplona.quartz.experimental.graperank), including both
commonTest suites, and repoint the CLI import. TrustGraphBuilder was already
protocol-agnostic (takes HexKey lists; the caller does the event->edge
extraction), so nothing had to change but the package. Makes the algorithm
reusable by the Android app for spam/trust filtering without pulling in
commons.
Replace the blunt global concurrency number with per-relay back-pressure.
Every relay starts generous (100 concurrent subscriptions) and is demoted
down a ladder (100 -> 20 -> 10) only when it complains about concurrency —
a CLOSED rate-limited, or a NOTICE like "too many concurrent REQs" /
"too many subscriptions" / "burst exhausted". Well-behaved relays keep
the full cap; only the busy hubs that push back get throttled, and only as
far as they keep pushing.
AdaptiveRelayLimiter registers as a RelayConnectionListener so demotions
are driven straight off the same NOTICE/CLOSED frames RelayDiagnostics
already observes, keyed by relay.url. Context.drain gains a gatePerRelay
path that opens one subscription per relay, each held behind that relay's
gate, so our concurrent subs on it never exceed its current cap. The gate
is a fair FIFO bounded semaphore whose limit can only be lowered; shrinking
below the in-use count admits no new subs until enough finish, so
concurrency converges down to the new cap.
Because a hot relay can no longer be flooded, the global content-drain
fan-out is raised (18 -> 48) to crawl the many well-behaved relays faster.
The crawl emits a relay_throttling summary (which relays were capped, and
to what) alongside relay_feedback.
Each concurrent content drain opens exactly one subscription per relay it
touches, so DRAIN_CONCURRENCY is effectively the per-relay concurrent-sub
cap. RelayDiagnostics showed the previous value (24) blew past typical
relay limits — rate-limited=1433, "too many concurrent REQs"=1286,
"too many subscriptions"=710 — causing dropped fetches and retry churn.
Lower it to 18 so the peak (18 drain subs + 1 persistent warm-pool sub)
stays ~19, just under the common ~20 cap.
The client's relay pool reconciles open sockets to the relays that active
subscriptions currently need, so between-round gaps (routing + contactsOf scans
> ~300ms) and niche-relay churn dropped connections we reuse every round, then
reconnected them — a TCP+TLS+WS handshake each time.
Hold a persistent do-nothing subscription (WARM_SUB_ID) open to the busiest
WARM_POOL_SIZE(20) live relays, refreshed to the current top set at each round
start (same subId → just updates the desired-relay set) and closed when the
crawl finishes. Its filter matches an impossible event id, so the relay EOSEs
immediately and streams nothing — it only keeps the socket warm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
Two blind spots in the crawl's relay I/O:
- No NIP-42 auth. Auth-gated relays sent AUTH, the client never answered, and
their sub CLOSed 'auth-required' — so those outboxes served us nothing. Wire a
RelayAuthenticator into Context that signs the AUTH challenge with the account
key (local signer only; a remote bunker is skipped to avoid a per-relay
round-trip storm mid-crawl). Signing with any key still unlocks relays that
just want some auth.
- No visibility into REQ failures. Add RelayDiagnostics, a connection listener
that tallies NOTICE frames, CLOSED reasons by NIP-01 prefix (auth-required /
rate-limited / restricted / …), and AUTH challenges. Surfaced in the crawl's
stderr summary and as relay_feedback in the JSON, so a failed fetch can be
explained instead of guessed at.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
Review of the sharded-sweep restructure flagged two recall regressions vs the
removed last-mile:
- the sweep + broadcast only ever hit the top SHARD_RELAYS (10), while the old
last-mile reached busy relays ranked 11-80 where a user's kind:3 is often
mirrored. Broadcast the small remainder to BROADCAST_RELAYS (60) top live
relays instead of just the rotation's 10, restoring that reach (indexers are
intentionally excluded — they don't serve kind:3).
- MAX_DEAD_STRIKES was 2 with no recovery, so two transient connect blips
evicted a relay for the whole run. Raise to 3 for a safety margin; drain still
only counts hard connect failures, not slow relays.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
The download phase was ~90% idle, blocked on the per-wave drain timeout waiting
on dead/stalled outbox relays. Three changes:
1. Dead-relay pruning. Context.drain now reports relays that failed to CONNECT
via a deadOut set; the crawl strikes them (MAX_DEAD_STRIKES=2) into a
deadRelays set and excludes them from routeByOutbox and the sweep, so a wave
stops re-paying the timeout on the same dead outboxes.
2. DRAIN_CONCURRENCY 8 -> 24, safe now that dead relays are pruned rather than
piling up as stalled connections.
3. Sharded backbone sweep (Phase A each round): split the pending authors across
the top-SHARD_RELAYS(10) live relays — one shard per relay, no relay gets the
same list twice — drain all concurrently, rotate the still-missing onto
different relays for up to SHARD_ROTATIONS(6) passes, then broadcast the
remainder to all top relays only once it drops below SHARD_BROADCAST_THRESHOLD
(2000). Phase B then outbox-routes only whoever the popular relays lacked. The
old broadcast-everyone last-mile is removed (the sweep subsumes it).
Each concurrent drain uses its own dead-set (no shared-HashSet race); harvest
feeds from the drain's returned events instead of re-scanning contactsOf over
the whole missing set. Adds download_ms so the crawl phase is timed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
The online crawl phase — the network-bound fetch of the whole graph off the
relays (rounds + last-mile sweep) — was untimed; only the offline store-load
had a phase timer. Add download_ms around the crawl block and fold it into the
"crawl complete" log and the JSON, so a from-scratch run reports every phase:
download -> graph build -> scoring (and, with --bench-sign, card signing).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
Adds a benchmark path to `amy graperank`: after scoring, build + sign one
NIP-85 kind:30382 ContactCardEvent per scored user (rank >= --min-rank) with a
throwaway keypair, fanned out across CPU cores, and report bench_signed +
bench_sign_ms. The signed events are discarded — this measures the id-hash +
Schnorr-sign cost of emitting the full card set without touching any relay or
real identity. Complements the existing store_load_ms / graph_build_ms /
scoring_ms phase timings so the whole pipeline (load → build → score → sign) is
measured end to end.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
The worklist re-enqueued a node's dependents on every >convergence nudge, so on
a dense graph its total work scaled with the in-degree of the churning core —
an offline score over the full ~419k-node / 19.3M-edge Vitor graph ran 640M+
node-visits and still had not converged after 32 minutes.
Replace it with synchronous Gauss-Seidel sweeps over all nodes (in-place
updates, so trust flows outward within a sweep), ending when no node moves more
than the convergence delta. Same per-node formula, same 0.0001 threshold, same
unique fixed point as NosFabrica's Brainstorm reference (which iterates the same
way) — verified byte-identical by the existing GrapeRankTest suite — but each
node is touched once per sweep instead of once per churning rater, cutting the
work by roughly the average in-degree. Progress now reports per-sweep
(node-updates + nodes-still-moving) and the CLI surfaces the sweep count.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
The graph_build_ms timer covered only builder.build() (the in-memory int-CSR
pack, a few hundred ms). It excluded the real pre-scoring cost: reading and
deserializing every kind:3 contact list out of the store. Add store_load_ms
(offline path) so the three phases — store load, CSR build, scoring — are each
measured and reported (stderr + JSON), instead of the load hiding behind a
misleadingly small build number.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB