May 13, 4-5 PM (33)
May 13, 5-6 PM (30)
May 13, 6-7 PM (51)
May 13, 7-8 PM (33)
May 13, 8-9 PM (9)
May 13, 9-10 PM (24)
May 13, 10-11 PM (30)
May 13, 11-12 AM (11)
May 14, 12-1 AM (18)
May 14, 1-2 AM (3)
May 14, 2-3 AM (4)
May 14, 3-4 AM (21)
May 14, 4-5 AM (11)
May 14, 5-6 AM (18)
May 14, 6-7 AM (18)
May 14, 7-8 AM (47)
May 14, 8-9 AM (53)
May 14, 9-10 AM (35)
May 14, 10-11 AM (20)
May 14, 11-12 PM (114)
May 14, 12-1 PM (54)
May 14, 1-2 PM (151)
May 14, 2-3 PM (32)
May 14, 3-4 PM (17)
May 14, 4-5 PM (14)
May 14, 5-6 PM (38)
May 14, 6-7 PM (12)
May 14, 7-8 PM (22)
May 14, 8-9 PM (37)
May 14, 9-10 PM (35)
May 14, 10-11 PM (27)
May 14, 11-12 AM (14)
May 15, 12-1 AM (18)
May 15, 1-2 AM (15)
May 15, 2-3 AM (5)
May 15, 3-4 AM (3)
May 15, 4-5 AM (13)
May 15, 5-6 AM (14)
May 15, 6-7 AM (10)
May 15, 7-8 AM (31)
May 15, 8-9 AM (23)
May 15, 9-10 AM (52)
May 15, 10-11 AM (71)
May 15, 11-12 PM (70)
May 15, 12-1 PM (73)
May 15, 1-2 PM (73)
May 15, 2-3 PM (66)
May 15, 3-4 PM (26)
May 15, 4-5 PM (13)
May 15, 5-6 PM (30)
May 15, 6-7 PM (29)
May 15, 7-8 PM (25)
May 15, 8-9 PM (8)
May 15, 9-10 PM (34)
May 15, 10-11 PM (34)
May 15, 11-12 AM (25)
May 16, 12-1 AM (2)
May 16, 1-2 AM (2)
May 16, 2-3 AM (3)
May 16, 3-4 AM (3)
May 16, 4-5 AM (0)
May 16, 5-6 AM (6)
May 16, 6-7 AM (2)
May 16, 7-8 AM (10)
May 16, 8-9 AM (1)
May 16, 9-10 AM (2)
May 16, 10-11 AM (1)
May 16, 11-12 PM (13)
May 16, 12-1 PM (11)
May 16, 1-2 PM (8)
May 16, 2-3 PM (15)
May 16, 3-4 PM (10)
May 16, 4-5 PM (2)
May 16, 5-6 PM (2)
May 16, 6-7 PM (2)
May 16, 7-8 PM (10)
May 16, 8-9 PM (6)
May 16, 9-10 PM (9)
May 16, 10-11 PM (29)
May 16, 11-12 AM (42)
May 17, 12-1 AM (9)
May 17, 1-2 AM (1)
May 17, 2-3 AM (0)
May 17, 3-4 AM (1)
May 17, 4-5 AM (0)
May 17, 5-6 AM (3)
May 17, 6-7 AM (2)
May 17, 7-8 AM (1)
May 17, 8-9 AM (1)
May 17, 9-10 AM (1)
May 17, 10-11 AM (6)
May 17, 11-12 PM (6)
May 17, 12-1 PM (4)
May 17, 1-2 PM (5)
May 17, 2-3 PM (9)
May 17, 3-4 PM (4)
May 17, 4-5 PM (8)
May 17, 5-6 PM (14)
May 17, 6-7 PM (10)
May 17, 7-8 PM (2)
May 17, 8-9 PM (4)
May 17, 9-10 PM (2)
May 17, 10-11 PM (20)
May 17, 11-12 AM (13)
May 18, 12-1 AM (10)
May 18, 1-2 AM (4)
May 18, 2-3 AM (5)
May 18, 3-4 AM (9)
May 18, 4-5 AM (14)
May 18, 5-6 AM (2)
May 18, 6-7 AM (37)
May 18, 7-8 AM (28)
May 18, 8-9 AM (35)
May 18, 9-10 AM (41)
May 18, 10-11 AM (43)
May 18, 11-12 PM (29)
May 18, 12-1 PM (136)
May 18, 1-2 PM (34)
May 18, 2-3 PM (89)
May 18, 3-4 PM (33)
May 18, 4-5 PM (45)
May 18, 5-6 PM (21)
May 18, 6-7 PM (16)
May 18, 7-8 PM (13)
May 18, 8-9 PM (23)
May 18, 9-10 PM (4)
May 18, 10-11 PM (25)
May 18, 11-12 AM (12)
May 19, 12-1 AM (7)
May 19, 1-2 AM (2)
May 19, 2-3 AM (9)
May 19, 3-4 AM (5)
May 19, 4-5 AM (10)
May 19, 5-6 AM (3)
May 19, 6-7 AM (53)
May 19, 7-8 AM (23)
May 19, 8-9 AM (46)
May 19, 9-10 AM (66)
May 19, 10-11 AM (30)
May 19, 11-12 PM (48)
May 19, 12-1 PM (79)
May 19, 1-2 PM (70)
May 19, 2-3 PM (41)
May 19, 3-4 PM (51)
May 19, 4-5 PM (15)
May 19, 5-6 PM (20)
May 19, 6-7 PM (18)
May 19, 7-8 PM (9)
May 19, 8-9 PM (21)
May 19, 9-10 PM (10)
May 19, 10-11 PM (28)
May 19, 11-12 AM (13)
May 20, 12-1 AM (21)
May 20, 1-2 AM (9)
May 20, 2-3 AM (4)
May 20, 3-4 AM (5)
May 20, 4-5 AM (9)
May 20, 5-6 AM (37)
May 20, 6-7 AM (47)
May 20, 7-8 AM (53)
May 20, 8-9 AM (50)
May 20, 9-10 AM (16)
May 20, 10-11 AM (40)
May 20, 11-12 PM (28)
May 20, 12-1 PM (50)
May 20, 1-2 PM (91)
May 20, 2-3 PM (17)
May 20, 3-4 PM (99)
May 20, 4-5 PM (1)
4,053 commits this week May 13, 2026 - May 20, 2026
Subsume `RuleListEra` into `EraTest`:
There is no need to have a separate type class for a common concept that
we aready have for all eras that is provided by `EraTest`. This commit
also hides unnecessary for public view implementation detail:
`EraRuleProof` and `UnliftRules`.

