Home / Input Output / ouroboros-leios
May 14, 8-9 AM (0)
May 14, 9-10 AM (0)
May 14, 10-11 AM (0)
May 14, 11-12 PM (0)
May 14, 12-1 PM (0)
May 14, 1-2 PM (0)
May 14, 2-3 PM (1)
May 14, 3-4 PM (0)
May 14, 4-5 PM (0)
May 14, 5-6 PM (0)
May 14, 6-7 PM (0)
May 14, 7-8 PM (1)
May 14, 8-9 PM (1)
May 14, 9-10 PM (1)
May 14, 10-11 PM (0)
May 14, 11-12 AM (0)
May 15, 12-1 AM (0)
May 15, 1-2 AM (0)
May 15, 2-3 AM (0)
May 15, 3-4 AM (0)
May 15, 4-5 AM (0)
May 15, 5-6 AM (0)
May 15, 6-7 AM (0)
May 15, 7-8 AM (0)
May 15, 8-9 AM (0)
May 15, 9-10 AM (0)
May 15, 10-11 AM (0)
May 15, 11-12 PM (0)
May 15, 12-1 PM (1)
May 15, 1-2 PM (0)
May 15, 2-3 PM (1)
May 15, 3-4 PM (0)
May 15, 4-5 PM (1)
May 15, 5-6 PM (0)
May 15, 6-7 PM (0)
May 15, 7-8 PM (0)
May 15, 8-9 PM (0)
May 15, 9-10 PM (0)
May 15, 10-11 PM (0)
May 15, 11-12 AM (0)
May 16, 12-1 AM (0)
May 16, 1-2 AM (0)
May 16, 2-3 AM (0)
May 16, 3-4 AM (0)
May 16, 4-5 AM (0)
May 16, 5-6 AM (0)
May 16, 6-7 AM (0)
May 16, 7-8 AM (0)
May 16, 8-9 AM (0)
May 16, 9-10 AM (0)
May 16, 10-11 AM (0)
May 16, 11-12 PM (0)
May 16, 12-1 PM (0)
May 16, 1-2 PM (0)
May 16, 2-3 PM (0)
May 16, 3-4 PM (0)
May 16, 4-5 PM (0)
May 16, 5-6 PM (0)
May 16, 6-7 PM (0)
May 16, 7-8 PM (0)
May 16, 8-9 PM (0)
May 16, 9-10 PM (0)
May 16, 10-11 PM (0)
May 16, 11-12 AM (0)
May 17, 12-1 AM (0)
May 17, 1-2 AM (0)
May 17, 2-3 AM (0)
May 17, 3-4 AM (0)
May 17, 4-5 AM (0)
May 17, 5-6 AM (0)
May 17, 6-7 AM (0)
May 17, 7-8 AM (0)
May 17, 8-9 AM (0)
May 17, 9-10 AM (0)
May 17, 10-11 AM (0)
May 17, 11-12 PM (0)
May 17, 12-1 PM (0)
May 17, 1-2 PM (0)
May 17, 2-3 PM (0)
May 17, 3-4 PM (0)
May 17, 4-5 PM (0)
May 17, 5-6 PM (0)
May 17, 6-7 PM (0)
May 17, 7-8 PM (0)
May 17, 8-9 PM (0)
May 17, 9-10 PM (0)
May 17, 10-11 PM (0)
May 17, 11-12 AM (0)
May 18, 12-1 AM (0)
May 18, 1-2 AM (0)
May 18, 2-3 AM (0)
May 18, 3-4 AM (0)
May 18, 4-5 AM (0)
May 18, 5-6 AM (0)
May 18, 6-7 AM (0)
May 18, 7-8 AM (0)
May 18, 8-9 AM (1)
May 18, 9-10 AM (0)
May 18, 10-11 AM (1)
May 18, 11-12 PM (0)
May 18, 12-1 PM (0)
May 18, 1-2 PM (0)
May 18, 2-3 PM (0)
May 18, 3-4 PM (0)
May 18, 4-5 PM (0)
May 18, 5-6 PM (0)
May 18, 6-7 PM (0)
May 18, 7-8 PM (0)
May 18, 8-9 PM (0)
May 18, 9-10 PM (0)
May 18, 10-11 PM (0)
May 18, 11-12 AM (0)
May 19, 12-1 AM (0)
May 19, 1-2 AM (0)
May 19, 2-3 AM (0)
May 19, 3-4 AM (0)
May 19, 4-5 AM (0)
May 19, 5-6 AM (0)
May 19, 6-7 AM (0)
May 19, 7-8 AM (0)
May 19, 8-9 AM (2)
May 19, 9-10 AM (0)
May 19, 10-11 AM (0)
May 19, 11-12 PM (1)
May 19, 12-1 PM (0)
May 19, 1-2 PM (0)
May 19, 2-3 PM (1)
May 19, 3-4 PM (3)
May 19, 4-5 PM (0)
May 19, 5-6 PM (3)
May 19, 6-7 PM (0)
May 19, 7-8 PM (0)
May 19, 8-9 PM (0)
May 19, 9-10 PM (0)
May 19, 10-11 PM (0)
May 19, 11-12 AM (0)
May 20, 12-1 AM (0)
May 20, 1-2 AM (0)
May 20, 2-3 AM (0)
May 20, 3-4 AM (0)
May 20, 4-5 AM (0)
May 20, 5-6 AM (0)
May 20, 6-7 AM (0)
May 20, 7-8 AM (0)
May 20, 8-9 AM (0)
May 20, 9-10 AM (3)
May 20, 10-11 AM (2)
May 20, 11-12 PM (0)
May 20, 12-1 PM (0)
May 20, 1-2 PM (4)
May 20, 2-3 PM (0)
May 20, 3-4 PM (88)
May 20, 4-5 PM (6)
May 20, 5-6 PM (0)
May 20, 6-7 PM (0)
May 20, 7-8 PM (0)
May 20, 8-9 PM (0)
May 20, 9-10 PM (1)
May 20, 10-11 PM (0)
May 20, 11-12 AM (0)
May 21, 12-1 AM (0)
May 21, 1-2 AM (0)
May 21, 2-3 AM (0)
May 21, 3-4 AM (0)
May 21, 4-5 AM (0)
May 21, 5-6 AM (0)
May 21, 6-7 AM (0)
May 21, 7-8 AM (0)
May 21, 8-9 AM (0)
123 commits this week May 14, 2026 - May 21, 2026
sim-core: expose every tcp-envelope knob as a YAML override
RawTcpEnvelope now exposes the full LinkEnvelopeCfg surface as
optional fields (mss-bytes, initial-cwnd-segments, idle-reset-
threshold-ms, rto-ms, loss-bw-depth, cold-bw-depth, cold-release-ms,
cold-release-shape, loss-release-ms, loss-release-shape). Any field
left unset falls through to the physics-derived default from
LinkEnvelopeCfg::defaults_for. Unknown fields are rejected.

