Status: Normative · API version: 1 · Crate: dig-peer-selector
This document is the authoritative contract for dig-peer-selector, the self-optimizing
peer-selection middleware of the DIG Node. An independent implementation built from this document
alone MUST behave interchangeably with the reference crate: given the same registry feed and the
same stream of measured outcomes, it MUST make selection decisions with the same invariant
properties (it need not reproduce the exact numeric scores — §1.4).
The selector is a pure decision + learning layer. It sits between dig-download (the executor
that fetches bytes) and the peer-discovery layers (dig-dht, dig-pex/dig-gossip, dig-nat). It
answers one question — "of these candidate peers, which subset should serve this content, and in
what order?" — and it learns the answer from the real, measured outcome of every transfer it
influenced. It has no user-facing configuration: every tradeoff is self-tuned from observed
data.
The selector maintains a dynamic registry of candidate peers (fed by the discovery layers), attaches to each peer a learned quality model derived from real download outcomes, and exposes a closed feedback loop:
dig-download(via the node) asks the selector toselectthe best peer subset for a content request.dig-downloadexecutes the multi-source byte-range transfer overdig-natmTLS mux streams.- Every per-range and per-request outcome (throughput, latency, success/failure, which peer, which
range) streams back into the selector via
record_outcome, updating the models in real time so the nextselect— and a mid-transfer re-balance — is smarter.
The loop is autonomous: it improves selection with no manual tuning and no user-visible knobs.
The selector MUST NOT perform, own, or re-implement any of the following — they belong to the named crates and the selector only reads their outputs or drives their choices:
- Discovery / the DHT lookup. It does not query the DHT. The node calls
dig-dht'sfind_providersand hands the resulting candidates in (§7.1). (Peer network §4c.) - Topology maintenance. It does not run the gossip peer pool or PEX; it subscribes to their churn to keep its registry current (§7.2). (Peer network §4d.)
- Transport / NAT traversal. It never opens a socket, a TLS session, or a mux stream. All
node-to-node traffic is mTLS via
dig-nat; the selector only reads the established connection class (§7.3). (Peer network §0 dual-transport, §2.) - Byte transfer / verification / pause-resume. It does not fetch, verify, decrypt, or persist
ranges.
dig-downloadowns the transfer, per-chunk merkle verification, and resume (§6). - User configuration. It exposes no behavior-changing knobs (§1.5). Internal hyperparameters are self-tuned from observed data, never surfaced as settings.
- The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as in RFC 2119.
peer_id— the mTLS peer identity,peer_id = SHA-256(TLS SubjectPublicKeyInfo DER), 32 bytes, rendered as 64 lower-case hex on every text surface. This is the SAME identity acrossdig-nat,dig-gossip,dig-dht, anddig-pex; the selector re-uses it verbatim and MUST NOT invent its own peer key. (Peer network §1.)- Connection class — the
dig-nattraversal tier that established a peer link:Direct,Upnp,NatPmp,Pcp,HolePunch,Relayed(dig_nat::TraversalKind). Per the relay-last-fallback invariant,Relayedmeans the relay carries every byte; every other class is a peer-to-peer data path. (Peer network §10.) - Peer class — an equivalence bucket the selector learns saturation behavior per, keyed by the coarse connection-path category (§4.1). NOT the same as the raw connection class.
- Candidate — a peer offered as a possible source for a specific content request (from
dig-dhtproviders, filtered/ordered against the registry). - Outcome — a measured result of a real transfer attempt (a range or a whole request): the only input that moves a peer's quality model (§3, §9.2). Advertised or self-reported capacity is not an outcome and MUST NOT move the model.
- Registry — the selector's live map of
peer_id → PeerEntry(§2).
This spec fixes the selector's API shapes, its inputs, and the invariant properties its learning and scoring MUST satisfy (§4.4, §12). It deliberately does not fix a scoring formula, a decay constant, or a saturation threshold — those are learned. An implementation conforms iff:
- its public API matches §5 exactly (the frozen surface); and
- its behavior satisfies every invariant in §4.4 and passes the conformance harness in §8.
Two conforming implementations MAY assign different numeric scores to the same peer; they MUST reach the same qualitative decisions the invariants describe (converge to fast/reliable peers, spread load off a saturating peer, adapt when a peer degrades).
The selector exposes no settings that change its selection behavior. SelectorConfig (§5.6) MAY
carry pure wiring (clock source, registry capacity bound, an RNG seed for deterministic tests) but
MUST NOT expose scoring weights, decay constants, saturation limits, or a relayed penalty as
tunables. Any such quantity is a learned internal state, not a knob. A build that exposes a scoring
knob to the user does not conform.
The registry maps peer_id → PeerEntry. An entry carries identity, reachability, and the
learned quality model. Its logical shape (field names are the API contract; the in-memory type MAY
add private fields):
PeerEntry {
peer_id: PeerId, // SHA-256(TLS SPKI DER); the registry key
addresses: Vec<CandidateAddr>, // dial candidates, byte-compatible with dig-dht/L7
connection_class: Option<TraversalKind>, // the dig-nat tier of the live link, if connected
provenance: Provenance, // how this peer entered the registry (§2.3)
quality: PeerQuality, // the learned model (§3) — MEASURED-only
first_seen: u64, // unix seconds, when first registered
last_outcome_at: Option<u64>, // unix seconds of the most recent measured outcome
}
addressesuses theCandidateAddr { host, port, kind }shape (kindsdirect|mapped|reflexive|relay) byte-compatible withdig-dhtprovider records and the L7dig.getPeersaddress shape (Peer network §7). The selector MUST sort candidate addresses most-direct-first bykindrank when it needs a preferred dial target; it MUST NOT assume wire order.connection_classis observational only — read fromdig-nat(PeerConnection::method/NatPeerConnection::method()) for a currently-connected peer,Noneotherwise. It seeds relayed-penalty priors but is subordinate to measured outcomes (§3.3, §4.2).qualitystarts cold (§3.5) for a newly-registered peer and is refined only byrecord_outcome.
Provenance records how the peer entered the registry. It is a hint about trust of the address,
never a substitute for a measured outcome (§9.1):
Provenance = Dht | Gossip | Pex | Nat | Manual
| Token | Meaning |
|---|---|
Dht |
Learned as a dig-dht provider record for some content (§7.1). |
Gossip |
Present in the dig-gossip connected pool (a live mTLS link) (§7.2). |
Pex |
Learned via peer exchange — a HINT only, quality unknown until measured (§7.2, §9.1). |
Nat |
A connection whose class dig-nat reported directly. |
Manual |
Injected by the host for testing / bootstrap. |
A peer MAY be known via several provenances; the registry keeps the strongest-evidence one and MAY retain the set. Provenance MUST NOT by itself raise a peer's quality score — only measured outcomes do (§9).
The registry is fed, not self-discovered. Two feeds keep it live:
-
Gossip pool churn (the topology feed). The host subscribes to
dig-gossip::GossipHandle::subscribe_pool_events()and forwards eachPoolEventto the selector viaon_pool_event(§5.4):PoolEvent::PeerAdded { peer_id, addr }→ the selector upserts a registry entry (provenanceGossip), preserving any existing learnedquality(a reconnecting peer keeps its history — §2.4).PoolEvent::PeerRemoved { peer_id, reason }→ the peer is marked disconnected; its entry and its learnedqualityare retained (not deleted) so a later reconnect resumes from history, subject to §2.5 eviction.reason(Disconnected|Dead|Banned) is recorded;BannedSHOULD make the peer ineligible for selection until re-added. The host MAY additionally seed the registry from aconnected_pool_peers()snapshot at startup.
-
DHT providers (the candidate feed). On each content want the host passes
dig-dhtfind_providersresults intoselectascandidates(§5.1); a candidate not yet in the registry is upserted (provenanceDht) with a cold quality model, so its first selection is exploratory (§4.4-E) and its first outcome starts its history.
dig-nat connection classes are attached as connections are established (on_connection_class,
§5.4).
The registry key is always the transport-verified peer_id. The selector MUST NOT trust a
peer_id, address, or class asserted in any message payload; the only authoritative source of a
peer's identity is the mTLS handshake that dig-nat completed (§9.1). Addresses arriving via
dig-pex/DHT are dial hints — proven only when a transfer over them yields a measured outcome.
The registry MUST be bounded (a capacity in SelectorConfig, a pure resource limit, not a behavior
knob). When over capacity the selector evicts the lowest-value entries first, where value
combines staleness (last_outcome_at age) and learned quality, PREFERRING never to evict a
currently-connected peer or one with a range in flight. Eviction of a peer's entry discards its
learned quality; a re-learned peer starts cold again. Eviction MUST NOT be observable as a behavior
knob.
The capacity bound is a HARD limit, not merely a preference. A dispatch whose outcome never
arrives (the peer went silent post-dispatch) MUST NOT be able to pin an entry as permanently
unevictable: an unsettled in_flight count is reclaimed after a bounded dispatch TTL (§5.3), and if,
after reclamation, every remaining entry is still connected or in-flight, capacity enforcement MUST
fall back to evicting the lowest-value non-connected entry regardless of its in_flight count. A
currently-connected live link is the only thing eviction may never touch; everything else is subject
to the bound. This closes the resource-bound bypass an attacker could otherwise trigger by feeding a
stream of unique cold peer_ids that are dispatched to (via exploration / anti-starvation forced
coverage, §4.4-E) and then never report an outcome.
PeerQuality is refined only from measured outcomes (§9.2). It captures at least:
PeerQuality {
throughput: Estimate, // achievable bytes/sec, learned (§3.2)
rtt: Estimate, // round-trip / time-to-first-byte latency, learned (§3.2)
reliability: Estimate, // success probability in [0,1], learned (§3.4)
samples: u64, // count of measured outcomes folded in (confidence)
in_flight: u32, // ranges currently assigned to this peer (live, not learned)
}
throughput and rtt are the peer's observed capacity. Advertised or self-reported capacity
(e.g. a peer claiming a bandwidth in a message) MUST NOT be folded into these estimates — observed
capacity always overrides advertised (§9.3). A peer that claims to be fast but measures slow
is treated as slow.
Each of throughput and rtt is a recency-weighted estimate over the peer's outcome stream. An
implementation MAY use an EWMA, a decaying reservoir, a windowed quantile, or any estimator that
satisfies ALL of the following normative properties:
- P-recency. A more recent outcome influences the estimate at least as much as an older one; as outcomes accumulate, ancient samples' influence decays toward zero.
- P-monotone-convergence. Under a stationary outcome stream (a peer that consistently delivers
throughput
T), the estimate converges towardTand stays within a bounded neighborhood of it. - P-responsive-degradation. When a peer's true performance drops from
T_hightoT_lowand stays there, the estimate converges towardT_lowwithin a bounded number of subsequent outcomes — it MUST NOT stay pinned nearT_high(§4.4-D). - P-volatility-tracking. The estimator's effective memory (how fast it decays) is adaptive to observed volatility, not a fixed policy constant: a peer whose outcomes are steady is smoothed over a longer effective window; a peer whose outcomes swing widely decays faster so a stale-but-once-good reading falls off. The decay is derived from the data, per §4.3.
The estimator carries enough state to expose a confidence (via samples and, SHOULD, an
observed variance) so the scorer can distinguish a well-measured peer from a barely-sampled one
(§4.4-E).
The connection_class seeds a prior on a cold peer only: a Relayed peer starts with a
throughput/latency prior no better than a direct peer's, so it is not preferred before it is
measured. The prior is a starting point, not a cap: once measured, the peer's estimate is driven by
outcomes, so a relayed peer that measures fast is used and a direct peer that measures slow is
demoted. The magnitude of the relayed prior is itself learned (§4.2), not a fixed constant.
reliability is a recency-weighted success rate over {success, failure} outcomes (a
timeout/interruption/verification-failure is a failure — §6.2). It satisfies the same P-recency and
P-responsive-degradation properties: a peer that starts failing MUST see its reliability fall, and a
recovering peer MUST see it rise. A verification failure (a peer served bytes that failed the merkle
check — §6.3) is a hard failure and SHOULD penalize reliability more sharply than a transport
timeout, because it may indicate a bad or hostile source.
A newly-registered peer has samples == 0 and an unknown quality: neither optimistic nor
pessimistic, but uncertain. The scorer MUST give cold peers a bounded exploration allowance so
they get measured (§4.4-E) — the selector cannot learn a peer it never tries — while never letting
unmeasured peers crowd out proven fast ones for the bulk of a transfer.
The selector learns, per peer class, each peer's saturation point — the number of concurrent in-flight ranges beyond which that peer's measured throughput stops rising (and then degrades). It MUST:
- estimate saturation from the observed relationship between a peer's
in_flightat dispatch time and the throughput the resulting outcomes measured (more concurrency that yields no more aggregate throughput, or lower per-range throughput, signals saturation); - stop assigning a peer additional concurrent ranges once it is at or past its learned saturation point, spilling the excess to the next-best peer (§4.4-B, the anti-thundering-herd rule);
- generalize across peers of the same class (peers reached by a similar connection path share a saturation prior) so a new peer of a known class inherits a sane concurrency prior before it is individually measured, then specializes as its own outcomes arrive.
The saturation point is learned, not a fixed constant; it MUST adapt if a peer's capacity changes.
A relayed link often (not always) has lower throughput and higher latency, and consumes shared relay bandwidth. The selector learns a relayed penalty rather than hardcoding one:
- the penalty is derived from the observed frequency and real performance impact of relayed transfers — how often relayed peers are the only/relevant option, and how their measured throughput/reliability compares to non-relayed peers;
- a relayed peer that consistently measures fast MUST NOT be over-penalized (its measured quality dominates); a relayed peer that consistently measures slow MUST be de-prioritized accordingly;
- the penalty tracks the environment: in a network where relayed links measure nearly as well as direct, the learned penalty shrinks; where they measure much worse, it grows.
This is a learned prior/adjustment, subordinate to per-peer measured quality (§3.3). It MUST NOT be exposed as a user knob.
The recency-weighting/decay of each peer's quality (§3.2 P-volatility-tracking) is driven by that peer's observed volatility, not a baked-in policy constant:
- a peer whose recent outcomes are consistent is decayed slowly (its history is trustworthy);
- a peer whose recent outcomes swing widely is decayed quickly (a reading from an hour ago is a poor predictor), so "was fast an hour ago but is now degraded" is reflected promptly (§4.4-D).
An implementation MUST derive the effective decay from the data (e.g. from the estimate's residual variance / an observed change-rate); it MUST NOT expose a single global decay constant as policy.
The scorer's objective is, jointly:
- minimize P99 request latency across the node's download requests (make the slowest requests faster — not merely the median), and
- avoid thundering-herd collapse on high-capacity peers (do not route so much concurrency to the one fast peer that it saturates and its throughput collapses).
An implementation's scoring and dispatch MUST satisfy these frozen invariants:
| ID | Invariant |
|---|---|
| A · Convergence | Under a stationary topology, repeated select+record_outcome cycles converge to preferring the fast, reliable peers: a peer with strictly higher measured throughput and reliability is ranked ahead of a strictly worse one (all else equal), and receives a larger share of ranges over time. |
| B · Load-spread / anti-herd | The selector MUST NOT pile unbounded concurrency on a single high-capacity peer. Once a peer reaches its learned saturation point (§4.1) it receives no additional concurrent ranges; further demand spreads to the next-best peers. Sending every range to the single fastest peer is a conformance failure even if that peer has the best point estimate. |
| C · P99 orientation | Given a set of ranges and candidates, the chosen subset+assignment MUST reduce the expected completion time of the whole request (bounded by its slowest range), not just pick the single best peer. A peer likely to leave a range straggling (low reliability / near saturation / high tail latency) MUST be down-weighted for tail-sensitive assignment even if its median throughput is high. |
| D · Degradation adaptation | When a peer's measured performance degrades and stays degraded, its rank MUST fall within a bounded number of subsequent outcomes (§3.2 P-responsive-degradation, §4.3); a peer that recovers MUST rise again. The selector MUST NOT keep routing to a peer that has been consistently failing/slow on stale history. |
| E · Exploration | Cold (unmeasured) peers MUST get bounded exploratory selection so they acquire outcomes, without unmeasured peers displacing proven fast peers for the bulk of a transfer. A network of all-cold peers MUST still make progress (every candidate is tried). |
| F · Determinism (testability) | Given the same config (incl. RNG seed), the same registry state, and the same ordered outcome stream, select MUST produce the same ranking. Any randomness (exploration tie-breaks) MUST be seedable so §8 tests are reproducible. |
| G · Bounded work | select and record_outcome MUST run in time bounded by the candidate/registry size (no unbounded recomputation); the models are updated incrementally per outcome. |
select returns a ranked subset — an ordered list of chosen peers with, per peer, a recommended
concurrency (its remaining headroom below its learned saturation point) so dig-download knows both
who and how many concurrent ranges each. The subset size is bounded by the request's requested
parallelism (§5.1) and by how many candidates are worth using (a low-quality tail MAY be omitted).
The ranking MUST be stable under §4.4-F.
The frozen public surface (version 1). Types are given in normative logical form; the crate's
concrete Rust signatures MUST match these shapes (names, parameters, return semantics). All identity
types (PeerId, CandidateAddr, TraversalKind, ContentId, ProviderRecord) are re-used from
the sibling crates (dig-nat, dig-dht) — the selector MUST NOT define parallel copies.
fn select(&self, req: &ContentRequest, candidates: &[Candidate]) -> Selection
-
req: ContentRequest— what is being fetched and how:ContentRequest { content: ContentId, // dig-dht ContentId: Store | Root(capsule) | Resource total_length: Option<u64>, // resource ciphertext length if known (from availability) range_count: Option<usize>, // planned number of ranges, if known parallelism: usize, // how many parallel sources the caller wants (>= 1) } -
candidates: &[Candidate]— the population to rank, typically thedig-dhtfind_providersresult mapped in. ACandidatecarries at leastpeer_idandaddresses(fromProviderRecord), and MAY carry a knownconnection_class. Candidates not yet in the registry are upserted (cold) as a side effect (§2.3), soselectMAY take&selfwith interior mutability or&mut self— the crate fixes which; the semantics are: selecting a fresh candidate registers it. -
returns
Selection— the ranked chosen subset:Selection { peers: Vec<SelectedPeer> } SelectedPeer { peer_id: PeerId, rank: u32, // 0 = best max_concurrency: u32, // recommended concurrent ranges for this peer (§4.1 headroom) exploratory: bool, // true if chosen for exploration (§4.4-E), not proven quality } -
Semantics.
selectMUST satisfy §4.4. It MUST NOT block on I/O (pure, in-memory). An empty candidate set yields an emptySelection(the node handles "no providers").selectnever mutates a peer's learned quality — onlyrecord_outcomedoes.
fn record_outcome(&self, outcome: &TransferOutcome)
-
TransferOutcome— one measured result, at either granularity:TransferOutcome { peer_id: PeerId, // which peer (transport-verified, §9.1) content: ContentId, // what was being fetched kind: OutcomeKind, // Range { .. } | Request { .. } result: OutcomeResult, // Success | Failure { reason } | Interrupted { .. } bytes: u64, // bytes actually transferred (measured) duration_ms: u64, // wall-clock the transfer took (measured) rtt_ms: Option<u64>, // time-to-first-byte / RTT if measured at: u64, // unix seconds the outcome completed } OutcomeKind = Range { index: usize, offset: u64, length: u64 } | Request { total_length: u64 } OutcomeResult = Success | Failure { reason: FailureReason } | Interrupted { bytes_before: u64 } FailureReason = Timeout | Transport | Unavailable | VerificationFailed | Cancelled | Other -
Semantics.
record_outcomefolds the measured throughput (bytes / duration_ms), latency (rtt_ms/duration_ms), and success/failure into the peer'sPeerQualityimmediately, per the estimator properties (§3.2, §3.4) and updates the peer's saturation/volatility learning (§4.1, §4.3). It MUST run in real time (called per range as ranges complete mid-transfer) so an in-flight download can be re-balanced (§5.5). Outcomes for an unknownpeer_idupsert a cold entry first (self-healing).VerificationFailedis a hard failure (§3.4).record_outcomeMUST NOT trust any self-reported throughput — it derives throughput only frombytesandduration_msit is given by the executor (§9.3).
The selector tracks in_flight per peer (§3.1) to apply saturation (§4.1). The executor MUST inform
the selector when a range is dispatched to a peer and when it settles (success/failure);
this MAY be implicit (a SelectedPeer in a Selection is counted dispatched, and the matching
record_outcome decrements) or explicit via on_dispatch(peer_id, range) / the settling
record_outcome. The crate fixes the mechanism; the invariant is that in_flight reflects reality
so §4.4-B holds.
Dispatches are TTL-reclaimed, not just decremented. A dispatch bump MAY exceed the count a
matching record_outcome releases (e.g. a peer is dispatched max_concurrency ranges' worth of
headroom in one select but only ever reports outcomes one at a time, or an outcome never arrives at
all because the peer went silent). The selector MUST NOT let such an imbalance leave in_flight
permanently stuck above 0: it stamps the wall-clock time in_flight last rose from 0 and, on each
select/rebalance, reclaims (zeros) in_flight for any entry whose dispatch has aged past a bounded
TTL without settling. This is what keeps §2.5's capacity bound genuinely enforceable rather than
gameable by an attacker who dispatches to a peer and then never reports back.
fn on_pool_event(&self, event: &PoolEvent) // dig-gossip churn (§2.3, §7.2)
fn on_connection_class(&self, peer: &PeerId, class: TraversalKind) // dig-nat class (§7.3)
fn upsert_candidate(&self, candidate: &Candidate) // manual/seed feed (§2.3)
fn remove_peer(&self, peer: &PeerId) // explicit removal (rare; churn usually drives this)
on_pool_eventconsumes the gossip churn event (PeerAdded/PeerRemoved) with the exact shape ofdig_gossip::PoolEvent(PeerAdded { peer_id, addr },PeerRemoved { peer_id, reason }, over the re-useddig_nat::PeerIdand aPoolRemovalReasonofDisconnected|Dead|Banned). The shape is byte-identical so a host maps adig_gossip::PoolEventto it 1:1. Implementation note (binding deviation, recorded per the spec-with-code rule §10): the reference crate mirrors this event type locally rather thanuse-ingdig-gossipdirectly.dig-gossippulls the entire chia-protocol/consensus/TLS stack into what is a pure decision layer, and its published git tip does not currently compile as a dependency (it lags the upstreamchia-*crate versions), which would both bloat and break this crate's build — contradicting this spec's own dependency-minimalism principle (§1, §11) and thedig-dht/dig-pexprecedent that deliberately avoiddig-gossip. The mirrored type is field-for-field identical, exactly asdig-pexmirrors the L7 address shape "rather than pulling those crates in"; whendig-gossipis published to crates.io with a compiling tip it MAY be replaced by a direct re-export with no field/variant change. The contract (the shape the host feeds in) is unchanged; only the source of the type differs.on_connection_classattaches / updates a live peer'sconnection_class(§2.1) fromdig_nat::TraversalKind.
fn rebalance(&self, req: &ContentRequest, active: &[PeerId], need: &RangePlanDelta) -> Selection
Mid-transfer, when a source drops or a range must be relocated (dig-download's relocate path, §6),
the executor calls rebalance to re-query the current (freshly-learned) models for a replacement
subset for the still-needed ranges, excluding/de-ranking the active peers already saturated and
the peer that just failed. rebalance MUST reflect every record_outcome received so far in the
transfer (it is not a cached copy of the original select). It obeys the same invariants (§4.4).
SelectorConfig {
clock: ClockSource, // injectable clock (tests drive time deterministically)
rng_seed: Option<u64>, // seed for exploration tie-breaks (§4.4-F)
registry_capacity: usize, // resource bound (§2.5), NOT a behavior knob
}
SelectorConfig MUST NOT carry scoring weights, decay constants, saturation limits, or a relayed
penalty (§1.5). Adding such a field is a conformance failure.
The selector MAY expose read-only introspection for debugging/metrics — e.g. a snapshot of a peer's
learned PeerQuality, the current registry size, learned saturation/penalty values, and the size of
any internal per-peer bookkeeping the engine keeps outside the registry (e.g. anti-starvation /
dispatch-attribution side maps). These are observability only: reading or logging them MUST NOT
change selection behavior, and they are not configuration. This satisfies the ecosystem
"agent-friendly / machine-consumable" requirement without introducing a knob.
No side map may outlive a peer's registry membership (#179 finding 2). Any per-peer bookkeeping
the engine keeps outside the registry (e.g. the last-selected epoch for anti-starvation coverage,
or dispatch-attribution context keyed by peer) MUST be pruned for a peer_id no later than the same
operation that removes it from the registry (capacity eviction or explicit removal, §2.5). Such a
side map's size MUST track the live registry population, not the cumulative count of distinct
peer_ids ever fed — otherwise the registry's own capacity bound (§2.5) protects nothing, since an
attacker feeding a continuous stream of unique cold peer_ids would still grow unbounded state
elsewhere in the selector.
The selector and dig-download form the tight select → execute → record_outcome → rebalance loop.
This section binds the selector's contract to dig-download's exact outcome, event, and resume model.
dig-download today picks a source with an internal least-loaded heuristic (pick_source). Under
this contract the selector drives that choice: dig-download obtains its source subset (and each
source's recommended concurrency) from select/rebalance instead of the built-in heuristic. The
selector is the decision authority; dig-download remains the executor. (The integration adds the
selector as the source-selection seam in dig-download; the selector crate itself defines the
contract, not the wiring.)
dig-download emits a DownloadEvent stream as the transfer runs. The host adapter MUST translate
each relevant event into a record_outcome (or in-flight update) as it happens, so the models
update mid-transfer:
dig-download DownloadEvent |
Selector call | Mapping |
|---|---|---|
RangeCompleted { range, provider, progress } |
record_outcome |
kind: Range { index: range, .. }, result: Success, peer_id = provider, bytes/duration_ms from the range's measured transfer, decrement in_flight. |
RangeFailed { range, provider, reason } |
record_outcome |
result: Failure { reason } (map reason → FailureReason; a verify/integrity failure → VerificationFailed), decrement in_flight. This is the failure the selector LEARNS from (§3.4) and re-routes around. |
ProvidersRefreshed { providers } |
(registry feed) | New providers from dig-download's relocate re-query become candidates on the next select/rebalance. |
Paused / Resumed |
(in-flight only) | On pause, in-flight ranges are no longer dispatched; the selector's learned quality is UNCHANGED (a pause is not a peer failure). |
Completed { total_length } |
record_outcome (optional) |
An aggregate Request outcome MAY be recorded for whole-request latency learning (P99, §4.4-C). |
Failed { reason } |
(no quality change beyond the per-range failures already recorded) | The request-level failure is composed of the per-range outcomes already learned. |
A range's measured bytes/duration_ms/rtt_ms are what the executor observed on the wire — the
selector MUST use those, never a peer's advertised rate (§9.3).
dig-download verifies every range against the chain-anchored merkle root before accepting it
(Peer network §9 integrity). A range that fails verification (bad chunk length, failed
inclusion proof, or a decryption-tag failure) is a RangeFailed with an integrity reason; the
adapter MUST map it to FailureReason::VerificationFailed, which the selector treats as a hard
reliability penalty (§3.4). A peer that repeatedly serves unverifiable bytes MUST be driven toward
the bottom of the ranking (potentially below cold peers) — a bad source is worse than an unknown one.
dig-download resumes per range: its DownloadState { done_ranges, chunk_lens, root, .. }
records which ranges are already verified so a resume re-fetches only the missing ones. The selector's
contract with resume:
- Learned quality survives a pause/resume and the process only if the host persists it; the
selector's in-memory quality is NOT part of
DownloadState. A resume after a restart begins with the registry as re-fed by the discovery layers; peers re-acquire quality from the resumed transfer's outcomes. (The selector MAY expose a snapshot for the host to persist, but persistence is the host's choice — the selector defines no on-disk format in version 1.) - On resume,
dig-downloadcallsselect/rebalancefor the remaining ranges only; the selector MUST rank against whatever registry/quality it currently holds. Ranges already indone_rangesare never re-selected (the executor does not ask for them). - A pause is not a failure signal (§6.2) — the selector MUST NOT penalize a peer for a user-initiated pause.
A range is identified to the selector by its index and byte extent (OutcomeKind::Range { index, offset, length }), matching dig-download's range plan (chunk-aligned, keyed by index + offset).
The selector does not need the chunk merkle layout; it needs only the identity to attribute an
outcome to a range and a peer.
All bindings are read-only inputs to the selector or choices the selector drives; the selector opens no transport and owns no discovery (§1.2).
- The node calls
dig_dht::DhtService::find_providers(&ContentId) -> Result<Vec<ProviderRecord>, _>and maps eachProviderRecord { content_key, provider_peer_id, addresses, expires_at }into aCandidateforselect. The selector MUST re-usedig_dht'sContentId,ProviderRecord,CandidateAddr, andAddressKindtypes (which themselves re-usedig_nat::PeerId) — no parallel definitions. - The selector treats a provider record as an address hint (§9.1): the peer is proven to hold the
content only when a transfer from it yields a
Successoutcome. An expired record (now >= expires_at) SHOULD be ignored as a candidate. - The selector does not query the DHT, announce, or maintain provider records. (Peer network §4c.)
- The registry is fed from the
dig-gossipconnected pool:subscribe_pool_events()→on_pool_event(§2.3),connected_pool_peers()for an initial snapshot,pool_stats()for under-connected awareness (a host MAY widen exploration when the pool is below target). The selector consumes the gossip churn event with the exact shape ofdig_gossip::PoolEvent(mirrored field-for-field locally per the §5.4 binding note; the host mapsdig_gossip::PoolEventto it 1:1). dig-pex-learned peers arrive (through the gossip address book / pool) as hints with unknown quality; the selector marks provenancePex/Gossipand treats quality as cold until measured (§3.5, §9.1) — exactly the dig-pex trust rule (a PEX entry is a candidate to dial and verify, never an authenticated fact). (Peer network §4d.)
- The selector reads each connected peer's
dig_nat::TraversalKind(Direct,Upnp,NatPmp,Pcp,HolePunch,Relayed) viaon_connection_class(fromPeerConnection::method/NatPeerConnection::method()) to seed the peer-class saturation prior (§4.1) and the relayed penalty prior (§4.2).Relayedis the only class that means the relay carries the bytes; all others are peer-to-peer data paths (Peer network §10). - All transfers the selector influences ride
dig-natmTLS mux streams (PeerSession::open_stream/open_range_stream); the selector never opens one itself.peer_id = SHA-256(TLS SPKI DER)is the identity throughout (§1.3, §9.1).
- The selector's output is ranked
PeerIds in aSelection: eachSelectedPeercarries apeer_idand a recommendedmax_concurrency. The selector does not establish connections (§1.2 boundary). - The host/executor (typically
dig-node, viadig-download's integration point) is responsible for connecting to each selected peer:- Construct a [
dig_peer::PeerTarget] from the rankedpeer_id+ the candidate's address(es). - Call [
dig_peer::DigPeer::connect] to establish mTLS overdig-natand obtain the connection. - Use the resulting [
dig_peer::Connected] streams for the data transfer (viadig-download).
- Construct a [
- Connection establishment, NAT traversal, and transport are entirely the responsibility of
dig-peeranddig-nat; the selector is a pure decision layer and has no opinion on how the connection is made. Its only contract is to rank peers and learn from measured outcomes (§5, §6).
dig-nodeconstructs the selector, wires the gossip pool subscription anddig-natconnection classes into it, callsfind_providersper content want and passes the providers toselect, and runs theselect ↔ dig-downloadloop (translatingDownloadEvents intorecord_outcomecalls, §6.2). For each peer in the returnedSelection, it usesdig-peerto establish a connection (§7.4). The selector is a dependency of the node's content-fetch path.
The learning and scoring MUST be unit-testable with synthetic topologies and throughput traces —
no real network. An implementation MUST pass a harness that feeds it a simulated candidate population
and a scripted outcome stream (via record_outcome) and asserts the §4.4 invariants:
- Convergence test (A). Feed peers with fixed distinct throughputs/reliabilities; drive repeated
select+record_outcomecycles; assert the ranking converges to prefer the fast/reliable peers and that the fastest peer's share of ranges rises while the slowest's falls. - Load-spread / anti-herd test (B). Give one peer the highest point throughput but a low saturation point; drive a high-parallelism request; assert the selector caps that peer at its learned saturation headroom and spreads the remaining ranges to others (it does NOT send all ranges to the one peer even though its point estimate is best).
- Degradation-adaptation test (D). Feed a peer high throughput, then flip its trace to low throughput / failures; assert its rank falls within a bounded number of subsequent outcomes and selection moves off it; then flip it back and assert it recovers.
- P99 test (C). Compose a mix where a high-median peer has a heavy tail; assert the selector's assignment reduces whole-request completion time (bounded by the slowest range) versus a naive "pick the best point estimate" baseline.
- Exploration test (E). Start all peers cold; assert every candidate is tried (acquires an outcome) and the selector still converges; assert unmeasured peers do not displace a proven fast peer for the bulk of a transfer.
- Determinism test (F). With a fixed
rng_seedand clock, assert identical rankings for identical registry state + outcome stream across runs. - Anti-gaming test (§9). Feed a peer that (in the synthetic model) "advertises" high capacity but
measures low; assert its rank tracks the measured (low) value, never the advertised one; assert
a peer serving
VerificationFailedranges is driven below cold peers. - Rebalance test (§5.5). Mid-stream, drop an active peer and inject a fresh candidate; assert
rebalancere-ranks using the up-to-the-moment models and picks a replacement for the still-needed ranges.
The public API surface (§5) and the invariants (§4.4) are the frozen conformance targets; a change that alters a public signature or breaks an invariant is a breaking change and updates this SPEC in the same unit of work.
Every candidate the selector receives — from dig-dht providers, dig-pex exchange, or the gossip
pool — is a hint, exactly as in the dig-pex trust model. The selector MUST NOT treat a peer as
verified, reachable, content-holding, or high-quality on the basis of any received record. The only
proof is a completed mTLS transfer with a measured, verified outcome:
- A peer's identity is authoritative only as the transport-verified
peer_id = SHA-256(TLS SPKI DER)thatdig-natconfirmed — never apeer_idasserted in a payload (§2.4). - A peer is proven to hold content only when a range from it yields a
Success(and the bytes passeddig-download's merkle verification, §6.3). - Provenance and connection class (§2.2, §2.1) seed priors only; they never substitute for a measured outcome.
A peer's quality (§3) is refined exclusively from measured TransferOutcomes produced by the
executor from real transfers. There is deliberately no input path by which a peer can raise its
own score: the selector has no "advertised capacity" field that feeds the model, and it accepts no
quality assertion from the network. This is what makes the ranking non-gameable by self-report.
If any advertised/self-reported capacity is ever visible to the host (e.g. a hypothetical hint in a
peer record), the selector MUST treat it as at most a tie-breaker among otherwise-equal cold
peers, never as an input to throughput/rtt and never as a reason to prefer a peer over one with
better measured performance. A peer that claims fast and measures slow is ranked slow. Under no
configuration does advertised capacity override measured capacity.
- A peer that serves ranges failing merkle verification (
VerificationFailed, §6.3) MUST be driven toward the bottom of the ranking (below cold/unmeasured peers) so the selector routes around a bad or hostile source — matching the L7 rule that a bad range is refetched from another holder and the source penalized (Peer network §9). - A
Bannedpeer (fromPoolEvent::PeerRemoved { reason: Banned }) MUST be ineligible for selection until re-added by the pool. - The selector cannot be poisoned by a flood of low-quality candidates: unmeasured peers get only bounded exploration (§4.4-E) and never displace proven peers; a peer that measures poorly falls in rank; the registry is bounded and evicts lowest-value entries (§2.5).
The selector introduces no network endpoint, no message wire, and no credential. It cannot be
attacked directly over the network — it only consumes structured inputs from the sibling crates and
outputs rankings. All confidentiality/integrity/authentication properties are those of dig-nat
mTLS and dig-download verification; the selector adds none and weakens none.
- The public API (§5) is version 1. Evolution is additive: new optional fields on the
logical types, new methods, new
FailureReason/OutcomeKind/Provenancevariants (consumers MUST ignore unknown variants where the wire crosses a boundary). Removing or repurposing a field or changing a method's semantics is a breaking change requiring an explicit version bump. - The selector holds no persisted on-disk format in version 1 (learned state is in memory; the host owns any persistence, §6.4), so there is no stored-format compatibility surface to preserve yet. If a persisted snapshot format is added later, it MUST be versioned and additively evolved like every DIG format.
- Every behavior or API change updates this
SPEC.mdin the same unit of work.
Exported from the crate root; identity/candidate types re-used from dig-nat / dig-dht
(#![forbid(unsafe_code)]; license and MSRV mirror the sibling crates — Apache-2.0 OR MIT,
MSRV ≥ 1.75.0):
PeerSelector—new(SelectorConfig),select(&ContentRequest, &[Candidate]) -> Selection,rebalance(&ContentRequest, &[PeerId], &RangePlanDelta) -> Selection,record_outcome(&TransferOutcome),on_pool_event(&PoolEvent),on_connection_class(&PeerId, TraversalKind),upsert_candidate(&Candidate),remove_peer(&PeerId), plus read-only observability (§5.7).- Types:
SelectorConfig(§5.6),ContentRequest/Candidate/Selection/SelectedPeer(§5.1),TransferOutcome/OutcomeKind/OutcomeResult/FailureReason(§5.2),RangePlanDelta(§5.5),PeerEntry/PeerQuality/Provenance(§2–3, read-only snapshots),PoolEvent/PoolRemovalReason(§5.4 — mirrored byte-for-byte fromdig_gossip). - Re-used (not redefined):
dig_nat::{PeerId, TraversalKind},dig_dht::{ContentId, ProviderRecord, CandidateAddr, AddressKind}. - Mirrored byte-for-byte (not re-used, per the §5.4 binding note):
PoolEvent/PoolRemovalReason— field-identical todig_gossip's, sourced locally becausedig-gossippulls the whole chia stack and its current git tip does not compile as a dependency.
Dependency posture: depend on dig-nat and dig-dht for identity/candidate types (git-pinned bare
form until they are on crates.io); mirror the dig-gossip PoolEvent shape locally rather than
depending on dig-gossip (§5.4 binding note — keeps the tree minimal and the build green); do NOT
depend on dig-download for types the loop can express structurally (avoid a dependency cycle — the
outcome/event mapping lives in the host adapter, §6.2).
The frozen, testable statements of version 1. An implementation conforms iff all hold.
| ID | Statement |
|---|---|
| SEL-01 | The public API matches §5 exactly: select, rebalance, record_outcome, the four registry-feed hooks, and the §5.1/§5.2/§5.5 type shapes — re-using the dig-nat/dig-dht identity/candidate types (not parallel copies) and consuming the gossip churn event with the exact dig_gossip::PoolEvent shape (mirrored field-for-field per the §5.4 binding note, so a host maps dig_gossip::PoolEvent to it 1:1). |
| SEL-02 | The registry key is peer_id = SHA-256(TLS SPKI DER); entries carry addresses, optional connection class, provenance, and a learned quality model; fed by gossip churn (on_pool_event) + DHT candidates passed to select; bounded with lowest-value eviction (§2). |
| SEL-03 | A peer's quality is refined ONLY from measured TransferOutcomes; throughput/RTT/reliability are recency-weighted estimators satisfying P-recency, P-monotone-convergence, P-responsive-degradation, P-volatility-tracking; observed capacity overrides advertised (§3, §9.2–9.3). |
| SEL-04 | Saturation point is learned per peer class and caps per-peer concurrency; the relayed penalty is learned from observed frequency + measured impact; decay is driven by observed volatility — none of these is a hardcoded constant or a user knob (§4.1–4.3, §1.5). |
| SEL-05 | Scoring satisfies invariants A–G (§4.4): convergence to fast/reliable peers, anti-thundering-herd load-spread, P99 orientation, degradation adaptation, bounded exploration, determinism under a fixed seed, bounded work. |
| SEL-06 | record_outcome updates the models in real time (per range, mid-transfer); rebalance re-queries the up-to-the-moment models for still-needed ranges, excluding saturated/failed active peers (§5.2–5.5). |
| SEL-07 | The dig-download loop contract holds: the selector drives source choice; RangeCompleted/RangeFailed/Completed/Failed map to record_outcome as they stream; a VerificationFailed range is a hard penalty driving the source below cold peers; a pause is not a failure; resume selects only the remaining ranges (§6). |
| SEL-08 | Integration inputs are read-only hints/choices: DHT find_providers provider records are candidates (hints); gossip PoolEvent/pool snapshot feed the registry; dig-nat TraversalKind seeds class/relay priors; the selector opens no transport and runs no discovery/DHT/transfer (§1.2, §7). |
| SEL-09 | Peers are proven only by a completed, verified mTLS transfer; identity is the transport-verified peer_id, never a payload field; there is no input path by which a peer raises its own score; a Banned peer is ineligible until re-added (§9). |
| SEL-10 | Backwards-compatible & additive: no user-facing behavior knobs; no persisted on-disk format in v1; every change updates this SPEC in the same unit of work (§1.5, §10). |
| SEL-11 | The learning + scoring are deterministic-testable over a synthetic-topology + throughput-trace harness (no real network) proving convergence, load-spread, degradation adaptation, P99 improvement, exploration, determinism, anti-gaming, and rebalance (§8). |
Cross-references: the DIG Protocol Peer network page (docs.dig.net → Protocol → Peer network)
defines the peer_id, the dual-transport tiers (§0), the connection-class ladder + relay-last
invariant (§2, §10), content discovery via the DHT (§4c), PEX (§4d), and the
byte-range multi-source download + per-range integrity + retry/penalize model (§9) this selector
optimizes. The superproject change-impact map records that a change to the shared identity /
candidate / event shapes must be mirrored across the affected modules in the same unit of work.
dig-dht—find_providers/ProviderRecord/ContentId/CandidateAddr(the candidate source, §7.1).dig-download— the executor:Downloader::download, theDownloadEventstream (RangeCompleted/RangeFailed/Completed/Failed), per-range verification, and theDownloadStatepause/resume model the feedback loop is bound to (§6).dig-gossip— the peer pool:connected_pool_peers/subscribe_pool_events(PoolEvent) /pool_stats, the topology feed (§7.2).dig-nat— the mTLS mux transport +TraversalKindconnection class the transfers ride and the selector reads (§7.3).dig-pex— the peer-exchange trust model this selector mirrors: entries are hints, proven only by a verified connection (§9.1).- L7 · DIG Node peer network — docs.dig.net → Protocol → Peer network (dual-transport, NAT ladder, DHT, PEX, multi-source download + integrity).
- RFC 2119 — requirement-level key words.