Thanks to `EraRulesWithFailures` type family and relocation of predicate
failure CBOR roundtrip tests into `ledgerTestMain` we are guaranteed
that these tests will run for all eras. Moreover, this changes is in
line with overall tests hierarchy that we are converging onto.
net-rs: wire shared-consensus behaviour hooks into net-node + net-cluster
Static config + dynamic toggle for the per-node `Behaviour` introduced
in the shared-consensus commit.

net-node
- `NodeConfig.behaviour: Option<BehaviourSpec>` (TOML field).  Applied
  in `Consensus::new` to all three state machines (Praos, Leios,
  Mempool) before the consensus task starts.
- `DynamicConfigUpdate.behaviour: Option<BehaviourSpec>` for runtime
  swap via the existing stdin JSON channel.  `Consensus::set_behaviour`
  applies it across the live state machines.
- Per-layer `set_behaviour` setters on `PraosConsensus`,
  `LeiosConsensus`, and `Mempool` materialise the spec via
  `shared_consensus::behaviour::build`.

net-cluster
- `ClusterConfig.behaviour: Option<BehaviourSpec>` plus
  `behaviour_nodes: Vec<usize>` for per-node selection.  Empty
  `behaviour_nodes` with a non-`None` spec attaches to every node.
- `NodeTopology` carries the resolved per-node spec; `render_overlay`
  serialises it through the `toml` crate so the [behaviour] table
  stays in sync with serde's view of `BehaviourSpec`.
- Sample config `sample-cluster-equivocator.toml` runs a 10-node
  cluster with node-3 as the `RbEquivocator`.