Adds kebab-case serde rename on tcp_model::Curve so the YAML enum
values are spelt "step" / "linear" / "geometric" rather than the
PascalCase Rust variants.

Four new parsing tests cover empty blocks, the full override schema,
the deny-unknown-fields guard, and the layered-defaults semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
sim-core: wire tcp-model envelope into Connection (opt-in per link)
Each directed Connection optionally carries a tcp_model::LinkState plus
the (from, to) link identity and the stateless Rng oracle used to seed
per-message loss draws. Behaviour with no envelope configured is
byte-identical to today; the regression suite is unchanged.

When an envelope is attached:

- send() calls LinkState::on_send with a loss outcome drawn from the
  oracle under context ("tcp_loss", from, to, send_time, message_id).
- update_bandwidth_queues() integrates `bps * bw_mult(t)` to find the
  total bytes deliverable over [last_event, now], then fair-shares them
  among miniprotocols exactly as before. Per-message arrival timestamps
  are computed by inverting bytes_deliverable so the slow-start ramp is
  reflected in arrivals (not averaged out over the update window).
- All arrivals are clamped to LinkState::delivery_floor, modelling
  cross-protocol HoL blocking during a loss-induced RTO stall.

Topology YAML grows an optional per-link `tcp-envelope` block; for now
only `loss-prob-per-segment` is exposed, with all other params derived
from the link's latency and bandwidth via LinkEnvelopeCfg::defaults_for.

Three new connection.rs tests cover: cold-start delaying a 1 MB transfer,
loss-induced delivery floor, and envelope-disabled byte-identical
matching against the no-envelope path.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
sim-core: thread tcp-envelope through the sequential/turbo engine
sequential.rs builds Connections directly (it doesn't route through
NetworkCoordinator), so the envelope wiring added earlier was only
reaching the actor engine. Fix: construct an EnvelopeWiring at link-
build time whenever `LinkConfiguration.tcp_envelope` is `Some`, using
the same `Rng::new(config.seed)` stateless oracle already used by other
sequential-engine machinery.

