refactor(consensus): expose threshold policy seams

This commit is contained in:
Nicholas Dudfield
2026-06-24 18:58:24 +07:00
parent acb492a2c9
commit 4fb91ea9f5
9 changed files with 248 additions and 31 deletions

View File

@@ -788,6 +788,19 @@ class ConsensusExtensions_test : public beast::unit_test::suite
{
testcase("Sidecar peer alignment helper");
BEAST_EXPECT(detail::sidecarLocalContribution(true) == 1);
BEAST_EXPECT(detail::sidecarLocalContribution(false) == 0);
BEAST_EXPECT(detail::sidecarLocalContribution(true, true) == 1);
BEAST_EXPECT(detail::sidecarLocalContribution(false, true) == 0);
BEAST_EXPECT(detail::sidecarAlignedParticipants(2, true) == 3);
BEAST_EXPECT(detail::sidecarAlignedParticipants(2, false) == 2);
BEAST_EXPECT(detail::sidecarQuorumAligned(2, true, 3));
BEAST_EXPECT(!detail::sidecarQuorumAligned(2, true, 4));
BEAST_EXPECT(detail::sidecarFullObservation(2, 2));
BEAST_EXPECT(!detail::sidecarFullObservation(2, 3));
BEAST_EXPECT(detail::exportSigSetQuorumAligned(4, 4));
BEAST_EXPECT(!detail::exportSigSetQuorumAligned(3, 4));
ExportTickHarness harness;
auto const localHash = makeHash("sidecar-local");
auto const conflictHash = makeHash("sidecar-conflict");
@@ -814,7 +827,7 @@ class ConsensusExtensions_test : public beast::unit_test::suite
fetched.push_back(*hash);
});
BEAST_EXPECT(state.localPublished);
BEAST_EXPECT(state.localCounts);
BEAST_EXPECT(state.conflict);
BEAST_EXPECT(state.aligned == 1);
BEAST_EXPECT(state.alignedParticipants() == 2);
@@ -835,7 +848,7 @@ class ConsensusExtensions_test : public beast::unit_test::suite
exportHashOf,
allMembers,
[](auto const&) {});
BEAST_EXPECT(!unpublishedState.localPublished);
BEAST_EXPECT(!unpublishedState.localCounts);
BEAST_EXPECT(unpublishedState.alignedParticipants() == 0);
BEAST_EXPECT(!unpublishedState.quorumAligned(1));
BEAST_EXPECT(unpublishedState.fullObservation());
@@ -880,7 +893,7 @@ class ConsensusExtensions_test : public beast::unit_test::suite
exportHashOf,
activeOnly,
[](auto const&) {});
BEAST_EXPECT(!nonActiveLocal.localPublished);
BEAST_EXPECT(!nonActiveLocal.localCounts);
BEAST_EXPECT(nonActiveLocal.alignedParticipants() == 1); // node 1 only
//@@end test-sidecar-active-view-filter
}
@@ -1112,6 +1125,52 @@ class ConsensusExtensions_test : public beast::unit_test::suite
}
}
void
testThresholdPolicyHelpers()
{
testcase("consensus-extension threshold policy helpers");
// Empty views fail closed at one participant. The raw formulas stay
// mathematical; sidecar gates use the safe wrappers.
BEAST_EXPECT(calculateQuorumThreshold(0) == 0);
BEAST_EXPECT(safeQuorumThreshold(0) == 1);
BEAST_EXPECT(safeQuorumThreshold(6) == calculateQuorumThreshold(6));
BEAST_EXPECT(calculateParticipantThreshold(0) == 1);
BEAST_EXPECT(safeParticipantThreshold(0) == 1);
BEAST_EXPECT(
safeParticipantThreshold(10) == calculateParticipantThreshold(10));
// Gate threshold uses effective view for the 80% quorum and original
// view for the Tier-2 floor, then takes the lower enabled bar.
BEAST_EXPECT(
ConsensusExtensions::entropyGateThresholdForView(0, 0) == 1);
BEAST_EXPECT(
ConsensusExtensions::entropyGateThresholdForView(6, 6) == 4);
BEAST_EXPECT(
ConsensusExtensions::entropyGateThresholdForView(8, 10) == 7);
BEAST_EXPECT(
ConsensusExtensions::entropyGateThresholdForView(6, 10) == 5);
// Tier labels require a ledger-anchored UNLReport view. With one, the
// ladder is validator_quorum first, then participant_aligned, then
// fallback.
BEAST_EXPECT(
ConsensusExtensions::selectEntropyTierForView(false, 99, 8, 10) ==
entropyTierConsensusFallback);
BEAST_EXPECT(
ConsensusExtensions::selectEntropyTierForView(true, 7, 8, 10) ==
entropyTierValidatorQuorum);
BEAST_EXPECT(
ConsensusExtensions::selectEntropyTierForView(true, 6, 8, 10) ==
entropyTierConsensusFallback);
BEAST_EXPECT(
ConsensusExtensions::selectEntropyTierForView(true, 5, 8, 8) ==
entropyTierParticipantAligned);
BEAST_EXPECT(
ConsensusExtensions::selectEntropyTierForView(true, 4, 8, 8) ==
entropyTierConsensusFallback);
}
void
testRuntimeConfigPolicyAccessors()
{
@@ -3291,6 +3350,7 @@ public:
testActiveValidatorViewAppliesNegativeUNL();
testActiveValidatorViewNullSourceAndExpectedProposers();
testParticipantThreshold();
testThresholdPolicyHelpers();
testRuntimeConfigPolicyAccessors();
testDecoratePositionGeneratesCommitment();
testOnPreBuildInjectsZeroEntropyFallback();

View File

@@ -1613,6 +1613,20 @@ class NegativeUNLVoteOffline_test : public beast::unit_test::suite
class NegativeUNLVoteMaxListed_test : public beast::unit_test::suite
{
void
testMaxListedPolicy()
{
testcase("Max listed policy");
BEAST_EXPECT(NegativeUNLVote::maxNegativeUNLListed(0) == 0);
BEAST_EXPECT(NegativeUNLVote::maxNegativeUNLListed(1) == 1);
BEAST_EXPECT(NegativeUNLVote::maxNegativeUNLListed(4) == 1);
BEAST_EXPECT(NegativeUNLVote::maxNegativeUNLListed(5) == 2);
BEAST_EXPECT(NegativeUNLVote::maxNegativeUNLListed(8) == 2);
BEAST_EXPECT(NegativeUNLVote::maxNegativeUNLListed(9) == 3);
BEAST_EXPECT(NegativeUNLVote::maxNegativeUNLListed(32) == 8);
}
void
testDoVoting()
{
@@ -1640,6 +1654,7 @@ class NegativeUNLVoteMaxListed_test : public beast::unit_test::suite
void
run() override
{
testMaxListedPolicy();
testDoVoting();
}
};

View File

@@ -215,23 +215,19 @@ ConsensusExtensions::quorumThreshold() const
// Use the shared validator view so Tier 3 RNG and Export use the same
// denominator.
auto const base = activeValidatorView()->size();
if (base == 0)
return 1; // safety: need at least one commit
return calculateQuorumThreshold(base);
return safeQuorumThreshold(base);
}
std::size_t
ConsensusExtensions::exportSigQuorumThreshold() const
{
auto const base = activeValidatorView()->size();
if (base == 0)
return 1;
// Export sidecar hashes are signed through ExtendedPosition even when RNG
// is disabled, so a quorum-aligned exportSigSetHash is deterministic
// enough for Export-only mode. Unanimity would let one active validator
// veto an otherwise converged export round.
return calculateQuorumThreshold(base);
return safeQuorumThreshold(base);
}
std::size_t
@@ -248,9 +244,7 @@ ConsensusExtensions::tier2Threshold() const
// Regressing this to size() is a consensus fork under nUNL and is pinned by
// ConsensusExtensions_test::testTier2ThresholdAnchorsToOriginalView.
auto const base = activeValidatorView()->originalViewSize;
if (base == 0)
return 1; // safety: need at least one aligned participant
return calculateParticipantThreshold(base);
return safeParticipantThreshold(base);
}
std::size_t
@@ -267,7 +261,39 @@ ConsensusExtensions::entropyGateThreshold() const
// allowed only when the round view is anchored by UNLReport; the
// trusted-fallback view is local configuration and selectEntropy() maps it
// to consensus_fallback.
return std::min(quorumThreshold(), tier2Threshold());
auto const view = activeValidatorView();
return entropyGateThresholdForView(view->size(), view->originalViewSize);
}
std::size_t
ConsensusExtensions::entropyGateThresholdForView(
std::size_t effectiveViewSize,
std::size_t originalViewSize)
{
auto const quorum = safeQuorumThreshold(effectiveViewSize);
auto const tier2 = safeParticipantThreshold(originalViewSize);
return std::min(quorum, tier2);
}
EntropyTier
ConsensusExtensions::selectEntropyTierForView(
bool fromUNLReport,
std::size_t participantCount,
std::size_t effectiveViewSize,
std::size_t originalViewSize)
{
if (!fromUNLReport)
return entropyTierConsensusFallback;
auto const quorum = safeQuorumThreshold(effectiveViewSize);
if (participantCount >= quorum)
return entropyTierValidatorQuorum;
auto const tier2 = safeParticipantThreshold(originalViewSize);
if (participantCount >= tier2)
return entropyTierParticipantAligned;
return entropyTierConsensusFallback;
}
void
@@ -349,9 +375,7 @@ bool
ConsensusExtensions::hasQuorumOfCommits() const
{
auto const validatorView = activeValidatorView();
auto const threshold = validatorView->size() == 0
? std::size_t{1}
: calculateQuorumThreshold(validatorView->size());
auto const threshold = safeQuorumThreshold(validatorView->size());
auto const proofedCommitCount = std::count_if(
pendingCommits_.begin(),
pendingCommits_.end(),
@@ -534,10 +558,13 @@ ConsensusExtensions::selectEntropy(
// floor over the original view (~0.6*n; see calculateParticipantThreshold).
// Below tier2Threshold too few aligned participants contributed to trust
// the result — fall back.
if (count >= quorumThreshold())
return {digest, entropyTierValidatorQuorum, count};
if (count >= tier2Threshold())
return {digest, entropyTierParticipantAligned, count};
auto const tier = selectEntropyTierForView(
validatorView->fromUNLReport,
count,
validatorView->size(),
validatorView->originalViewSize);
if (tier != entropyTierConsensusFallback)
return {digest, static_cast<std::uint8_t>(tier), count};
return fallback();
//@@end entropy-selector-tier-ladder
}

View File

@@ -13,6 +13,7 @@
#include <xrpl/basics/Buffer.h>
#include <xrpl/basics/Log.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/protocol/EntropyTier.h>
#include <xrpl/protocol/PublicKey.h>
#include <chrono>
#include <map>
@@ -174,6 +175,25 @@ public:
std::size_t
entropyGateThreshold() const;
/// Pure threshold helper used by the live gate and optional drift tests.
/// `effectiveViewSize` is post-nUNL; `originalViewSize` is the pre-nUNL
/// UNLReport active count.
static std::size_t
entropyGateThresholdForView(
std::size_t effectiveViewSize,
std::size_t originalViewSize);
/// Pure selector helper for the tier label only. The digest/count bytes are
/// still derived from the agreed entropySetMap_ in selectEntropy(); this
/// isolates the policy that says which EntropyTier a given agreed count
/// earns under a ledger-anchored validator view.
static EntropyTier
selectEntropyTierForView(
bool fromUNLReport,
std::size_t participantCount,
std::size_t effectiveViewSize,
std::size_t originalViewSize);
void
setExpectedProposers(hash_set<NodeID> proposers);

View File

@@ -368,8 +368,7 @@ NegativeUNLVote::findAllCandidates(
{
// Compute if need to find more validators to disable
auto const canAdd = [&]() -> bool {
auto const maxNegativeListed = static_cast<std::size_t>(
std::ceil(unl.size() * negativeUNLMaxListed));
auto const maxNegativeListed = maxNegativeUNLListed(unl.size());
std::size_t negativeListed = 0;
for (auto const& n : unl)
{

View File

@@ -74,7 +74,19 @@ public:
/**
* We only want to put 25% of the UNL on the NegativeUNL.
*/
static constexpr float negativeUNLMaxListed = 0.25;
static constexpr size_t negativeUNLMaxListedDenominator = 4;
/** Maximum number of validators allowed on the NegativeUNL for a given
* UNL size, rounded up. Kept as integer policy arithmetic so consensus
* tests compare against the production helper, not a copied
* floating-point expression.
*/
static constexpr size_t
maxNegativeUNLListed(size_t unlSize)
{
return (unlSize + negativeUNLMaxListedDenominator - 1) /
negativeUNLMaxListedDenominator;
}
/**
* A flag indicating whether a UNLModify Tx is to disable or to re-enable

View File

@@ -173,8 +173,7 @@ Export::doApply()
if (!ctx_.app.config().standalone())
{
//@@start export-doapply-agreed-signature-snapshot
std::size_t const threshold =
unlSize == 0 ? 1 : calculateQuorumThreshold(unlSize);
std::size_t const threshold = safeQuorumThreshold(unlSize);
if (!validatorView->fromUNLReport)
{

View File

@@ -10,9 +10,70 @@ namespace ripple {
namespace detail {
inline std::size_t
sidecarLocalContribution(bool localCounts)
{
return localCounts ? 1 : 0;
}
inline std::size_t
sidecarLocalContribution(bool localIsMember, bool localPublished)
{
return sidecarLocalContribution(localIsMember && localPublished);
}
inline std::size_t
sidecarAlignedParticipants(std::size_t aligned, bool localCounts)
{
return aligned + sidecarLocalContribution(localCounts);
}
inline std::size_t
sidecarAlignedParticipants(
std::size_t aligned,
bool localIsMember,
bool localPublished)
{
return aligned + sidecarLocalContribution(localIsMember, localPublished);
}
inline bool
sidecarQuorumAligned(
std::size_t aligned,
bool localCounts,
std::size_t threshold)
{
return sidecarAlignedParticipants(aligned, localCounts) >= threshold;
}
inline bool
sidecarQuorumAligned(
std::size_t aligned,
bool localIsMember,
bool localPublished,
std::size_t threshold)
{
return sidecarAlignedParticipants(aligned, localIsMember, localPublished) >=
threshold;
}
inline bool
sidecarFullObservation(std::size_t peersSeen, std::size_t txConverged)
{
return peersSeen == txConverged;
}
inline bool
exportSigSetQuorumAligned(
std::size_t alignedParticipants,
std::size_t quorumThreshold)
{
return alignedParticipants >= quorumThreshold;
}
struct SidecarPeerAlignment
{
bool localPublished = false;
bool localCounts = false;
bool conflict = false;
std::size_t aligned = 0;
std::size_t peersSeen = 0;
@@ -21,19 +82,19 @@ struct SidecarPeerAlignment
std::size_t
alignedParticipants() const
{
return aligned + (localPublished ? 1 : 0);
return sidecarAlignedParticipants(aligned, localCounts);
}
bool
quorumAligned(std::size_t quorum) const
{
return alignedParticipants() >= quorum;
return sidecarQuorumAligned(aligned, localCounts, quorum);
}
bool
fullObservation() const
{
return peersSeen == txConverged;
return sidecarFullObservation(peersSeen, txConverged);
}
};
@@ -66,7 +127,7 @@ inspectTxConvergedSidecarPeers(
// margin (2t - N) below the Byzantine floor f, breaking equivocation
// uniqueness. Mirror buildEntropySet/hasQuorumOfCommits' containsNode
// filter, and only count our own +1 when this node is itself active.
state.localPublished = localIsMember;
state.localCounts = localIsMember;
for (auto const& [nodeId, peerPos] : peerPositions)
{
if (!isMember(nodeId))
@@ -1104,7 +1165,8 @@ extensionsTick(Ext& ext, Ctx const& ctx)
auto exportState = inspectExportPeers(ctx.getPosition(), true);
auto const exportQuorum = ext.exportSigQuorumThreshold();
auto quorumAligned = [&] {
return exportState.quorumAligned(exportQuorum);
return detail::exportSigSetQuorumAligned(
exportState.alignedParticipants(), exportQuorum);
};
//@@start export-sigset-alignment-check
if (exportState.conflict && !quorumAligned())

View File

@@ -229,6 +229,18 @@ calculateQuorumThreshold(std::size_t count)
return (count * 80 + 99) / 100;
}
/** Safe quorum helper for consensus-extension gates.
The raw quorum formula returns zero for an empty view. Consensus-extension
sidecar gates use a fail-closed floor of one participant instead: an empty
local view must not make a sidecar quorum vacuously true.
*/
inline std::size_t
safeQuorumThreshold(std::size_t count)
{
return count == 0 ? 1 : calculateQuorumThreshold(count);
}
/** Calculate the Tier 2 (participant_aligned) alignment floor.
Tier 2 sub-quorum entropy aligns a cohort at this lower bar. The floor is
@@ -265,6 +277,17 @@ calculateParticipantThreshold(std::size_t count)
return (count + byzantine) / 2 + 1;
}
/** Safe Tier-2 helper for consensus-extension gates.
Like safeQuorumThreshold(), this floors empty-view sidecar gates at one
participant so they fail closed instead of succeeding vacuously.
*/
inline std::size_t
safeParticipantThreshold(std::size_t count)
{
return count == 0 ? 1 : calculateParticipantThreshold(count);
}
inline std::pair<std::size_t, std::optional<ConsensusParms::AvalancheState>>
getNeededWeight(
ConsensusParms const& p,