test(rng): tier-2 (participant_aligned) testnet scenario at n=6

A 6-node smoke (the smallest NON-degenerate tier-2 size: tier2 floor 4, quorum 5) driving the 4/6 band. n=5 has no band (tier2 == quorum), which is why the existing degradation smoke only ever sees tier 3 / fallback.

Tier 2 is below the 80% validation quorum, so the 4/6 cohort's ledgers are provisional: the scenario confirms tier-2 injection from the cohort's logs during the window, then verifies the on-ledger EntropyTier=2 count=4 POST-RECOVERY once those ledgers become canonical and validate (the mechanism the degradation smoke also relies on). Adds the assert_participant_aligned helper and the node_count:6 suite entry.

Verified on a live testnet: PASS — 12 tier-2 injections in the 4/6 window, seq 7 & 8 validated as participant_aligned (count 4).
This commit is contained in:
Nicholas Dudfield
2026-06-16 10:28:57 +07:00
parent c3cc3513c9
commit c322b59961
3 changed files with 150 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
""":descr: 5/6 validator_quorum, 4/6 participant_aligned (tier 2), recovery
Requires node_count: 6 (see suite.yml) — the smallest NON-degenerate Tier 2
size. At n=6: tier2 floor = 4, validator quorum = 5, validation quorum = 5. So
6/6, 5/6 present -> validator_quorum (EntropyTier=3)
4/6 present -> participant_aligned (EntropyTier=2, count 4) <-- the band
3/6 present -> consensus_fallback (EntropyTier=1)
n=5 has NO tier-2 band (tier2 == quorum == 4), which is why the existing
degradation smoke at 5 nodes only ever sees tier 3 / fallback.
KEY: the 4/6 window is BELOW the 80% validation quorum (5). The 4 survivors
keep building ledgers carrying tier-2 entropy, but those ledgers do NOT validate
until the network recovers — exactly the transition window Tier 2 serves. So we
confirm tier-2 injection from the cohort's LOGS during the window, then verify
the on-ledger EntropyTier=2 POST-RECOVERY, once the provisional ledgers become
canonical and validate (same mechanism the degradation smoke relies on).
"""
from __future__ import annotations
from helpers import require_entropy, get_entropy_tx, assert_participant_aligned
async def scenario(ctx, log):
await require_entropy(ctx, log)
# Baseline: healthy 6/6 produces validator_quorum entropy.
await ctx.wait_for_ledgers(1, node_id=0, timeout=30)
# --- 5/6: still validator_quorum (5 present >= quorum 5; validates) ---
ctx.stop_node(5)
await ctx.wait_for_nodes_down(nodes=[5], timeout=30)
await ctx.wait_for_ledgers(1, node_id=0, timeout=30)
val_5of6 = ctx.validated_ledger_index(0)
ce5, _ = get_entropy_tx(ctx, val_5of6)
if ce5.get("EntropyTier") != 3:
raise AssertionError(
f"5/6 ledger {val_5of6}: expected validator_quorum (tier 3), got "
f"tier {ce5.get('EntropyTier')} (EntropyCount={ce5.get('EntropyCount')})"
)
log(f"5/6: validator_quorum at validated seq {val_5of6}")
# --- 4/6: participant_aligned (Tier 2) degraded window ---
# Validation stalls here (4 < 5); the 4 survivors build PROVISIONAL tier-2
# ledgers. Confirm injection from the cohort's logs now.
ctx.stop_node(4)
await ctx.wait_for_nodes_down(nodes=[4], timeout=30)
# ~12s ≈ 4 rounds at 3s cadence — enough for the transition blip to settle
# and several steady 4/6 (tier-2) rounds to close.
op = await ctx.sleep(12, name="tier2_window")
selected_t2 = ctx.search_logs(
r"RNG: entropy selected seq=\d+ tier=2 count=4",
within=op.window,
nodes=[0, 1, 2, 3],
)
log(f"4/6: 'entropy selected tier=2 count=4' logs: {selected_t2.count}")
if selected_t2.count == 0:
raise AssertionError(
"4/6 window injected no participant_aligned (tier 2) entropy: no "
"'RNG: entropy selected ... tier=2 count=4' on the surviving cohort"
)
# 4/6 sits AT the entropy-gate threshold, so the round must NOT take the
# impossible/fallback path the sub-floor degradation smoke relies on — this
# is what distinguishes the tier-2 band from the tier-1 fallback regime.
ctx.assert_not_log(
r"reason=impossible-entropy-gate", within=op.window, nodes=[0, 1, 2, 3]
)
# --- Recovery: restart -> the provisional tier-2 ledgers become canonical
# and validate, advancing the validated tip past them. ---
ctx.start_node(4)
ctx.start_node(5)
await ctx.wait_for_ledgers(1, node_id=0, timeout=120)
val_recovered = ctx.validated_ledger_index(0)
if not val_recovered or val_recovered <= val_5of6:
raise AssertionError(
f"Validated ledger did not advance after recovery "
f"({val_5of6} -> {val_recovered})"
)
log(f"Recovered: validated seq {val_5of6} -> {val_recovered}")
# The 4/6 window's ledgers are now validated. At least one must carry
# participant_aligned (tier 2): EntropyTier=2, EntropyCount=4. The range may
# also hold a transitional fallback (the count-3 blip) and post-recovery
# validator ledgers; the invariant is that the band was reached AND recorded
# on-ledger — never silently upgraded to tier 3 with a sub-quorum cohort.
tier2_seen = 0
for seq in range(val_5of6 + 1, val_recovered + 1):
ce, _ = get_entropy_tx(ctx, seq)
tier = ce.get("EntropyTier")
count = ce.get("EntropyCount", -1)
if tier == 2:
assert_participant_aligned(ce, seq, expected_count=4)
tier2_seen += 1
log(f" ledger {seq}: tier={tier} count={count}")
if tier2_seen == 0:
raise AssertionError(
f"No participant_aligned (tier 2) ledger in the recovered range "
f"{val_5of6 + 1}..{val_recovered}; tier 2 was injected (logs) but "
"not recorded on a validated ledger"
)
log(f"4/6 entropy: {tier2_seen} participant_aligned ledger(s) validated")
log("PASS")