Caught by a NA,0.200 / top-stake-fraction / 750n sanity run with
loss-prob-per-segment 0.01: the turbo-engine output was byte-identical
to baseline because the envelope cfg was being discarded at the
Connection::new call site.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
tcp-model: caller-supplied loss draw; add inversion helper
Drops the internal RNG: on_send now takes a pre-drawn `loss_drawn`
boolean, and LinkEnvelopeCfg gains `msg_loss_prob(bytes)` for the
caller to sample from any deterministic oracle. This keeps tcp-model
purely deterministic state with no rand dependency.

Adds `invert_bytes_deliverable(bps, target, t0, upper)` — binary
search for when a cumulative byte count crosses a threshold, used by
consumers to compute envelope-aware per-message arrival times.

Adds `has_active_envelopes()` so consumers can fast-path the unperturbed
case, and stops retaining envelopes that fire immediately expired (e.g.
under `LinkEnvelopeCfg::disabled`), guaranteeing byte-identical behaviour
when envelopes are turned off.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
sim-rs: pass header issuer to PraosState::on_tip_advanced
shared-consensus's `on_tip_advanced` grew an `&[u8]` `header_issuer`
argument in 7920317e4 (RB-header equivocation detection on tip
announce); the sim-rs adapter was still calling the old 8-arg
signature, which CI surfaced after the prc/con-rs branch was rebased
onto a main that includes the equivocation-detection commit.

Sim's notion of an issuer is the producing `NodeId`; feed it in as
its little-endian byte representation.  Honest-only sims never
produce two distinct headers at the same `(slot, issuer)` pair so
the new equivocation predicate stays dormant, but the wiring is now
ready for behaviour-driven adversarial runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
shared-consensus: fix 11 rustdoc intra-doc-link warnings
Mix of stale renames, unqualified type references inside nested
modules, and one private-item link.  cargo doc -p shared-consensus
--no-deps is now warning-free.

- praos.rs: parent_announced_eb → parent_announced_eb_for_cert
  (renamed at some point); qualify equivocating_rb_slots and
  note_header_first_seen with PraosState::; drop the link to the
  private LeiosState::decide_vote and point at the public
  ChainTipContext::equivocating_slots that actually carries the data.
- leios.rs: [`EmitVote`] → [`LeiosEffect::EmitVote`].
- mempool.rs: [`get_body_by_id`] → [`MempoolState::get_body_by_id`].
- chain_tree.rs: [`select_chain_once`](super::consensus::praos)
  was wrong on both ends — fix to the real path
  crate::praos::PraosState::select_chain_once.
- behaviour/mod.rs: inside `mod behaviours`, [`Behaviour`] resolves to
  nothing — use [`super::Behaviour`]; same for the [`decide_body_path`]
  cross-method link (use [`Self::decide_body_path`]); drop the
  redundant explicit link target on [`registry::build`].
- behaviour/behaviours/lazy_voter.rs: [`LeiosEffect::NoVote`] →
  [`crate::leios::LeiosEffect::NoVote`].

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
shared-consensus, sim-rs: Declined NoVote reason for policy abstention
`LazyVoter` previously defaulted to reporting `WrongEB`, which made
its abstentions indistinguishable from real chain-tip-race predicate
failures in the JSONL telemetry — on the honest 30-node + 2-lazy
sample-cluster-lazy-voter sweep, 96% of NoVote events were tagged
WrongEB and only the LazyVoter share was actually policy-driven.
Without a way to tell them apart, the sweep can't validate whether
WrongEB is real chain-tip dynamics or just the configured lazy
voters declining.

Add a dedicated `NoVoteReason::Declined` variant to both
shared-consensus's enum and sim-rs's `model::NoVoteReason`, plumbed
through the sim adapter mapping at
`sim-rs/sim-core/src/sim/shared_consensus.rs`.  `LazyVoter::default`
and `default_lazy_reason` for the `BehaviourSpec::LazyVoter` YAML
node both flip from `WrongEB` to `Declined`; explicit `reason: X`
overrides on either side still work if a sweep wants to mimic an
undetectable lazy voter by picking one of the honest predicate
failures.

Verified on sample-cluster-lazy-voter.toml (30 nodes mainnet-shaped,
2 LazyVoter pools holding ~22% of voting stake, 5-min run):

  Before: NoVote = MissingTX 5 + WrongEB 130
  After:  NoVote = MissingTX 5 + Declined 130 + WrongEB 0

Confirms the dominant signal is the policy abstention, not a
chain-tip race — and that real WrongEB at this throughput is zero,
i.e. tx and EB diffusion are timely.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
net-rs, shared-consensus: wire RbProductionStrategy into block production
Adds the wrapper-side hook the behaviour trait was missing.  After
`BlockProducer::try_produce_block` returns a winning RB, the main loop
asks the behaviour what to do via the new `Consensus::rb_production_strategy`:

