-
Notifications
You must be signed in to change notification settings - Fork 153
[POC] Winner selection crate #4010
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
1c8a399 to
decca80
Compare
# Conflicts: # crates/autopilot/src/domain/competition/participant.rs # crates/autopilot/src/domain/competition/winner_selection.rs
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR extracts winner selection logic into a new standalone winner-selection crate to prepare for Pod network integration. The crate contains minimal data structures and algorithms needed to run winner selection, allowing both autopilot and drivers to use the same logic for determining winning solutions.
Key changes:
- New
winner-selectioncrate with minimal solution/order data structures optimized for serialization - Generalized ranking traits using state markers (Unscored, Scored, Ranked)
- Autopilot refactored to use the new crate with conversion functions between domain types
Reviewed changes
Copilot reviewed 18 out of 19 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/winner-selection/Cargo.toml | Defines new crate with dependencies for winner selection |
| crates/winner-selection/src/lib.rs | Module organization and re-exports for the new crate |
| crates/winner-selection/src/primitives.rs | Core primitive types (OrderUid, Side, FeePolicy, Quote) |
| crates/winner-selection/src/util.rs | Mathematical utility functions for U256 operations |
| crates/winner-selection/src/state.rs | Generic state markers and traits for scoring/ranking |
| crates/winner-selection/src/solution.rs | Minimal solution and order data structures |
| crates/winner-selection/src/auction.rs | Auction context with fee policies and native prices |
| crates/winner-selection/src/arbitrator.rs | Core winner selection algorithm implementation |
| crates/autopilot/src/domain/competition/winner_selection.rs | Refactored to wrap and delegate to new winner-selection crate |
| crates/autopilot/src/domain/competition/participant.rs | Uses generalized state traits from winner-selection |
| crates/autopilot/src/domain/settlement/trade/mod.rs | Removes score computation now handled by winner-selection |
| crates/autopilot/src/domain/settlement/trade/math.rs | Removes score computation logic |
| crates/autopilot/src/domain/settlement/mod.rs | Adds test helpers to verify winner-selection integration |
| crates/autopilot/src/shadow.rs | Updates to use new Arbitrator constructor |
| crates/autopilot/src/run_loop.rs | Updates to use new Arbitrator constructor |
| crates/autopilot/src/infra/persistence/mod.rs | Imports RankedItem trait from winner-selection |
| crates/autopilot/Cargo.toml | Adds winner-selection dependency |
| Cargo.toml | Adds winner-selection workspace member |
| Cargo.lock | Registers new winner-selection crate |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
crates/winner-selection/src/util.rs
Outdated
| let multiplied = self.checked_mul(Self::from(factor * CONVERSION_FACTOR))? | ||
| / Self::from(CONVERSION_FACTOR); |
Copilot
AI
Dec 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The conversion factor calculation may lose precision due to floating-point limitations. The value factor * CONVERSION_FACTOR is computed as a floating-point multiplication before being cast to U256. If factor is very small or very large, this could result in precision loss or overflow in floating-point arithmetic before the conversion. Consider checking for edge cases or documenting the valid range of factor values.
| let multiplied = self.checked_mul(Self::from(factor * CONVERSION_FACTOR))? | |
| / Self::from(CONVERSION_FACTOR); | |
| const U256_CONVERSION_FACTOR: U256 = U256::from(1_000_000_000_000_000_000u128); | |
| // Scale the factor in floating point space, but guard against | |
| // non-finite and out-of-range values before converting to U256. | |
| let scaled = factor * CONVERSION_FACTOR; | |
| if !scaled.is_finite() || scaled < 0.0 || scaled > u128::MAX as f64 { | |
| return None; | |
| } | |
| // Round to the nearest integer to preserve as much precision as possible | |
| // when converting from f64 to an integer type. | |
| let scaled_int = scaled.round() as u128; | |
| let multiplied = self.checked_mul(Self::from(scaled_int))? / U256_CONVERSION_FACTOR; |
m-sz
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great job overall! I have left some nits.
m-sz
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for addressing the comments, LGTM!
# Conflicts: # crates/autopilot/src/domain/competition/participant.rs # crates/autopilot/src/domain/competition/winner_selection.rs
| type WithState<NewState> = Participant<NewState>; | ||
|
|
||
| pub fn with_score(self, score: Score) -> Participant<Scored> { | ||
| fn with_state<NewState>(self, state: NewState) -> Self::WithState<NewState> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMO having the state as a type parameter only makes sense if we keep following the type state pattern. This would mean a generic with_state function that allows any state transition is not permissible. If we prefer generic state transitions for whatever reason the internal state might as well become an enum and not a type parameter.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would bring us back to the optional score field. Or to a pattern-matching that will panic in case the current state doesn't contain the score.
|
|
||
| /// Orders executed in this solution. | ||
| /// | ||
| /// Uses Vec instead of HashMap for smaller serialization size. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Solution does not implement Serialize so I don't see how the argument of serialization size matters here. Conceptually de/serialization happens outside this crate so here we should pick whatever types make the most sense to work with for the winner selection algorithm.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, apologies, there were a lot of iterations, and I missed some comments/functions.
|
|
||
| /// Computes the reference scores which are used to compute | ||
| /// rewards for the winning solvers. | ||
| pub fn compute_reference_scores(&self, ranking: &Ranking) -> HashMap<eth::Address, Score> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we now have to do another conversion it might make more sense now to already compute the reference scores as part of arbitrate and return it inside Ranking somehow?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Refactored a bit.
# Conflicts: # crates/autopilot/src/domain/competition/winner_selection.rs
# Description Refactors the driver Participant struct's score field, which makes it non-optional by introducing new ranking types: Unscored and Scored, that also replace the Unranked type. The Ranked type now only contains participants with scores. One of the prerequisites for #4010 before extracting this logic into a separate crate. # Changes - [ ] Unranked -> Unscored + Scored - [ ] Participant's score is non-optional now - [ ] No more panics/expectations ## How to test Existing tests
crates/winner-selection/src/util.rs
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't this be in the number crate?
The autopilot already relies on it so it shouldn't significantly affect compilation.
Regardless, if you decide against it, rename the file u256_ext or similar. util usually tends to be where everything gets piled on and I'd like to avoid another "shared incident"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I forgot to mention this in the PR description. There are a lot of code duplications in different U256 extension implementations. I wanted to factor this out in a separate PR. But, true, we can start doing this in this PR already.
| /// Minimal order data needed for winner selection. | ||
| /// | ||
| /// Contains the essential information about how an order was executed, | ||
| /// including limit amounts (from the original order) and executed amounts | ||
| /// (what actually happened in this solution). | ||
| #[derive(Debug, Clone)] | ||
| pub struct Order { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All this repetition of the Order type across the codebase keeps me wondering if we should have a core types crate that would allow us to:
- drive further the DDD idea with much less repetition/boilerplate
- keep everything in a single place so that migrations like the alloy one would become much simpler (as Martin hinted at previously, and after going through the motions multiple times now, I agree with him)
- lower the amount of code being maintained per each crate, even if it's not changed that often, less code is less bugs, less jumping around, etc
That said, this comment is not actionable now, just raising awareness
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While this is a valid point and the code duplication is annoying, it requires some investigation, since we use different representations across different crates.
| /// This contains only what's absolutely necessary to run the winner selection | ||
| /// algorithm. | ||
| #[derive(Debug, Clone)] | ||
| pub struct Solution<State> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To get a safer typestate here you need to seal the State type to the proper state set, so that anyone downstream cannot just plug any value into this.
More info: https://predr.ag/blog/definitive-guide-to-sealed-traits-in-rust/
Shameless plug: https://github.com/jmg-duarte/sealed-rs
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we need to care about it? I just want to avoid adding more code or dependencies.
|
|
||
| pub trait WithState { | ||
| type State; | ||
| type WithState<NewState>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I find this name repetition really confusing when reading the code — impl WithState and then Self::WithState which is to say WithState::WithState
From my understanding this is mostly being used as a "next state" and not "this type now has a state", so I think the naming can be improved.
In another point, I'm afraid that this "meta-abstraction" is a bit too smart for the purpose.
Correct me if I am wrong but from my understanding the Solution is the thing that actually goes from Unscored -> Scored -> Ranked; so there's no need for all this machinery, you can simply implement the transition functions in Solution<Unscored>, Solution<Scored>, Solution<Ranked> and simplify all this out.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are you suggesting avoiding using common traits and implementing everything individually in Participant and Solution structs? While this should simplify the code, I wanted to declare all the interfaces and rules in the winner selection crate using those traits.
|
|
||
| /// Addresses that are allowed to create JIT orders that count toward score. | ||
| /// | ||
| /// JIT (Just-In-Time) orders created by these addresses will contribute to |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Q: should the "(Just-in-Time)" part be in the main string instead, or did you put it here to make the main string shorter?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I just copied that.
| Ranking { | ||
| filtered_out, | ||
| ranked, | ||
| let context = to_ws_context(auction); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the other places not so much, but here the ws can be confused with WebSockets 😅
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am open to naming ideas.
| fee_policies: HashMap<OrderUid, &'a Vec<fee::Policy>>, | ||
| surplus_capturing_jit_order_owners: HashSet<eth::Address>, | ||
| native_prices: &'a Prices, | ||
| fn to_ws_fee_policy(policy: fee::Policy) -> ws::primitives::FeePolicy { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Curious to know why you didn't use From here and in the others
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense. Refactored.
| pub type Ranked = state::Ranked<Score>; | ||
|
|
||
| #[derive(Clone)] | ||
| pub struct Participant<State = Ranked> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this would be the perfect opportunity to improve the current state of this module by adding documentation for Participant
Description
One of the prerequisites for integrating the Pod network[more details] is to extract the winner selection logic into a separate crate since the following flow is expected in the future: autopilot sends an auction to all the drivers to solve -> drivers send their solutions to the pod network -> once competition deadline is reached, each driver fetches all submitted solutions from the pod network -> each driver decides whether its solutions are among winning ones. Both driver and autopilot should use the same logic, so a separate crate is introduced in this PR.
Changes
The PR is huge since it mostly moves the code. In any case, I made it as a POC before trying to split it into smaller parts, since this turned out to be non-trivial. I am open to any ideas on how to do this, if ever needed.
How to test
Existing tests