View File

@@ -71,6 +71,36 @@ def entropy_fields(ce_tx):
return digest, entropy_count, is_fallback
def assert_participant_aligned(ce_tx, seq, expected_count=None):
"""Assert participant_aligned (Tier 2) entropy on a ConsensusEntropy tx.
Tier 2 is the sub-quorum band: the agreed reveal cohort is >= the
participant floor but < the 80% validator quorum, so it carries
EntropyTier=2 with a deterministic non-zero digest. NOTE entropy_fields()'s
is_fallback lumps tier 2 in with fallback (is_fallback = tier != 3), so the
tier must be checked EXPLICITLY here.
"""
digest = ce_tx.get("Digest", "")
count = ce_tx.get("EntropyCount", -1)
tier = ce_tx.get("EntropyTier", None)
if tier != 2:
raise AssertionError(
f"Ledger {seq}: expected EntropyTier==2 (participant_aligned), "
f"got {tier} (EntropyCount={count})"
)
if not digest or digest == ZERO_DIGEST:
raise AssertionError(
f"Ledger {seq}: participant_aligned digest must be non-zero, got "
f"{digest[:16]}..."
)
if expected_count is not None and count != expected_count:
raise AssertionError(
f"Ledger {seq}: participant_aligned EntropyCount must be "
f"{expected_count} (the surviving cohort), got {count}"
)
return digest, count
def assert_valid_entropy(ce_tx, seq, seen_digests=None):
"""Assert quorum-met validator entropy. Optionally check uniqueness."""
digest, entropy_count, is_fallback = entropy_fields(ce_tx)

View File

@@ -42,4 +42,14 @@ tests:
LedgerConsensus: trace
ConsensusExtensions: trace
# Tier 2 (participant_aligned) needs 6 nodes: n=5 has no band (tier2 ==
# quorum). At 6, the 4/6 window is the participant_aligned band.
- name: participant_aligned_smoke
script: .testnet/scenarios/entropy/participant_aligned_smoke.py
network:
node_count: 6
log_levels:
LedgerConsensus: trace
ConsensusExtensions: trace
# Export scenarios: see export-suite.yml