- `Normal` — register and inject as today (honest path).
- `Suppress` — log "suppressing produced RB" and drop the block.
  Lottery win is wasted; no telemetry, no chain insert, no broadcast.
- `Equivocate` — register + inject the primary, then call
  `BlockProducer::produce_equivocation_extra` to build a duplicate RB
  sharing the primary's `(slot, issuer)` but carrying an empty body.
  Different body_hash → different header_hash, so CIP-0164
  `note_header_for_equivocation` flags the slot when the duplicate
  reaches a peer.

shared-consensus
- `LeiosState::ask_rb_production_strategy(&PraosState, slot)` runs the
  standard take/restore around the strategy hook.  Lives on Leios
  because the behaviour itself does (chosen during scaffolding for the
  broadest hook surface); both states are reachable from one method.

net-node
- `BlockProducer` now caches the issuer_vkey for the current slot
  rather than regenerating per `make_fake_block` call.  Honest paths
  refresh at the top of `try_produce_block`; equivocation extras
  reuse the cached value.  This is also a pre-existing-bug fix:
  honest blocks now have a stable per-call issuer that
  `note_header_for_equivocation` can correlate against.
- `BlockProducer::produce_equivocation_extra(&primary, prev_hash, …)`
  builds the duplicate.
- `LeiosConsensus::state_mut` + `PraosConsensus::state` expose the
  inner states so the wrapper can run the take/restore hook.

Smoke (10-node cluster, node-3 = RbEquivocator, rb_generation_probability=1.0):
node-3 logs `emitting equivocation extra RB primary=… extra=…` on
every slot it wins.  The producer's own `equivocating_rb_slots`
records the slot via the existing `note_header_for_equivocation` path.

Known limitation (documented in the plan file): peer-side detection
doesn't fire yet because the cluster's chain-advertise path
publishes a single tip at a time.  Closing that gap is a wire-layer
change in `net_core::multi_peer::coordinator` and is queued as a
follow-up.

Tests: 263 shared-consensus + 119 net-node + 43 net-cluster + 316
net-core all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
con-rs: own bitmap helpers and missing-eb-tx logic
Moves the sparse BTreeMap<u16, u64> bitmap helpers
(from_indices / select_all / contains / iter_indices) into
con_rs::bitmap.  The encoded form already crosses the con-rs trait
boundary (EbTxsFetchPolicy::pick, on_eb_txs_offered), so its
encoders belong on the same side.  net-core's
leios_fetch::bitmap stays as a thin re-export to preserve callers.

Adds LeiosState::missing_eb_tx_bitmap(eb_hash, &MempoolState) →
the wire-encoded sparse bitmap of manifest indices whose bodies
aren't yet locally available.  Empty bitmap when the manifest is
unknown or every referenced tx is held — both deliberately
suppress further fetches (a "select all" reply against a peer
that doesn't have the bodies either triggers the retry storm we
hit on the broadcast-n=3 experiment).

Net-rs's bitmap_for_missing_txs now delegates to con-rs.
LeiosConsensus is the only consumer with both the LeiosState and
mempool handles, so the wrapper exposes a borrow via
Mempool::as_inner.  Dead Mempool::current_tx_ids method removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
con-rs: NoFetch policy + per-class fetch-policy YAML knob
Adds a NoFetch policy implementing BlockFetchPolicy, EbFetchPolicy,
EbTxsFetchPolicy, and VoteFetchPolicy.  Every pick returns empty so
the matching offer never fans out into a fetch effect.

Surfaces it as a config in both consumers:

  net-rs:  FetchPolicyKind::NoFetch added alongside LowestRtt /
           Broadcast, routed through all four into_*_policy helpers.

  sim-rs:  new top-level `fetch-policy` YAML stanza mirroring net-rs's
           FetchPolicyConfig shape (block / eb / eb-txs / votes), each
           class independently set to lowest-rtt | broadcast | no-fetch.
           Default `lowest-rtt` everywhere preserves LeiosState::new
           behaviour.

Wired into the sim adapter via set_eb_policy / set_eb_txs_policy /
set_vote_policy on LeiosState and set_fetch_policy on PraosState.

Empirical note: at NA,0.350 / wfa-ls / 750n / -s 1500, switching the
eb-txs class to NoFetch produces results bit-identical to LowestRtt.
The sim adapter sends EBs via `Message::EB(eb)` with the full
LinearEndorserBlock.txs Arcs in-band; the receiver never enters a
manifest-without-bodies state, so on_eb_txs_offered is never called.
Modelling separate EB-tx fetch in the simulator needs an adapter
change to split EB transmission into manifest + body fetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
shared-rs, sim-rs: fix four pre-existing clippy lints
- shared-consensus/praos.rs: drop unused `let now` in equivocation test
- sim-core/clock/coordinator.rs: `while let Poll::Pending = poll!(...)`
  -> `while poll!(...).is_pending()`
