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 (165)
May 20, 4-5 PM (3)
4,114 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]>
con-rs: forbid cert + inline-body in BodyPath::decide
CIP-0164 specifies that an RB body contains either a Leios
certificate OR a list of transactions, not both:

  > "RB' contains either a certificate for the EB announced in RB,
  > or a list of transactions forming a valid extension of RB."
  > "When a certificate is included, no further transactions are
  > allowed in the RB for the same reason."

Rationale (per the CIP): a validator carrying a cert must build
ledger state from the endorsed EB's closure before it could
validate fresh inline txs in the same block, and the slot timing
budget can't accommodate both.  Announcing a new EB in the header
is independent of this rule and remains available alongside a cert.

Previously `BodyPath::decide` made the inline-vs-Eb choice purely
on mempool-size grounds, ignoring whether the caller was about to
attach a cert.  At low throughput (mempool routinely <= rb body
cap) the cert+small-mempool path would return Inline, producing
an RB body with both a cert and inline txs — a CIP violation.
At high throughput the bug was suppressed because the mempool
overflow threshold pushed every cert-carrying slot into the Eb
branch, but the violation was still latent.

This thread `endorsement_present: bool` through `BodyPath::decide`.
When the cert is about to attach and the mempool is below the
overflow threshold, the body returns Empty rather than Inline.
All four legal CIP combinations remain reachable:

  cert + empty body                      (small mempool + cert)
  cert + empty body + announced EB       (large mempool + cert)
  inline body                            (small mempool, no cert)
  empty body + announced EB              (large mempool, no cert)

Threaded through both consumers — sim-rs's `con_rs.rs` adapter
passes `endorsement.is_some()`, net-rs's `try_produce_block`
passes its `certified_eb` flag.  Existing tests in con-rs updated
to the new signature (10 call sites); three new tests cover the
cert-present cases.

Verification: con-rs 231 tests pass (3 new), sim-core 55 pass,
net-rs workspace 119 pass.  At NA,0.350 the bug was suppressed
by mempool size; effect on the 5-seed sweep should be invisible
or seed-noise.  At low-throughput configs the change is
observable: cert-carrying RBs with small mempools now emit empty
bodies instead of cert+inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
sim-rs: con-rs emits CIP-0164 per-vote messages, not bundles
The sim's `VoteBundle` aggregation is a pre-CIP-0164 simplification —
in the real protocol every PV / NPV vote is one BLS signature on the
wire, diffused independently, aggregated only at the certifier.  The
con-rs adapter now mirrors that shape:

  model.rs:
    Vote, VoteId<Node>, VoteKind  (no `weight` field — weight is
    re-derived at verification time from the persistent-committee
    registry / NPV-VRF check, matching CIP wire encoding)

  linear_wire.rs:
    Message::{AnnounceVote, RequestVote, Vote(Arc<Vote>)}
    CpuTask::{VoteGenerated, VoteValidated}
    Vote class costs the matching `persistent_vote_bytes` or
    `non_persistent_vote_bytes` from VotingConfig.

  con_rs.rs:
    `votes: BTreeMap<VoteId, VoteState>` (was `vote_bundles`)
    `emit_vote` produces ONE Vote per (voter, EB) honouring Part A's
    PV-xor-NPV partition.  Receivers call
    `Elections::weight_for(voter_id, tag, sig)` to re-derive weight —
    same code path net-rs uses.

  events.rs / sim-cli:
    Event::{VoteGenerated, VoteSent, VoteReceived} alongside the
    existing bundle variants.  `weight` is carried on
    `VoteGenerated` for telemetry aggregation (NOT on the wire).
    sim-cli stats aggregator handles the new events.

`linear_leios.rs` is untouched in behaviour — bundle Message /
CpuTask variants stay live for it; the per-vote variants are
`unreachable!` from its dispatch.  Conversely the bundle variants
are unreachable in con-rs.  Strict adapter ownership keeps the
union-typed `Message` enum honest.

Empirical at NA,0.350 / wfa-ls / 750n / -s 200:
  1085 PV-only signatures + 57 NPV-only signatures = 1142 votes
  0 dual emissions (Part A partition holding)
  0 bundles emitted by con-rs (`"There were 0 bundle(s) of votes"`)
  weighted vote total 2825 matches PV-multi-seat + NPV-unit-seat
  aggregation.

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]>
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]>
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]>
shared-consensus: clear 6 clippy errors in behaviour subsystem
Five derivable_impls: OutboundDecision, BehaviourSpec,
BehaviourOutcome<E>, DecisionOutcome<T>, RbProductionStrategy all had
manual Default impls picking a unit variant — replaced with
#[derive(Default)] + #[default].

One type_complexity: Behaviour::decide_vote took and returned
`Result<(bool, Option<Vec<u8>>), NoVoteReason>`, repeated four times
across the trait, CompositeBehaviour, LazyVoter, and a test stub.
Promoted to a public `VoteDecision` type alias next to NoVoteReason
in leios.rs and updated every callsite.

`cargo clippy -p shared-consensus --all-targets -- -D warnings` is
now clean; 270 unit tests pass; net-rs still builds.

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]>
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]>