Smoke-verified: the generated `node-3.toml` carries
`[behaviour]\nkind = "rb-equivocator"`, peer overlays carry none, and
node-3's startup log emits `installing per-node behaviour
spec=RbEquivocator`.

Tests: 119 net-node + 43 net-cluster + 316 net-core + 263
shared-consensus pass; sim-rs still builds.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
net-rs: default smoke clusters to top-stake-fraction committee
The wFA-LS default (480 PV + 120 NPV) assumes mainnet-scale pool
counts.  At the smoke cluster's 9 pool nodes only PV is allocatable
(CIP-0164 partitions PV/NPV pool sets disjointly, and every pool
wins at least one PV seat at this scale), so the achievable max is
~480 vs the threshold of 0.75 × 600 = 450 — only ~30 seats of margin
above the threshold.  Losing a single pool's PV seats drops the
cluster below quorum, masking the abstention-budget behaviour the
lazy-voter sample is meant to exercise.

Switch the shared base (`mainnet.toml`) to top-stake-fraction
selection: every pool covered by the top 99% of stake casts a
weight-1 vote.  The committee size now matches the actual voter
count (9), so the threshold (`floor(0.75 × 9) = 6`) is proportional
to who can actually vote.

Lazy-voter sweep on the 30/9-pool sample, 5 min each, identical
seed/topology (push-on-admit fanout already in tree):

  fraction=0.0  →  0 / 9 lazy →  9 honest →  92% cert
  fraction=0.1  →  1 / 9 lazy →  8 honest →  92% cert
  fraction=0.2  →  2 / 9 lazy →  7 honest →  92% cert
  fraction=0.25 →  3 / 9 lazy →  6 honest →  92% cert  (at threshold)
  fraction=0.34 →  4 / 9 lazy →  5 honest →   0% cert  (below threshold)

The 13/13 EB-generation count is deterministic across all five
runs.  The 12/13 ceiling (and the 5 residual MissingTX seen in
every run) is producer-rotation residue — same artefact across the
sweep.

Also trims the sample-cluster-lazy-voter docstring to match the
TSF math.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
shared-consensus, net-node: detect RB-header equivocation on tip announcement
Move `note_header_for_equivocation` onto the ChainSync announce path
(`PraosState::on_tip_advanced`) in addition to the existing
block-arrival path (`on_block_received` and `register_self_produced`).

Why: an adversary that advertises divergent RB headers to disjoint
peer subsets (CIP-0164 RB-header equivocation) can keep honest peers
unaware of the divergence indefinitely.  Each peer fetches one
variant, sees no `(slot, issuer)` collision, and never bothers
fetching the sibling because Praos chain selection picks the first /
lowest-hash header at that slot and treats the alternative as a
discarded fork.  Without a block fetch, `on_block_received` never
fires, so the existing tracker never trips even when the peer has
already received the divergent header.

Detection from the header alone is enough — the equivocation predicate
keys on `(slot, issuer)` against a set of distinct header hashes, all
of which are derivable from the ChainSync `MsgRollForward` payload.
`on_tip_advanced` gains a `header_issuer: &[u8]` parameter; the
net-node wrapper passes `info.issuer` from the parsed header.

Also: an `info!` line on the first detection per slot ("RB-header
equivocation detected") so operators can see the tracker fire from a
log, and a small `was_new` guard so the log fires only when the slot
transitions from non-equivocating to equivocating.

Verified end-to-end on a 10-node cluster with one node running
`RbHeaderEquivocator { ways = 2 }`: every slot the equivocator wins
its bucket-1 peer detects the divergence and inserts the slot into
`equivocating_rb_slots`.  Two test fixtures in `praos::tests` were
updated for the new signature.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
sim-rs: gate con-rs EB validation on local tx availability
Mirrors `LinearWithTxReferences`'s CIP-0164 receiver gate: an EB
can only be validated once every referenced tx body is in the
local mempool.  Two side maps thread the dependency:

- `eb_pending_txs: EBId → BTreeSet<TxId>` — per-EB still-missing set
- `missing_tx_index: TxId → BTreeSet<EBId>` — reverse index for
  the tx-admit release path

`finish_validating_eb_header` now computes `missing` against
`mempool.has_tx` (union of free pool + EB-pinned bodies) and
either schedules `EBBlockValidated` immediately or stashes the EB
pending in both maps.  `acknowledge_tx_for_pending_ebs` walks the
reverse index after every successful `admit_validated` and
releases EBs whose set reaches empty.  Chain-prune sweeps both
maps in lockstep with the existing `self.ebs` retention.

Empirical note: NA,0.350 / wfa-ls / 750n / -s 1500 produces
bit-identical results with and without the gate (88/15/33/42084
either way), because tx diffusion is fast enough in this sim that
every receiver already has the bodies in mempool before the EB
arrives.  The gate matters in regimes where Message::Tx lags
Message::EB (cluster-scale net-rs, withheld-tx attacks, or once
the upcoming sim message split surfaces a real manifest-only
intermediate state).  It's the protocol-correct behaviour either
way — without it, an EB could in principle validate and open
voting before any voter has the bodies, an outcome the
CIP-0164 MissingTX predicate is supposed to prevent at validation
time, not just at decide-vote time.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
sim-rs: plumb NPV through con-rs WfaLs adapter
Renames the two committee-size config fields to match con-rs's
internal naming and the CIP-0164 terminology:

  persistent-vote-generation-probability    → persistent-voters
  non-persistent-vote-generation-probability → non-persistent-voters

Old YAML keys keep working via `#[serde(alias = ...)]` so the 70+
existing experiment configs round-trip unchanged.  The "probability"
framing was always misleading — these have always been expected
committee sizes (defaults 400 / 100, not 0.4 / 0.1).

SimConfiguration grows `persistent_voters` / `non_persistent_voters`
alongside the existing `vote_probability` (= their sum).  Linear's
VRF lottery keeps consuming the sum unchanged.  The con-rs adapter
now wires both into `CommitteeSelection::WfaLs { persistent_voters,
non_persistent_voters }` — previously NPV was hard-zeroed because
the sim collapsed both into one number.