- sim-core/sim/shared_consensus.rs: drop redundant `as u64` cast on
  `rb_win_threshold` (already returns u64)
- sim-core/sim/shared_consensus.rs: collapse `contains_key + insert`
  into `entry(id).or_insert(...)` in `receive_announce_eb`

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
sim-rs: parent-tied cert selection in con-rs adapter
CIP-0164 reads a Praos+Leios cert as endorsing the EB announced by
*the* preceding block ("a preceding block" in the spec language,
but in practice the immediate parent in the chain).  The con-rs
adapter's previous selection — "iterate all cert-eligible EBs in
`votes_by_eb`, pick the first whose hash has quorum" — could pick
any cert-eligible EB at the certified slot, including ones the
chain has long since moved past.

Switch to the linear-equivalent selection: look up the parent's
`eb_announcement` from the parent RB header, check that the
parent's EB has cleared `3·Δhdr + L_vote + L_diff` slots and has
quorum, and build the cert.  Mirrors `linear_leios.rs`'s
`ebs_by_rb.get(&parent_rb_id)` lookup.

This is the load-bearing change that closes the +21% EB inflation
gap at NA,0.350 that has tracked con-rs vs linear since the W2.6
sweep.  With this + the `preserve quorum-reached elections` fix
(47680d34c) + the placeholder-elections fix (1c414cdfe), seed 1 at
NA,0.350 lands at 74 EBs / 28 endorsed vs linear's 71 / 29 —
within seed-noise.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
sim-rs: con-rs adapter — fan out AnnounceRB after body validation
The adapter only sent AnnounceRBHeader (in `finish_validating_rb_header`)
and relied on the header response's `has_body` flag to propagate body
fetches.  When a relay node first announced a header to its consumers,
its own state was Pending / Requested (body still in flight), so the
consumers saw `has_body=false` and didn't request the body.  When the
relay later transitioned to Received, it never told the consumers, so
bodies only propagated one hop — out to the producer's direct
consumers — and stalled there.

Mirror linear_leios's `publish_rb` fan-out: send AnnounceRB to every
consumer when `finish_validating_rb` completes.  Consumers in Pending
state pick it up via `receive_announce_rb` and request the body.

NA,0.200 / -s 1500 / seed 0 now produces 60 EBs / 10 uncertified / 26
endorsements / 28 755 votes — matching linear's 61 / 10 / 26 / 29 569.
Peak RSS stays ~5 GB (was ~38 GB), since the mempool drain on cert now
fires at every node instead of just direct consumers of the producer.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
net-cluster: replace behaviour selection fields with BehaviourSelection enum
The previous shape spread three loosely-coupled fields across the
cluster config — `behaviour_nodes`, `behaviour_fraction`,
`behaviour_source` — with semantics scattered between them and a
magic-string `source` that the type system could not enforce.  Replace
with a single tagged enum, `BehaviourSelection`, holding five variants:

- `All` — every node.
- `Nodes { indices }` — verbatim list.
- `StakeOrdered { count }` — top N stake-bearing nodes by stake.
- `StakeRandom { count }` — deterministic-random N stake-bearing nodes,
  seeded from the cluster seed.
- `StakeFraction { fraction }` — smallest prefix of stake-bearing
  nodes (stake-desc) whose cumulative stake covers `fraction` of the
  total.  Same shape as CIP-0164 top-stake committee selection and
  the right knob for abstention-pressure experiments — `0.2` makes
  20% of the *voting weight* run the behaviour regardless of how
  many nodes that turns out to be.

TOML:

```toml
[behaviour_selection]
kind = "stake-fraction"
fraction = 0.2
```

All variants are deterministic for a given `seed` so re-runs land on
the same nodes.  Stake-aware variants filter to `stake > 0` so relays
under `mainnet-shaped` are never picked.  Resolver lives next to the
stake-distribution code in `topology.rs` with unit coverage for each
variant.

Also adds `sample-cluster-lazy-voter.toml`: 30 nodes, mainnet-shaped
distribution, `tx_rate = 1.0`, `LazyVoter` behaviour installed on the
top 20% of stake via `stake-fraction`.  Pairs with the
`sample-cluster-leios-baseline.toml` baseline for delta studies (see
the caveat in that commit's message about the underlying MissingTX
ceiling — the threshold experiment is currently dominated by net-rs's
EB-tx fetch starvation, not the lazy stake).

