Skip to content

Conversation

@amesgen
Copy link
Member

@amesgen amesgen commented Jul 16, 2025

This PR does not change any behavior; most of the diff is purely mechanical.

Previously, the SelectViews of all ConsensusProtocols contained the BlockNo as the most important criterion for comparing chains, with only tiebreaking behavior (among chains of equal length) being different across different protocols.

This PR makes this explicit: SelectView is now longer an associated type family of ConsensusProtocol, but rather an ordinary data type

data SelectView p = SelectView
  { svBlockNo :: !BlockNo
  , svTiebreakerView :: !(TiebreakerView p)
  }

where TiebreakerView is now an associated type family of ConsensusProtocol.

This PR also removes the WithBlockNo type that the HFC was already using to always attach a BlockNo to its HardForkSelectView. The code can be simpler now that SelectViews always automatically contain an explicit BlockNo.

Also see #1542 (comment)

Primary motivation

The main motivation for this is to enable weighted chain selection in Peras (tweag/cardano-peras#62), which can now be easily modeled by adding the weight of a chain to the block number component, at least morally; we probably want to eventually have more type safety here to make it hard to "forget" to account for the weight, such as defining

data WeightedSelectView p = WeightedSelectView
  { wsvWeight :: !PerasWeight
  , wsvTiebreakerView :: !(TiebreakerView p)
  }

If the only goal were to minimize the diff, then it would also be possible to add a function (BlockNo -> BlockNo) -> SelectView p -> SelectView p, but it seems more honest to "properly" extract out the block number.

instance NoThunks (TiebreakerView p) => NoThunks (SelectView p)

-- | First compare block numbers, then compare the 'TiebreakerView'.
deriving stock instance Ord (TiebreakerView p) => Ord (SelectView p)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be an explicitly written-out Ord instance? it would be bad if someone decided to reorder the fields in SelectView and catastrophically broke chain selection lol

Copy link
Member Author

@amesgen amesgen Jul 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, pushed 👍

Irrational grudge of mine: I always found it a bit depressing to write such instances by hand; motivated by #549, I wrote https://gist.github.com/amesgen/0ddcbe0e2bae458ba8b40d4799d1b6e4 (essentially a "surgery" in the sense of generic-data) that allowed you to permute the constructors of a data type in the Generic representation for DerivingVia. The same thing of course works for fields instead of constructors; with that, we could write

deriving
  via Generically (PermuteFields ["svBlockNo", "svTiebreakerView"] (SelectView p))
  instance Ord (TiebreakerView p) => Ord (SelectView p)

which is now resistant to syntactic reordering in the data declaration of data SelectView. All that to avoid writing

instance Ord (TiebreakerView p) => Ord (SelectView p) where
  compare =
    mconcat
      [ compare `on` svBlockNo
      , compare `on` svTiebreakerView
      ]

by hand 💪 💪 💪

Copy link
Member

@dnadales dnadales left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. I left a couple of comments. Let me know when the TODOs are addressed and a new review round is required 👍

{ svBlockNo :: !BlockNo
, svTiebreakerView :: !(TiebreakerView p)
}
deriving stock Generic
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably unrelated to this PR, but I was wondering if we could elaborate on why the block number (which will become a weigth) and the notion of a tie-breaker cannot be unified into a single weight notion.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to capture this in the Haddocks 👍

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, it's still unclear to me (the answer to the question above, the haddocks comment is clear :D). It seems that ultimately, we could write a function to sort two select views which take the information of the tie-breaker into consideration. Do we want to keep the block number and tiebreaker separately because of the separation that we currently have between sorting candidates and preferring a chain?

Copy link
Member Author

@amesgen amesgen Jul 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not exactly sure what unification you have in mind, is it something different than what we have in current main, where everybody defines SelectView as a "single" thing determining the chain order for themselves? Or sth else entirely?

we could write a function to sort two select views which take the information of the tie-breaker into consideration.

Are you expecting something different than in the Ord/ChainOrder instances for SelectView in this PR?

Do we want to keep the block number and tiebreaker separately because of the separation that we currently have between sorting candidates and preferring a chain?

It is unrelated to that distinction; the motivation is

  1. (minor) to make it explicit that BlockNo must be the primary way of comparing chains; even before this PR, ConsensusProtocols didn't have the freedom to use arbitrary SelectViews, and
  2. nicely supports weighted chain comparisons, see "Primary motivation" in the PR description, as well as Introduce weighted chain comparisons #1594.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FTR, we discussed this offline with @amesgen. The key constraint we have is that we rely on a transitive Ord instance when selecting candidates. Therefore, we cannot unify the sorting of Views in a single function. I think that we have is indeed two comparison functions that operate on SelectViews. It's just that we're explicit about the part of the field that is used for breaking ties (which I think makes sense)

@amesgen amesgen force-pushed the amesgen/tiebreaker-view branch from 05b7cb1 to adb6891 Compare July 16, 2025 17:15
@amesgen amesgen marked this pull request as ready for review July 16, 2025 17:16
@amesgen amesgen force-pushed the amesgen/tiebreaker-view branch from adb6891 to 13c868f Compare July 17, 2025 13:25
@amesgen amesgen force-pushed the amesgen/tiebreaker-view branch from 13c868f to 9b07e31 Compare July 18, 2025 09:36
Copy link
Member

@dnadales dnadales left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great.


-- | The chain order of some type; in the Consensus layer, this will always be
-- the 'SelectView' of some 'ConsensusProtocol'.
-- the 'TiebreakerView'/'SelectView' of some 'ConsensusProtocol'.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we mention both in this comment? Would it make sense to clarify when we use one and when the other?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because we have instances for both of them, where ChainOrder SelectView uses ChainOrder TiebreakerView. I explicitly mentioned this also here 👍

{ svBlockNo :: !BlockNo
, svTiebreakerView :: !(TiebreakerView p)
}
deriving stock Generic
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, it's still unclear to me (the answer to the question above, the haddocks comment is clear :D). It seems that ultimately, we could write a function to sort two select views which take the information of the tie-breaker into consideration. Do we want to keep the block number and tiebreaker separately because of the separation that we currently have between sorting candidates and preferring a chain?

@amesgen amesgen force-pushed the amesgen/tiebreaker-view branch from 9b07e31 to 5cdc439 Compare July 18, 2025 11:04
@amesgen amesgen added this pull request to the merge queue Jul 18, 2025
Merged via the queue into main with commit c54d384 Jul 18, 2025
17 of 18 checks passed
@amesgen amesgen deleted the amesgen/tiebreaker-view branch July 18, 2025 12:48
@jasagredo jasagredo moved this to ✅ Done in Consensus Team Backlog Jul 22, 2025
amesgen added a commit that referenced this pull request Sep 17, 2025
This PR does not change any behavior; most of the diff is purely
mechanical.

Previously, the `SelectView`s of all `ConsensusProtocol`s contained the
`BlockNo` as the most important criterion for comparing chains, with
only tiebreaking behavior (among chains of equal length) being different
across different protocols.

This PR makes this explicit: `SelectView` is now longer an associated
type family of `ConsensusProtocol`, but rather an ordinary data type
```haskell
data SelectView p = SelectView
  { svBlockNo :: !BlockNo
  , svTiebreakerView :: !(TiebreakerView p)
  }
```
where `TiebreakerView` is now an associated type family of
`ConsensusProtocol`.

This PR also removes the `WithBlockNo` type that the HFC was already
using to always attach a `BlockNo` to its `HardForkSelectView`. The code
can be simpler now that `SelectView`s always automatically contain an
explicit `BlockNo`.

Also see
#1542 (comment)

## Primary motivation

The main motivation for this is to enable weighted chain selection in
Peras (tweag/cardano-peras#62), which can now
be easily modeled by adding the weight of a chain to the block number
component, at least morally; we probably want to eventually have more
type safety here to make it hard to "forget" to account for the weight,
such as defining
```haskell
data WeightedSelectView p = WeightedSelectView
  { wsvWeight :: !PerasWeight
  , wsvTiebreakerView :: !(TiebreakerView p)
  }
```

If the only goal were to minimize the diff, then it would also be
possible to add a function `(BlockNo -> BlockNo) -> SelectView p ->
SelectView p`, but it seems more honest to "properly" extract out the
block number.
github-merge-queue bot pushed a commit that referenced this pull request Oct 21, 2025
…ed chain selection (#1678)

# Description

This PR introduces:

- **Weighted chain comparisons**: We compare chains based on their
*weight* instead of their length. Non-trivial weight is given by Peras
certificates as introduced in
#1673.
- **Weighted chain selection**: Select the *weightiest* chain instead of
the *longest* chain.
- **Weighted immutability criterion**: Define the immutable tip in terms
of *weight* instead of length.

### Commits

The commits are intended to be reviewed individually:

 - *ChainDB: expose PerasCertDB functionality*

The ChainDB maintains a PerasCertDB internally, and exposes most of its
functionality through its public API. This is analogous to how the
LedgerDB is managed by the ChainDB.

 - *`SecurityParam`: mention weighted nature*

Purely a documentation update to mention the richer dynamics under
Peras, plus a small helper function.

 - *O.C.Peras.Weight: add `takeVolatileSuffix`*

In Praos, the volatile suffix of a chain is defined to be the `k` most
recent blocks. Analogously, in Peras, the volatile suffix of a chain is
defined to be the longest suffix with weight at most `k`.

This commits adds an appropriate function (via a binary search), as well
as documentation and tests.

 - *ChainDB: define `getCurrentChain` in terms of weight*

This makes use of the previous commit in the ChainDB. Note that
#1619 guarantees
that the LedgerDB automatically uses the same notion of immutability.

Note that this means that the immutable tip will be less than `k` blocks
behind the tip when Peras is working well, ie every Peras round is
giving rise to a certificate. Concretely, for plausible parameters, ie a
round length of $U = 90$ slots and a Peras boost of $B = 15$, the length
of the volatile suffix decreases from $k=2160$ to

   $$\frac{k}{1+\frac{B}{U\cdot f}} \approx 499$$

   on average.

 - *ChainDB.StateMachine: check immutable tip monotonicity*

To make sure that the more refined immutability criterion doesn't
introduce any surprises, we add a postcondition to the ChainDB q-s-m
test that the immutable tip never recedes.

- *GSM: allow `candidateOverSelection` to be stateful* and *ChainSel:
make `rollbackExceedsSuffix` weight-aware*

Preparatory refactorings for when the chain comparison will become
weighted and hence additionally depend on the `PerasWeightSnapshot`.

 - *Peras.SelectView: initialize, expose `WeightedSelectView`*

This introduces the notion of a *weighted* `SelectView`, making use of
#1591

https://github.com/IntersectMBO/ouroboros-consensus/blob/90131b282b4e002e9ddc0d7196043c50b255a1d4/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Peras/SelectView.hs#L35-L48

This will used in place of the "normal" `SelectView`. Eventually, we
could also remove the normal `SelectView` and replace it with
`WeightedSelectView`. However, to keep this already big PR more focused,
we propose to do this in the future.

 - *Introduce weighted chain comparisons*

The core change in this commit is in the `O.C.Util.AnchoredFragment` to
the `preferAnchoredCandidate` and `compareAnchoredFragments` functions.
These now

- have a slightly strengthened precondition, namely that they intersect
(as we otherwise can't meaningfully compare their weight), that we
ensure is satisfied anywhere, and
    - take `PerasWeightSnapshot` as an additional argument.

We keep the old implementation in case the `PerasWeightSnapshot` is
empty; which is semantically unnecessary, but is a trivial way to make
sure that no performance regression is introduced.

The rest of the diff of this commit is simply due to adapting the
respective call sites in a rather straightforward fashion.

 - *Integrate weighted BlockFetch decision logic*

This commit plugs the weighted chain comparison logic into the
BlockFetch decision logic. This relies on a (merged, but not yet
released, hence the s-r-p) Network change, see
IntersectMBO/ouroboros-network#5161 for a
detailed description.

 - *ChainDB: implement chain selection for Peras certificates*

We allow new Peras certificates to be added to the ChainDB (and
therefore to the managed PerasCertDB). If the certificate is boosting a
block that is not on the current selection, we perform chain selection
for it, potentially switching to a fork containing it if it now is
weightier than our selection.

 - *MockChainSel: switch to weighted chain selection*

   This logic is for example used in tests.

 - *ChainDB q-s-m: test weighted chain selection*

We enrich the model ChainDB implementation with weighted chain
selection, and add a new command with a simple generator for adding
certificates. We also enrich labelling with the
`TagSwitchedToShorterChain` tag that shows that we sometimes *do* switch
to a shorter chain; which would be a bug without Peras.

A follow-up PR
(#1670) will
further improve the generators of this test.

---

### **Regression**

As of today, there is no way to generate certificates, so `PerasCertDB`
is always empty, and the change to the chain selection algorithm is
therefore invisible: in the absence of certificates, the Peras weight of
a chain is equal to its length. Therefore, this PR does not introduce
any semantic changes.

Furthermore, from a performance standpoint, care is taken to ensure that
if there are no certificates, we use the unweighted Praos logic, meaning
that there is no change in performance when Peras is disabled.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: ✅ Done

Development

Successfully merging this pull request may close these issues.

4 participants