Empirical at NA,0.350 / wfa-ls / 750n / -s 500:

  bundles:  2093 PV-only  +  207 NPV-only  +  1593 both
  total: 3893 bundles, 11748 votes, mean 3.018/bundle
  uncertified: 6/26, endorsements: 9

NPV-only bundles previously were 0 across every sim run; the 207
new ones are voters without a PV seat who won the per-EB Bernoulli
trial against their stake share — exactly the CIP-0164 contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
shared-consensus: behaviour hook system
Replaces ad-hoc adversarial flags (sim-rs's `ib_equivocation`,
`withhold_txs`) with a pluggable `Behaviour` trait under
`shared-rs/consensus/src/behaviour/`.  Each state machine
(LeiosState, PraosState, MempoolState) owns a `Box<dyn Behaviour>`
and consults it at every effect-emitting handler via a small
take/restore helper.

Hook surface:

- Reactive `on_*` hooks return `BehaviourOutcome<E>` —
  `Continue` (honest unchanged), `Replace(Vec<E>)`
  (substitute the effect list) or `Append(Vec<E>)` (run honest
  AND add extras — the equivocation pattern).
- Decision hook `decide_vote` returns `DecisionOutcome<T>` —
  `Continue` (use honest predicate) or `Override(T)`.
- Strategy hook `rb_production_strategy` returns
  `RbProductionStrategy { Normal, Suppress, Equivocate }` for the
  wrapper to act on (RB construction stays consumer-side).

Wired entry points: `on_slot`, `on_eb_offered`, `on_eb_txs_offered`,
`on_votes_offered`, `on_eb_received`, `on_votes_received`,
`decide_vote` (Leios); `on_tip_advanced`, `on_block_received`,
`on_peer_disconnected` (Praos); `on_tx_received`, `on_tx_validated`
(Mempool).

Two concrete behaviours land alongside the trait, each in its own
file under `behaviour/behaviours/`:

- `RbEquivocator` — returns `RbProductionStrategy::Equivocate`.
- `LazyVoter` — overrides `decide_vote` to always abstain.

`BehaviourSpec` serde enum + `build()` factory in `registry.rs`
deserialises a behaviour from per-node config; `CompositeBehaviour`
composes children with first-non-`Continue` wins.  `DelayQueue<E>`
helper for slot-granular delay behaviours that hold and re-emit
effects on later slot ticks (no scheduler in shared-consensus).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
net-rs: wire EB-safety gate into apply-time
Carry CIP-0164's `certified_eb` flag through `ParsedHeaderInfo` /
`CachedBlock`, plus a new `PraosState::parent_announced_eb_for_cert`
that resolves a freshly-applied cert RB back to (parent EB slot,
parent EB hash) using only block-cache state.

`Consensus::on_validation_outcome` calls `LeiosState::on_chain_endorsement`
for the resolved EB on every successful `Applied`.  The gate fires
when a peer's cert RB lands before the EB body diffuses locally; the
next own-production attempt sees `has_endorsed_unvalidated_eb()` and
emits an empty body.  Self-produced cert RBs flow through the same
hook for symmetry (and are no-ops in practice, since cert formation
requires local validation).

Sim adapter's `parsed_header_from_rb` now populates `certified_eb`
from `rb.endorsement.is_some()`.  Its existing direct calls to
`leios.on_chain_endorsement` from `try_produce_rb` /
`finish_applying_rb` remain — same end-state, different path that
matches the sim's explicit endorsement reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
sim-rs: extract shared Linear-Leios wire types into linear_wire
The `Message`, `CpuTask`, and `TimedEvent` enums sat inside
`linear_leios.rs` but were consumed by both adapters that implement
Linear Leios in the sim — `linear_leios.rs` itself and the newer
`con_rs.rs`.  Splitting them into `sim/linear_wire.rs` makes the
ownership match reality: the wire shape is the union both adapters
exchange, neither adapter owns it.

Pure refactor: enum variants unchanged, `SimMessage`/`SimCpuTask`
impls move with their enums, all 55 sim-core tests pass.  The
adapter-specific dispatch (linear's bundle path; con-rs to follow
with per-vote variants in the next commit) stays in each adapter's
`handle_message` / `handle_cpu_task`.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
con-rs: CIP-0164 RB-header equivocation detection
CIP-0164 requires voters to abstain from voting for any EB
associated with a slot at which RB-header equivocation has been
detected:

  > "It has not detected any equivocating RB header for the same
  > slot."

  > "By waiting 3 L_hdr slots before voting begins, the protocol
  > ensures that if any equivocation occurred soon enough to
  > matter, all honest nodes will have detected it and will refuse
  > to vote for any EB from an RB slot where equivocation was
  > detected."