`sample-cluster-equivocator.toml` migrates from `behaviour_nodes = [3]`
to the new `[behaviour_selection] kind = "nodes" indices = [3]` form.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
con-rs: enforce CIP-0164 PV/NPV pool partition
CIP-0164 partitions pools by stake-ordering into persistent (indices
[1, n_1]) and non-persistent candidates ([i*, |P|]) — disjoint by
construction.  A pool emits exactly one vote per election: PV xor
NPV, never both.  Spec text in post-cip/weighted-fait-accompli.md
(§ Persistent seats / § Non-persistent candidates).

The current implementation violated the partition on both sides:

- `decide_vote` (leios.rs) ran the NPV trial unconditionally; a
  persistent-seated voter could emit both a PV and an NPV signature
  for the same EB.
- `weight_for` (elections.rs) accepted both and credited them
  independently in `voter_weights`, since `record_vote` dedups by
  `(voter_id, tag)` — so dual-emitters were double-counted toward
  quorum.

Now:

- `decide_vote` returns NPV signature only when `persistent_seats == 0`.
- `weight_for(tag=1, ..)` returns 0 if the voter is in the
  persistent committee.  Defence-in-depth: a malformed dual-vote
  arriving over the wire is silently dropped instead of inflating
  weight.

Empirical at NA,0.350 / wfa-ls / 750n / -s 500 (con-rs):

  bundles: 2093 PV-only  +  207 NPV-only  +  1593 dual  (pre)
        =  3686 PV-only  +  207 NPV-only  +    0 dual  (post)
  total weight: 11748 → 9648 (−18%, the prior over-count was the
                              dual NPV weight from PV-seated voters)
  uncertified: 6/26 → 9/26 (cert rate degrades because the previous
                            quorum-passing weights were inflated by
                            the spec violation; the new number is
                            the honest baseline)

The mean per-EB weight drops 452 → 371, crossing below the configured
`vote-threshold: 450` on three more EBs.  Recovering cert rate from
this regime is a committee-size / threshold tuning question, not a
correctness fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
sim-rs: con-rs adapter — drain endorsed-EB txs from mempool
Mirror linear_leios's two-path drain so the local mempool doesn't
keep re-including txs that are already on chain via an endorsement:

- Production path (`try_produce_rb`): build the endorsement first,
  then drain its EB's txs from `mempool.txs` and the adapter's
  tx-tracking maps before `BodyPath::decide` walks the mempool to
  shape the new EB body.
- Validation path (`finish_validating_rb`): on receipt of an RB that
  carries an endorsement, drain the endorsed EB's txs locally if its
  body has already validated; otherwise stash the EB id in
  `incomplete_onchain_ebs` and drain on the next
  `finish_validating_eb`.