Previously the 3·Δhdr `EquivocationCheck` pipeline phase was just a
waiting state with no detection logic — both linear and con-rs had
the timing window but neither implemented the actual abstention
rule.  Item 15 of the diff plan was misclassified as locus-only.

This adds:

- `ParsedHeaderInfo.issuer: Vec<u8>` — producer identity (Shelley+
  issuer_vkey in net-rs; NodeId bytes in sim-rs).  Empty bytes
  disable detection for that header (preserves legacy behavior for
  callers that don't populate it yet).

- `PraosState.header_hashes_by_slot_issuer` — per (slot, issuer)
  pair, the set of distinct RB header hashes observed.  Fed from
  `on_block_received` and `register_self_produced` on every
  parsed-header arrival.

- `PraosState.equivocating_rb_slots` — slots flagged because the
  same issuer signed multiple distinct headers there.  Exposed via
  `is_equivocating_slot(slot)` and the public `equivocating_rb_slots`
  set for wrapper consumption.

- `ChainTipContext.equivocating_slots` — wrapper-fed BTreeSet
  populated on every chain-tip refresh by cloning the PraosState
  set.  Cheap clone — bounded by the pipeline-window size.

- `NoVoteReason::EquivocatingRB` in con-rs and `EquivocatingRB` in
  sim-rs's `model::NoVoteReason`.

- New voting predicate in `LeiosState::decide_vote`, run after the
  existing WrongEB / LateRBHeader checks: if `eb_slot` is in
  `chain_tip_ctx.equivocating_slots`, return Err(EquivocatingRB).

Consumer wiring:

- sim adapter: `parsed_header_from_rb` populates `issuer` from
  `h.id.producer` (NodeId → little-endian u64 bytes).
  `update_chain_tip_ctx` clones `praos.equivocating_rb_slots` into
  the refreshed ChainTipContext.

- net-rs: `parse_header` in `consensus/praos/mod.rs` populates
  `issuer` from `i.issuer_vkey.to_vec()`.  `refresh_chain_tip_ctx`
  in `consensus/mod.rs` clones the equivocation set from praos.
  Added `PraosConsensus::equivocating_rb_slots()` accessor.

Two valid RBs from different producers winning the same slot via
VRF is a legitimate Praos fork — different (slot, issuer) pairs —
not an equivocation.  Praos's existing fork-resolution handles
that case; this detection is specifically for same-producer
signing of multiple headers at one slot.

Tests:
- con-rs: 5 new equivocation-tracking tests in praos.rs
  (same-issuer flags, different-issuer doesn't, empty-issuer
  disabled, duplicate-hash no-op, self-produced equivocation),
  1 new voting test in leios.rs (`no_vote_equivocating_rb_slot`).
- Existing tests in both crates updated for the new `issuer` /
  `equivocating_slots` fields via `..Default::default()` literals.

Verification: con-rs 237 tests pass (+6), sim-core 55, net-rs
workspace 119.  Behaviorally invisible in benign sweeps — no node
equivocates in current scenarios — so the in-flight NA,0.350
sanity check (pinned to the pre-equivocation commit anyway) is
unaffected.  Material under adversarial / threat-model studies.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
net-cluster: add tx_rate override and honest Leios baseline sample
The cluster config gains an optional top-level `tx_rate` that, when
set, overrides `[transactions] tx_rate` in every node's overlay.  Lets
cluster scenarios drive EB production without authoring a dedicated
base config — e.g. abstention-pressure experiments that need the
mempool busy enough to overflow into EBs.  Plumbed through the same
`control_fields` / `read_node_config_defaults` path as
`rb_generation_probability`, so future UI work can read or change it
dynamically.

Also adds `sample-cluster-leios-baseline.toml`: 30-node
`mainnet-shaped` cluster, `rb_generation_probability = 0.05` (the
mainnet-realistic minimum at which Leios still has a chance), no
adversarial behaviour.  Useful as the apples-to-apples comparison
point for any subsequent behaviour-bearing scenarios.

Empirical note (not encoded in any test, just for context): smoking
this baseline at `tx_rate = 1.0` doesn't currently reach quorum on
its own — 100% of NoVotes are `MissingTX`, confirming the net-rs
EB-tx fetch hub-spoke ceiling is the dominant failure mode at this
tx rate.  That's an upstream net-core issue tracked separately
(`project_netrs_vs_sim_NA200`); the cluster knob just exposes it.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
con-rs: retry_vote_in_window knob covers predicate + lottery retries
Adds `VotingConfig.retry_vote_in_window` (default `true`).  The flag
gates both in-window retry paths uniformly:

- predicate failure (`WrongEB` / `LateRBHeader` / `MissingTX`): a
  later slot in the window may see chain-tip / mempool state that
  flips the predicate to success;
- lottery loss (no PV seat and no NPV win): the NPV trial re-runs
  with a fresh per-slot VRF input, so a non-seated voter has up to
  `vote_window` independent chances to win.

`LateEB` stays permanent regardless — `eb_seen_slot` is fixed.

`true` matches the CIP-0164 reading that the voting window is the
licence to vote at any in-window moment.  `false` gives one decision
per (voter, EB) and one NPV trial — useful for adversarial sims and
for like-for-like comparison against linear_leios.rs.

Surfaced as `retry-vote-in-window` in sim-rs's RawParameters.
net-rs hardcodes `true` (production stays on CIP semantics).

Empirical: at NA,0.350 / wfa-ls / 750n / -s 1500 the flag doesn't
move the aggregate (this workload's WfaLs committee is pure PV;
the NPV path is dormant).  The real divergence vs linear_leios
turned out to be that linear's WfaLs is a per-EB VRF lottery
rather than the CIP-0164 stake-weighted Hare-quota seat
allocation con-rs uses — covered separately by item 1 in the diff
plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
con-rs: preserve quorum-reached elections through pipeline expiry
`Elections::on_slot` dropped every election whose `phase_for_elapsed`
returned `None` — i.e. once `elapsed >= 3*delta_hdr + L_vote +
L_diff + dedup_window`.  Quorum-reached entries went out with the
rest, which made a parent-tied cert-selection adapter unable to
assemble a cert for any parent EB older than `dedup_window` slots
past CertEligible start — even though the cert was provably valid
from accumulated votes.

A quorum-reached election is an assemblable cert; pruning it is a
correctness loss disguised as bookkeeping.  Linear's pruning
("drop only when a strictly newer EB has been endorsed") keeps such
entries available until something supersedes them.  Match that
semantic by keeping `quorum_reached` entries past pipeline expiry;
non-quorum entries still drop on schedule.

Diagnostic confirmation: sim-rs adapter at NA,0.350 seed 1 with
parent-tied cert selection went from 14 endorsements (the buggy
state where ~27/99 attempts hit `no_quorum` with sums of 467-477
against threshold 450 because the election had been pruned) to 28
endorsements (within seed-noise of linear's 29), closing the +21%
EB inflation gap that has tracked con-rs vs linear since the W2.6
sweep.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
shared-consensus: refresh README and CLAUDE; document behaviour plugins
README adds module entries for lottery / bitmap / fetch / mempool /
production / behaviour, four flow-specific sequence diagrams
(transactions, RBs, EBs, votes), an events-and-effects graph for
MempoolState, and summary sections for the Behaviour and Fetch
subsystems.  CLAUDE picks up the new modules in the map and adds a
Behaviours section pointing contributors at the subsystem README.

src/behaviour/README.md is new: hook taxonomy (reactive / decision /
strategy / notification), outcome enums, BehaviourHandle ownership,
composition rules, registry shape (BehaviourSpec, build, swap_handle),
outbound transforms, DelayQueue, shipped behaviours, and the recipe
for adding a new one — all under the same determinism contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
sim-rs: receive-side RB propagation in con-rs adapter
Wires the `AnnounceRBHeader → RequestRBHeader → RBHeader →
AnnounceRB → RequestRB → RB` handshake on the receive side, with
the same FIFO state-machine shape as `linear_leios`:

  HeaderPending → Pending → Requested → Received

Self-produced blocks bypass the early states and land directly in
`Received` (servable on `RequestRB`).  The unified `rbs` map
replaces the prior `produced_rbs` side-table — same `BlockId`
space whether locally produced or peer-received, which makes the
header / body fetch handlers symmetric.

`CpuTask::RBHeaderValidated` and `RBBlockValidated` are now hooked
up.  On `RBBlockValidated` the adapter calls
`MempoolState::on_block_applied` for the included tx ids and prunes
the local `tx_arcs` mirror — without it, txs that have gone on-chain
would still be served on `RequestTx` and accounted in the mempool.

Slot-battle resolution and `PraosState` integration (chain
selection, fork-choice, parent tracking) are deliberately
omitted in this slice — they land alongside `PraosState` in a
follow-up.  EB body fetch and vote propagation are likewise
next-slice work.

Build green; 55 sim-core tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
con-rs: extract shared consensus primitives from net-node
Introduce a top-level `con-rs` crate as a peer of `net-rs` and `sim-rs`,
holding the protocol pieces both implementations should agree on. This
is step 1 of a planned extraction: lift the pure modules verbatim, no
API redesign yet.

Lifted from net-node/src/consensus/leios:
- pipeline.rs: PipelinePhase, EbElection, PipelineConfig (CIP-0164 phase
  math).  Drops EbElection.eb_point — only used for tracing — to avoid
  pulling minicbor into con-rs.  Aggregation logging now formats slot +
  hash-prefix directly.
- wfa.rs: persistent committee allocation, NPV eligibility signature +
  Bernoulli win count, build_committee, expected_committee_size.
- aggregation.rs: record_vote / QuorumFormed.

Lifted from net-node/src/config.rs:
- CommitteeSelection (WfaLs / EveryoneVotes / StakeCentile) and its
  serde defaults; StakeEntry.  net-node still imports these from
  `crate::config` via `pub use con_rs::*`.

con-rs is sans-IO: deps are just serde / rand / blake2b_simd / tracing.
No tokio, no net-core.  Both consumers will reference it via path; sim-rs
adoption follows in a later commit.

Net-node delta: add con-rs path dep, replace the four module declarations
with `use con_rs::*`, drop the eb_point construction site.  voting.rs is
unchanged — `super::wfa` resolves through mod.rs's re-import — and is
deliberately left in net-node for now since it sends NetworkCommand and
belongs in step 2's sans-IO redesign.

Verified: cargo build, cargo test (28 con-rs + 544 net-rs workspace),
cargo clippy --all-targets (11 warnings, all pre-existing — no regression).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
sim-rs: produce RBs (and EBs on overflow) in con-rs adapter
`try_produce_rb` drives [`BodyPath::decide`] over the local mempool
on every Praos lottery win and assembles a sim `LinearRankingBlock`:

- `BodyPath::Inline` → txs go straight into the RB body; the
  mempool's free pool has already been drained by `decide`.
- `BodyPath::Eb` → manifest is committed via
  `MempoolState::produce_eb` under a deterministic synthesized
  hash (sim doesn't model wire-byte Blake2b), and a
  `LinearEndorserBlock` is built carrying the same txs as a sim
  side-band.  The RB header announces the EB.

The pair is dispatched through `CpuTask::RBBlockGenerated`, matching
`linear_leios`'s timing model.  `finish_generating_rb` records the
RB/EB in `produced_rbs` / `produced_ebs` side-tables and emits
`AnnounceRBHeader` (+ `AnnounceEB` when applicable) to every
consumer.

Parent chain-tip tracking, endorsement assembly, and the receive-side
RB/EB handlers are still stubs — they land alongside the
`PraosState` integration in the next slice.

Build green; 55 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
con-rs: lift PeerId and peer_chain in prep for selection extraction
Two small lifts that remove con-rs's last dependencies on net-core:

- `peer::PeerId` (`pub struct PeerId(pub u64)` + Display).  Picks up
  `PartialOrd, Ord` so it can key BTreeMaps in the upcoming PraosState.
  net-core re-exports via `pub use con_rs::PeerId`.
- `peer_chain::{PeerChain, PeerChainEntry, PeerChainAnchor}` — the
  per-peer ordered fragment used by Praos chain selection.  Lifted
  verbatim with visibilities widened from pub(crate)/pub(super) to pub.
  Adds `is_empty` to satisfy clippy.

Net-node's `consensus/praos/peer_chain.rs` is deleted; imports rewrite
to `con_rs::peer_chain::*` in praos/{mod,selection}.rs.  No semantic
change; this is scaffolding for the next commit, which moves the pure
parts of `selection.rs` (walk_ancestors_hybrid, select_chain_once,
try_switch_to) into a `PraosState` struct in con-rs.

Verified: 58 con-rs tests pass (was 57 — +1 peer_chain test moved in),
528 net-rs tests pass (was 529 — same test moved out), cargo clippy
--all-targets shows 11 warnings in net-rs (baseline) and 0 in con-rs.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
net-core: drop stale PeerEvent::Connected after peer was removed
When `remove_peer` aborts a peer task whose `PeerEvent::Connected`
was already buffered in the fan-in channel, the coordinator was
processing the Connected message after the peer was gone from
`self.peers`, emitting a spurious `NetworkEvent::PeerConnected`
with an empty address (the `unwrap_or_default` fallback) ordered
right after the corresponding `PeerDisconnected`.

Found while diagnosing the topology UI's edge-flash colour
ordering: an edge would show red→green where green→red was
expected, because each peer task's death produced a leftover
Connected at the tail of the event sequence.

Fix: in `handle_peer_event`'s `Connected` arm, return early when
`self.peers.get(&peer_id)` is None.  The early return both skips
the misleading event emission and avoids the empty-address
fallback path entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
con-rs: lift mempool into a sans-IO state machine with effect/on_xx surface
Pulls net-node/src/mempool.rs's queue, capacity-bounded eviction,
per-peer advertised set, and tx-id ↔ body lookup into
con-rs/src/mempool.rs as MempoolState — a peer of PraosState and
LeiosState.  Validation crosses the boundary in the same shape as
Praos block apply and Leios EB / vote validation:

  on_tx_received(tx_id, body)    →  emit ValidateTx
                                    (wrapper validates)
  on_tx_validated(tx_id, size)   →  admit + caller pulls advertise list
  on_tx_validation_failed(.., r) →  emit TxRejected(ValidationFailed)

No MempoolValidator trait — the consistent effect/on_xx pattern is
what makes the eventual Acropolis port a wrapper swap rather than a
trait migration.  admit_validated bypasses the dance for locally-
generated txs the wrapper has already validated.

MempoolEffect::TxRejected covers all three drop reasons (QueueFull on
oldest-evict, ValidationFailed, AlreadyKnown on duplicate arrival)
so consumers get parity telemetry.  No AdvertiseTx effect: the pull-
based peek_unannounced_for_peer query suffices for both net-rs's
TxSubmission server and the eventual sim-rs announce loop.  All
internal state is BTreeMap / BTreeSet for deterministic iteration.

Net-rs side:

  - net-node/src/mempool.rs reduced from ~650 to ~330 lines.  Mempool
    is now a one-field newtype over MempoolState; public methods
    translate net_core::peer::PeerId ↔ con_rs::peer::PeerId and
    net_core::PendingTx ↔ con_rs::mempool::PendingTx at the boundary.
  - spawn_tx_generator and spawn_tx_validator stay (they're I/O-side
    actors).  Validator currently uses admit_validated to bypass the
    effect path; the dance is exercised via direct con-rs unit tests
    until a real validator service lands.
  - Algorithmic mempool tests removed (lived in net-node's old test
    block, now covered by con-rs's 23 tests on MempoolState directly).
    Wrapper-specific tests (translation, fake-tx hashing, exp_sample,
    validator integration) kept.

Verification: 178 con-rs tests (+23 new on MempoolState) and 478
net-rs tests pass; both crates clippy-clean under -D warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
sim-rs: fix vote_probability dimension in con-rs adapter
`derive_committee_selection` was multiplying `vote_probability` by
the node count, which overshot the expected total committee weight
by a factor of N: con-rs ran with persistent_voters in the
thousands and produced ~13000 total votes per EB against
linear_leios's ~1538 on the same config.

The fix is one line — drop the `* total_nodes` factor.  Both
quantities are dimensioned as "expected total committee weight per
EB":

- sim's `vrf_probabilities(p)` runs `p` VRF trials per voter at
  stake-weighted success rate, so the per-EB total across voters
  sums to `p`.
- con-rs's `WfaLs.persistent_voters` is the seat count distributed
  across pools by stake — same per-EB total.

The dimension note is captured in the helper's doc comment and the
module-level YAML knob table.

W2.5 sanity check: byte-identical event streams across two runs
(simple.yaml, 300 slots, default seed, sequential engine, 18542
events each).  W2.6 equivalence on simple.yaml/600-slot brings
con-rs total vote weight from 13000 down to 1000 (sim baseline:
1538); RB counts match exactly (31 vs 31), EB / endorsement counts
match within one (4 vs 3, 1 vs 2).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
net-rs: cull duplicate consensus tests covered by con-rs
After the con-rs extraction, 17 of the ~31 leios consensus tests in
net-node were exercising pure state-machine behaviour through a thin
async wrapper — every shape change in con-rs (e.g., adding peer_id to
LeiosBlockOffered, adding peers: Vec<PeerId> to FetchLeiosBlock)
required a parallel edit here for the same assertion.

Deleted (pure pipeline / election / quorum / EB-tx-match logic, all
covered in con-rs's own #[cfg(test)] blocks):

  eb_creates_election
  election_advances_to_voting
  election_advances_through_all_phases
  duplicate_eb_deduped
  old_election_pruned
  multiple_ebs_concurrent
  eb_arriving_late_starts_in_correct_phase
  expired_eb_not_tracked
  no_vote_during_equivocation_check
  duplicate_block_offer_dedup
  duplicate_voter_not_counted
  quorum_reached_after_enough_voters
  pruned_election_drops_eb_manifests
  match_eb_tx_response_keeps_only_manifest_hashes_in_order
  match_eb_tx_response_with_unknown_manifest_passes_through
  match_eb_tx_response_pending_bitmap_cleared_after_match
  retry_eb_tx_fetch_with_empty_bitmap_is_noop

Kept: tests that exercise wrapper translation specifically — effect →
NetworkCommand dispatch, vote-body construction, validator
submissions, EmitTelemetry → NodeEvent mapping, mempool-aware bitmap
computation, and the cross-state-machine plumbing (validated-eb →
election → vote-validated → quorum).  These have no con-rs equivalent.

net-rs goes from 512 to 495 tests; con-rs's 155 still own the deleted
ground.  cargo test green; clippy unchanged from the pre-existing
manual_is_multiple_of in leios_store.rs.

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