At NA,0.200 / -s 1500 / seed 0 this shrinks producer EB tx counts
mid-run (slot 175 13312 → 2399 txs, slot 185 16648 → 3070) so the
network isn't propagating ever-growing EB bodies.  Voting cert rate
barely moves on its own — the remaining gap (87 EBs / 72 uncertified
vs linear's 61 / 10) needs separate work on RB-body propagation and
the WrongEB predicate input.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
con-rs: cap EB body at eb_body_max_bytes on overflow
`BodyPath::decide` now takes an `eb_body_max_bytes` cap.  When the
mempool overflows the RB body cap, the EB manifest is truncated at
this byte limit (FIFO-ordered, at-least-one guarantee preserved) and
the remainder stays in the mempool for the next RB.

`MempoolState::produce_eb` takes a matching `count` so the drain
commits exactly the manifest's prefix instead of every free tx.

Sim-rs's adapter passes `eb_referenced_txs_max_size_bytes`; net-rs
gains an `eb_body_max_bytes` config field (default 16 MB).

At NA,0.350 / wfa-ls / 750n / -s 1500: lifts con-rs cert rate from
11% to 83%, beats `linear-with-tx-references` (53%) on the same
config.  Pre-cap EBs grew monotonically with mempool ingest because
overflow spilled the whole pool into one EB; capped EBs now sit at
~5k-10k txs in the regime where the cap binds.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
net-rs: clear pre-existing clippy lints
Four lints were firing under cargo clippy --all-targets -- -D warnings:

- net-core/src/store/leios_store.rs: manual_is_multiple_of
  → version.is_multiple_of(stats_log_interval)

- net-node/src/consensus/leios/mod.rs: cloned_ref_to_slice_refs
  → std::slice::from_ref(&body1)

- net-node/src/consensus/leios/mod.rs: dead_code on
  election_phase / election_count test helpers, orphaned by the
  duplicate-test cull in 4d6588ee7

- net-node/src/mempool.rs: dead_code on Mempool::peek_up_to
  (only ever called from its own self-tests) and the unused
  make_tx test helper

The first two are mechanical clippy fixes; the latter two are
genuine dead code, deleted along with their self-tests.  Whole-
workspace cargo clippy --all-targets -- -D warnings is now clean,
so future regressions surface immediately instead of getting
shrugged off as "pre-existing."

493 net-rs tests pass (down 2 from removed peek_up_to self-tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
con-rs: emit TxRejected{EbClosurePruned} when EB closures age out
Sim-rs distinguishes tx losses by reason — `EBExpired` covers txs
that were pinned under an EB whose manifest aged past the retention
window.  Con-rs's mempool already pruned these silently; surface them
as a `MempoolEffect::TxRejected` so adapters can record the loss.

- New `TxRejectReason::EbClosurePruned` variant
- `prune_eb_slot_window` returns `Vec<MempoolEffect>`, one per body
  released from `eb_pinned` because no surviving manifest referenced it
- `produce_eb` returns `(manifest, effects)`; `record_eb_manifest`
  returns `effects`; both propagate evictions up through `bump_eb_slot`
- `MempoolEffect` now derives `PartialEq, Eq` for test assertions
- Net-rs's `Mempool::produce_eb` becomes `-> ()` (the manifest is
  already known from `BodyPath::Eb`); evictions dropped on the floor
  for now (no net-rs telemetry plumbing yet)

Tests: con-rs 206 → 207 (+1 for produce_eb evict path; existing
slot_retention test extended to assert the effect emission).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
con-rs: pluggable multi-peer fetch policies + candidate tracker
Replaces net-core's LeiosTracker (peer-selection algorithm + offer
maps + dedup, ~670 lines) and the inline fragment-scan-then-RTT-min
routing in coordinator.rs with four independently swappable policy
traits hosted in con-rs/src/fetch.rs:

  BlockFetchPolicy   — Praos block-range fetch
  EbFetchPolicy      — Leios EB body fetch
  EbTxsFetchPolicy   — Leios EB tx-list fetch (with bitmap)
  VoteFetchPolicy    — Leios vote-batch grouping across peers

Two stock implementations cover the trivial wiring case by
implementing all four traits: LowestRttFirst (single peer, current
behaviour) and BroadcastN (fan-out by RTT, with N=1 / N=∞ degenerating
to RequestFromFirst / RequestFromAll respectively).  Sims and
production wrappers can swap each class independently as research
evolves.

CandidateTracker holds the offer maps + pending-fetch dedup +
EB-txs-attempts retry-skip that LeiosTracker used to manage.  Lives on
LeiosState as a field; net-core's coordinator no longer dedups offers
or picks fetch peers — every offer flows through to consensus with
peer_id attached, and consensus emits fetch effects with concrete
peer lists already chosen.

PraosState similarly holds a BlockFetchPolicy + PeerRtt; its
issue_fetch_internal walks peer_chains for candidates (the
fragment-scan equivalent) before delegating to the policy.  The
peer_id hint that triggered selection still gets included in the
candidate pool as a fallback.

Effect / event / command schema changes:

  PraosEffect::FetchBlockRange { peer_id: Option<PeerId> }
                              → { peers: Vec<PeerId> }
  LeiosEffect::FetchLeiosBlock     { ..peers: Vec<PeerId> }
              FetchLeiosBlockTxs   { ..peers: Vec<PeerId> }
              FetchLeiosVotes      { per_peer: BTreeMap<PeerId, ..> }
  NetworkEvent::LeiosBlockOffered    + peer_id: PeerId
              LeiosBlockTxsOffered  + peer_id: PeerId
              LeiosVotesOffered     + peer_id: PeerId
  NetworkCommand::FetchLeiosBlock     + peer_id: PeerId (mandatory)
                FetchLeiosBlockTxs   + peer_id: PeerId
                FetchLeiosVotes      + peer_id: PeerId

Net-rs dispatchers loop over the peers vec / per_peer map, emitting
one NetworkCommand per peer (empty list ⇒ silent drop).  Coordinator
is now a thin pass-through: dispatches to the named peer without
running its own selection.

Verification: 155 con-rs tests (13 new fetch.rs tests) + 512 net-rs
tests pass.  cargo clippy --all-targets -- -D warnings clean in
con-rs.  net-rs's lone clippy hit is a pre-existing
manual_is_multiple_of in leios_store.rs unrelated to this work.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
con-rs: clear EB-tx pending guard on response, respect it on retry
Two-line bookkeeping bug in LeiosState's CandidateTracker integration
caused a runaway retry loop on partial EB-tx fetch responses:

1. on_eb_txs_received cleared the per-slot in_flight gate but never
   called candidates.finish_eb_txs_fetch — the per-Point pending
   guard stayed permanently set after the first fetch.

2. retry_eb_tx_fetch called candidates.start_eb_txs_fetch but
   ignored the returned bool, firing a retry whether the guard was
   already set or not.

Combined with main.rs's ordering — match_eb_tx_response runs BEFORE
handle_event in the event loop — every partial response from a
single peer triggered another fetch to a different peer, and so on
through the candidate pool, with each retry sitting in the
per-protocol mpsc channel until the consumer caught up.  In a
25-node cluster smoke this manifested as ~360 IngressOverflow
events per node on protocol 19 (LeiosFetch) over 60 seconds,
tearing down peer mux connections and starving the rest of the
Leios pipeline.

Fixes:

- match_eb_tx_response now calls candidates.finish_eb_txs_fetch.
  By definition the in-flight fetch is done the moment its response
  is matched; clearing here covers both the wrapper-direct flow
  (test fixtures call match_eb_tx_response without going through
  on_eb_txs_received) and the production main.rs flow (where
  match_eb_tx_response runs before handle_event).
- on_eb_txs_received also calls candidates.finish_eb_txs_fetch as
  a defence-in-depth idempotent clear — covers wrappers that drive
  the receive path without separately calling match_eb_tx_response.
- retry_eb_tx_fetch respects start_eb_txs_fetch's return value,
  skipping the fetch when a previous one is still in flight.  The
  next response will trigger the next retry attempt naturally.

Smoke verification in the same 25-node cluster: IngressOverflow
events on protocol 19 drop from 360 to 9 in node-0, partial-retry
warnings from hundreds-per-EB to ~3.

182 con-rs + 478 net-rs tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
sim-rs: drive Leios slot tick + RB lottery in con-rs adapter
`handle_new_slot` now advances the local Leios state via
`LeiosState::on_slot` and runs the Praos RB lottery using
`con_rs::lottery::rb_win_threshold` against sim's existing
`Rng::draw_range` VRF — the formula is shared with net-rs but each
consumer keeps its own draw form, per the W2.0 design note.

A new `apply_leios_effects` helper drains the effect vec.  Variants
without a target yet (fetch / vote-emit / validate / record-manifest)
fall through to no-ops; the next slices wire them when the RB / EB /
vote message families come up.  `NoVote` and the QuorumReached /
ElectionExpired telemetry events are matched explicitly so we don't
silently drop them later.

On a lottery win the adapter records the win via
`track_praos_block_lottery_won` and stops — building the RB body,
scheduling `RBBlockGenerated`, and announcing the header lands in
the production slice once `BodyPath::decide` and the RB / Praos
message handlers are in place.

`tx_known` passed to `LeiosState::on_slot` is `|_| true` until the
EB-manifest path lands; without EBs in flight the predicate never
fires.

Build green; 55 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
sim-rs: W2.3 — YAML → con-rs config translation
Three new helpers replace the previous placeholder values in
`ConRs::new`:

- `derive_pipeline` builds `PipelineConfig` from
  `leios-header-diffusion-time-ms`, `linear-vote-stage-length-slots`,
  `linear-diffuse-stage-length-slots`, and the residual portion of
  `linear-tx-max-age-slots` as the dedup window.
- `derive_committee_selection` maps sim's three committee algorithms
  to con-rs's `CommitteeSelection` variants.  For `WfaLs` it collapses
  sim's single combined `vote_probability` into `persistent_voters`
  (entire expected committee × node count) with `non_persistent_voters`
  forced to 0, because `SimConfiguration` doesn't preserve the per-class
  breakdown.
- `derive_quorum_fraction` turns sim's absolute `vote-threshold` count
  into the fractional form con-rs expects (`vote_threshold / expected
  committee size`), with the CIP-0164 default 0.75 as fallback.

Vote-bundle byte budgets now come from `Sizes::vote_bundle(1)` on both
the PV and NPV legs — sim collapses them into a single curve, so we
pass the same value to both fields.

The module docstring gains an at-a-glance table listing every YAML
knob the adapter reads and which con-rs destination it lands in, plus
a sibling table of the values that are still hardcoded and why
(`WfaLs.non_persistent_voters`, `StakeCentile.top_centile_of_stake`,
PraosState `k`, fetch policies).

Build green; 55 sim-core tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>