Lesson 1 — AdlScore, AdlRecord, AdlReport + adl_score — the ranking function
Question
How do you rank ADL candidates by "how lucky they were" — and how do you express in the type system that "this trader is not even a candidate"? Three types + one ranking function form the foundation of every later layer.
Principle (minimum model)
- Three design decisions are the lesson core. (1)
AdlScorenewtype prevents accidental ratio confusion. (2)AdlRecordrecords what happened (position size + entry + mark + pnl + score). (3)AdlReportis the return type ofexecute_adl— a transparent log of who was haircut and by how much. AdlScore = (mark - entry) / |position_size|. Larger profit per unit of position = higher score = earlier in the ADL queue. Symbolic; the actual representation is fixed-point i128 to preserve consensus determinism.- Type-state "not a candidate".
Option<AdlScore>—Nonemeans "this position is not an ADL candidate" (e.g. it's on the same side as the deficit, or it's already flat).Some(score)means "candidate with rank score". AdlRecordfields.position_size(signed) +entry_price+mark_price+unrealized_pnl+score(Option<AdlScore>) + post-haircut position size after ADL. Eachexecute_adlcall produces aVec<AdlRecord>.AdlReportaggregates. Total deficit absorbed + per-trader records + how much remains uncovered (almost always 0 — ADL is designed to fully absorb the deficit; uncovered is a system-level error).adl_scoreis pure compute. No I/O, no async, no state mutation. Reusable in proptest! and unit tests. Same shape as openhl-liquidation's pure-compute discipline.- Saturating arithmetic. Score computation uses
saturating_sub+saturating_divto avoid panics on edge cases (e.g. position_size = 0 → not a candidate, no division-by-zero).
Worked example + steps
Lesson 1 — AdlScore, AdlRecord, AdlReport + adl_score — the ranking function
Goal
Concepts you'll grasp in this lesson:
AdlScoreis a newtype because the score's meaning is ordering, not arithmetic. Wrappingi64in a tuple struct (pub struct AdlScore(pub i64)) lets us derivePartialOrd + Ordand treat scores as a totally-ordered type at the type level. The barei64would let you accidentally add two scores, multiply scores, use a score where a balance is expected — none of which makes sense. Newtypes encode the operations you want and forbid the ones you don't.Option<AdlScore>for the four "not a candidate" cases. Flat positions, losing positions, zero-equity positions, and zero-collateral positions are all "ineligible." Rather than returning a sentinel score (AdlScore(0)orAdlScore(-1)) and forcing the caller to check,adl_scorereturnsNone. The Lesson 2 orchestration then writescandidates.iter().filter_map(...)and ineligibility is encoded as filter-out.Optionis how you say "this input didn't produce a value of this type" at the type level.- The score is
pnl_pct × leverage, normalized byMARGIN_SCALEto fit in i64. Both factors are basis points (10000 = 100%). Their product is bps² and would overflow i64 in pathological inputs, so we (a) compute in i128, (b) saturate-multiply, (c) divide byMARGIN_SCALEto renormalize back to bps, (d) saturate the final i128 → i64 conversion. Same discipline as Stage 10a'snotional_value/unrealized_pnland Stage 10b'sliquidation_fee. - The "higher leverage same pnl_pct → higher score" axiom is the test that locks Hyperliquid's convention. Lesson 1's
score_higher_for_higher_leverage_winnertest constructs two winners with identicalpnl_pctbut different leverage and asserts score ordering. Any future refactor that flips the score formula to favor lower-leverage winners (some venues do this) would fail this single test. One test fixes the convention; the rest of the cascade can rely on it.
Verification:
cargo test -p openhl-liquidation
…passes 74 tests (69 from the Liquidation course + 5 new ADL score tests). Test count climbs to 90 by Lesson 4 (5 + 6 + 5 unit tests across Lessons 1 / 2 / 3 + 5 proptests in Lesson 4).
Specific changes:
src/adl.rs— new module file. Adds module-level doc, imports,AdlScorenewtype,AdlRecordstruct,AdlReportstruct,adl_score()function, and a 5-test scaffolding (4 None-case tests + 1 ordering test).src/lib.rs— addspub mod adl;and re-exports the four new public names (AdlScore,AdlRecord,AdlReport,adl_score).
Lesson 1 ships the type vocabulary + the pure scoring function. Lesson 2 implements the orchestration (execute_adl) that consumes both.
Recap
After the previous course (Lesson 13 of building-openhl-liquidation):
crates/liquidation/src/has 4 source files:compute.rs,insurance.rs,scanner.rs,types.rs, pluslib.rs.- 69 tests passing (34 compute + 21 insurance + 14 scanner).
- The scanner produces
ScanReport.unfilled_deficit: i64— the trigger for ADL. - No file in
crates/liquidation/src/has changed since0a8464e.
Lesson 1 starts the ADL module. The crate's diff against Stage 10d will be a single new file (adl.rs) plus the four-line lib.rs edit.
Plan
Three edits:
- Create
crates/liquidation/src/adl.rs— new module file with the doc preamble (cite the module-level doc from Lesson 0's "why ADL bypasses orderbook"), imports,AdlScorenewtype,AdlRecordstruct,AdlReportstruct, andadl_scorefunction. Noexecute_adlyet (lands in Lesson 2). - Add the 5
adl_scoreunit tests in#[cfg(test)] mod tests { ... }at the bottom ofadl.rs. Four None-case tests (flat / losing / zero collateral / short-at-entry) + one leverage-ordering test. - Add
pub mod adl;and the re-exports tocrates/liquidation/src/lib.rs.
(Answer: B → A → D → C. B is the highest-leverage profitable winner (10× leverage * 500% pnl_pct). A has 50% pnl_pct × 1× leverage. D has 75% × ~0.8× = lower than A. C has 50% × ~0.6× leverage = lowest. The exact numbers depend on equity-vs-collateral framing; the key intuition is leverage is a multiplier on PnL ranking, which is why Hyperliquid uses the product convention.)
The score formula in one diagram
┌─────────────────────────────────────────────────────────────┐
│ adl_score(snapshot, mark) → Option<AdlScore> │
├─────────────────────────────────────────────────────────────┤
│ │
│ Eligibility (returns None if ANY of these holds): │
│ ───────────── │
│ position_size == 0 ←─── flat │
│ pnl ≤ 0 ←─── losing or at entry │
│ collateral ≤ 0 ←─── degenerate (divide by zero) │
│ equity ≤ 0 ←─── degenerate (divide by zero) │
│ │
│ Computation (i128 intermediates, saturating, renormalize): │
│ ───────────── │
│ pnl_pct = pnl × MARGIN_SCALE / collateral (bps) │
│ leverage = notional × MARGIN_SCALE / equity (bps) │
│ raw = pnl_pct × leverage / MARGIN_SCALE (bps×bps→bps²/10000) │
│ score = saturate_i128_to_i64(raw) │
│ │
│ Returns: Some(AdlScore(score)) │
│ │
└─────────────────────────────────────────────────────────────┘
Three things to notice:
- The four eligibility predicates are in early-return order, cheapest first.
position_size == 0is an instant rejection (one i64 compare). The PnL / collateral / equity checks each require a function call to compute, so they fire only after the cheapest predicate has passed. Filter cascades evaluate cheapest test first; expensive tests come after. pnl_pctandleverageare both in bps, then multiplied (= bps²), then renormalized back to bps by dividing once. The renormalization is what keeps the final score in a range that fits cleanly in i64 for sane inputs. Without it, two 10000-bps factors would give10000 × 10000 = 100,000,000— fine for i64, but combined with leverage = 50_000 bps (5×) it would explode. Bps × bps × renormalize is the consensus-arithmetic idiom for percent × percent.- The final
saturate_i128_to_i64is for pathological inputs, not normal ones. A 100× leveraged winner with 1000% pnl_pct produces1000_0000 × 100_0000 / 10000 = 1_000_000_000— that's10^9bps, well within i64. The saturation only fires when something is fundamentally wrong upstream. Saturating conversion is the belt-and-suspenders for upstream bugs you didn't catch.
Walk-through
Step 1: Create src/adl.rs — doc preamble + imports
Create crates/liquidation/src/adl.rs. The module doc preamble carries the most important conceptual content from Lesson 0 (the "why ADL bypasses orderbook" framing) — cargo doc readers see it first:
//! Auto-deleveraging (ADL) — Layer 3 of the safety-net cascade (Stage 10d).
//!
//! When [`crate::scanner::LiquidationScanner`] finishes a scan with
//! `ScanReport::unfilled_deficit > 0`, the insurance fund couldn't
//! absorb everything. ADL is the last-resort mechanism: rank the
//! profitable counter-positions in the market by a "how much did they
//! win" score, force-close them in descending order, and haircut their
//! unrealized `PnL` until the deficit is absorbed.
//!
//! ### Why ADL bypasses the orderbook
//!
//! If we kept submitting market orders against profitable positions
//! through the matching engine, every order would punch through the
//! bid/ask stack and crash the mark further — which would push more
//! positions underwater. The feedback loop runs away. ADL is designed
//! to **close positions directly in the bookkeeping layer**, never
//! touching the orderbook. The records this module produces carry the
//! [`CloseOrderSpec`] for parity with Stage 10a's other paths, but the
//! bridge is expected to apply them as account-state mutations rather
//! than CLOB orders.
//!
//! ### How the haircut works
//!
//! Each ADL'd winner had unrealized `PnL` of `P` at the current mark.
//! In a normal close they'd receive `P` in full. With ADL they receive
//! `P - haircut`, where `haircut = min(remaining_deficit, P)`. The
//! system absorbs the `haircut` amount toward the unfilled deficit.
//! Winners with the highest score get the first cut; if the cumulative
//! haircuts reach the deficit before the candidate pool is exhausted,
//! later winners are untouched. If the candidate pool runs out first,
//! `AdlReport::deficit_remaining > 0` and the chain is in genuine
//! unresolved trouble.
//!
//! ### Score
//!
//! Following the Hyperliquid convention, score is
//! `unrealized_pnl_pct × leverage`, expressed in bps²/`MARGIN_SCALE`:
//!
//! ```text
//! pnl_pct_bps = pnl × MARGIN_SCALE / collateral
//! leverage_bps = notional × MARGIN_SCALE / equity
//! score = pnl_pct_bps × leverage_bps / MARGIN_SCALE
//! ```
//!
//! The intuition: the "luckiest" winners are those who both made the
//! highest relative gain AND took the most leveraged risk to get
//! there. They take the haircut first. Stable-sort ties break by
//! `AccountId` ascending so two equally-lucky winners produce a
//! deterministic order across validators.
//!
//! ### Determinism
//!
//! - All arithmetic uses i128 intermediates with saturating-to-i64
//! conversions.
//! - The ranking is a stable sort with a fully-defined tiebreaker.
//! - No clock reads, no `HashMap` iteration.
//!
//! Given the same `(candidates, mark, deficit)`, every validator
//! produces a byte-identical [`AdlReport`].
use crate::compute::{
account_equity, close_order_spec, notional_value, saturate_i128_to_i64, unrealized_pnl,
};
use crate::types::{AccountSnapshot, CloseOrderSpec, MARGIN_SCALE};
use openhl_clob::AccountId;
use openhl_funding::MarkPrice;
Five things to notice about this preamble:
- The first sentence names the trigger and the response. "When
ScanReport::unfilled_deficit > 0, the insurance fund couldn't absorb everything. ADL is the last-resort mechanism." A reader who reads only the first sentence knows where ADL sits in the cascade. Module docs lead with cascade position, not implementation detail. - The
Why ADL bypasses the orderbooksection is the load-bearing concept from Lesson 0, repeated in the module doc. Anyone who lands here without reading Lesson 0 needs the feedback-loop reason or they'll wonder why we're not just submitting market orders. Module docs duplicate the conceptual essentials from course-level orientation; readers shouldn't need to chase context. - The score formula is in a
textcode block, notrust. Because the formula isn't Rust — it's algebra. Naming ittextsignals the rendering style we want: monospace, no syntax highlighting, math notation. Usetextfor math,rustfor code; the distinction matters incargo docHTML. - The
Determinismsection names three negatives: no float arithmetic, noHashMapiteration, no clock reads. Documenting what a module doesn't do is how you signal what consensus determinism requires. Future contributors who add achrono::Utc::now()call somewhere will see this and reconsider. close_order_specis imported even though Lesson 1 doesn't use it (Lesson 2'sexecute_adldoes). This is the same staging discipline as Lesson 11'saccount_equityimport. Import what the file's full set of code uses, not what the current lesson's code uses. Unused-import warnings appear in Lesson 1 and disappear in Lesson 2.
Step 2: Add AdlScore
Below the imports, add the score newtype:
/// ADL ranking score. Higher means earlier force-close.
///
/// Computed as `pnl_pct × leverage`, both expressed in `MARGIN_SCALE`
/// units; the product is renormalized once. Saturates at `i64::MAX`
/// for pathological inputs.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct AdlScore(pub i64);
Six things to notice:
pub struct AdlScore(pub i64)— tuple struct, public inner. Thepubon the inner means callers can writeAdlScore(42)andscore.0directly. We could make the inner private and addpub fn new(v: i64) -> Self+pub fn value(&self) -> i64, but Lesson 1's primary user is the Lesson 2execute_adland the test module — both want direct access. Public-inner tuple structs are right when consumers are in-crate and the type is purely a "label" wrapper.- Derives
PartialOrd + Ord— this is why the newtype exists.Ordoni64would let any caller order any i64 by any other i64;OrdonAdlScoreonly orders scores against scores. Stage 10c'sLiquidationRecordwas a struct of unrelatedi64s — it never derivedOrdbecause ordering records makes no semantic sense. Here, ordering scores is the operation we want. DeriveOrdprecisely when comparison is the type's purpose, not just because it's i64-shaped. - Also derives
Hash— becauseBTreeMap<AdlScore, _>andHashMap<AdlScore, _>should both work if a future ADL extension needs them.Hashis cheap to derive and the cost is zero. Defensively deriveHashfor value types that consumers might use as keys. Defaultis derived —AdlScore::default()returnsAdlScore(0). This is meaningful: zero is the "nothing won, nothing lost" sentinel value. The Lesson 2 record initialization can rely on this default.Defaultfor newtypes follows the default of the wrapped type when zero is a meaningful sentinel.- No
Add/Mul/Subderives. Scores aren't summable or differenceable — there's no domain meaning for "score A plus score B." The newtype forbids these by not implementing them. The barei64would silently allowscore_a + score_b; the newtype refuses to compile such an attempt. Newtypes are subtractive: they take the operations off the table, not add new ones. - The doc comment names the saturation behavior, even though the saturation is in
adl_score's body. Consumers ofAdlScorewill read the doc, not the function; documenting the value range at the type level prevents bugs. Document a type's invariants on the type itself, not just on its constructor.
Step 3: Add AdlRecord
Below AdlScore:
/// Per-account record of one ADL force-close.
///
/// The bridge applies these as bookkeeping mutations: credit the
/// trader's collateral by `pnl_paid`, set their position size to zero,
/// remove the account from the open-positions table. `close_order`
/// carries the spec for parity with Stage 10a's other paths and for
/// telemetry; the matching engine is **not** consulted.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AdlRecord {
pub account: AccountId,
/// The (notional) close-order spec; emitted for telemetry and shape
/// consistency with [`crate::scanner::LiquidationRecord`]. The
/// bridge does NOT submit this to the CLOB.
pub close_order: CloseOrderSpec,
/// Unrealized `PnL` at the current mark — what the trader would
/// have received in a normal close.
pub pnl_gross: i64,
/// Amount the system kept toward absorbing the deficit
/// (`min(remaining_deficit, pnl_gross)` at the time this record
/// was generated).
pub haircut: i64,
/// What the trader actually receives. Always `pnl_gross - haircut`,
/// always `≥ 0`.
pub pnl_paid: i64,
/// The ranking score at the moment of selection.
pub score: AdlScore,
}
Five things to notice:
- Six fields, all
pub— same data-carrier pattern asLiquidationRecordfrom Stage 10c. The bridge reads every field directly; no accessors needed. All-public record types are right when consumers are in-crate or downstream auditors; accessors add friction without protecting any invariant. pnl_paid = pnl_gross - haircutis the conservation invariant for one record. Three fields encode the same information twice (gross, haircut, paid); the redundancy is deliberate — readers don't have to do arithmetic to know what the trader got. For audit-trail records, redundant fields are clearer than minimal fields.close_orderis present even though we don't submit it to the CLOB. Carrying it makes theAdlRecordshape-compatible withLiquidationRecord— a future "all closes in one log" merge can union the two types without re-running the close_order_spec calculation. Shape consistency across related records pays off in downstream merging code.score: AdlScore(noti64). The record carries the score type, not the raw number. Consumers of records compare scores against other scores; the newtype prevents comparing a score to a balance or to a deficit. Records hold values in their domain type, not in primitive types.- No
notionalormarkfield. The record represents the outcome of ADL on one account at one moment; the bridge already knows the mark (it calledexecute_adl(_, mark, _)) and can compute notional from the snapshot if needed. Don't duplicate caller-known context in records; store the result, not the inputs.
Step 4: Add AdlReport
Below AdlRecord:
/// Summary of one ADL pass.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct AdlReport {
/// One record per ADL'd account, in execution (rank) order.
pub records: Vec<AdlRecord>,
/// Total haircuts applied — how much of the input deficit was
/// absorbed.
pub deficit_absorbed: i64,
/// What the candidate pool couldn't cover. If `> 0`, the chain
/// must halt or the operator must accept the residual as protocol
/// loss.
pub deficit_remaining: i64,
}
Four things to notice:
- Three fields: vec + two i64 aggregates. Same shape as
ScanReport(vec + three i64 aggregates). The pattern is established: orchestration outputs carry both the audit trail and the aggregate. Match the report shape of prior modules in the same crate; predictability is its own virtue. Defaultderive is meaningful — emptyVec<AdlRecord>, zero deficit_absorbed, zero deficit_remaining. The Lesson 2 orchestration's "zero deficit input" early-return usesAdlReport::default(). Default-derived report types let happy-path early returns be one-liners.- The
Clone + Debug + PartialEq + Eq + Defaultset, but NOTCopy. Same reason asScanReport— theVecis heap-allocated. Vec-containing reports areClone; Vec-free reports areCopy. deficit_remaining > 0is the chain-insolvent signal. The doc says it. The Lesson 4 retrospective will name this as the "fourth layer" exit. Document the operational meaning of edge values, not just their type.
Step 5: Add adl_score
Below AdlReport:
/// Compute the ADL score for one account at `mark`.
///
/// Returns `None` for accounts that are not eligible for ADL:
/// - Non-profitable positions (`unrealized_pnl ≤ 0`).
/// - Flat positions (`position_size == 0`).
/// - Accounts whose collateral or equity is zero (degenerate;
/// score's divisor would be zero or negative).
#[must_use]
pub fn adl_score(snapshot: &AccountSnapshot, mark: MarkPrice) -> Option<AdlScore> {
if snapshot.position_size.0 == 0 {
return None;
}
let pnl = unrealized_pnl(snapshot, mark);
if pnl <= 0 {
return None;
}
let collateral = snapshot.collateral.0;
if collateral <= 0 {
return None;
}
let equity = account_equity(snapshot, mark);
if equity <= 0 {
return None;
}
let notional = notional_value(snapshot, mark);
// pnl_pct_bps = pnl × MARGIN_SCALE / collateral
let pnl_pct = i128::from(pnl).saturating_mul(i128::from(MARGIN_SCALE))
/ i128::from(collateral);
// leverage_bps = notional × MARGIN_SCALE / equity
let leverage = i128::from(notional).saturating_mul(i128::from(MARGIN_SCALE))
/ i128::from(equity);
// score = pnl_pct × leverage / MARGIN_SCALE (renormalize)
let raw = pnl_pct.saturating_mul(leverage) / i128::from(MARGIN_SCALE);
Some(AdlScore(saturate_i128_to_i64(raw)))
}
Eight things to notice:
- Four early-return guards, in cost-ascending order. Flat check (one compare) → pnl (function call, one compare) → collateral (one read, one compare) → equity (function call, one compare). Cheapest predicate first; expensive predicates after. The exit-fast-on-rejection pattern from Lesson 12's scanner reappears.
unrealized_pnlandaccount_equityare called separately, not collapsed into one snapshot-derive helper. Each takes(snapshot, mark)and returns onei64. Calling them separately means the function reads top-to-bottom as algebra. Linear function calls beat a one-shot bundle when the reader needs to follow the math.pnl <= 0rejects both losing AND at-entry positions. At entry,pnl == 0→ not a winner → not an ADL candidate. The unified check covers both. The "non-positive" predicate is the right boundary for "winner" semantics, not the "strictly negative" predicate.collateral <= 0andequity <= 0are defensive — they protect against divide-by-zero (and divide-by-negative, which would flip the score's sign nonsensically). Stage 10b'sliquidation_feedoesn't have these guards because it doesn't divide by collateral or equity. Divisions need pre-checks; multiplications don't.- All arithmetic uses i128 intermediates.
pnl × MARGIN_SCALEcan overflow i64 for large pnl (sinceMARGIN_SCALE = 10000). The product becomes i128, the division by collateral keeps it in i128, the next multiplication keeps it in i128, the final renormalize keeps it in i128, only atsaturate_i128_to_i64does it narrow. i128 intermediates are the consensus-arithmetic idiom for any multiplication that might overflow. saturating_mulon i128 products even though i128 has 128 bits. Belt-and-suspenders: for inputs at the boundary of "sane" (e.g., 1000% pnl × 1000× leverage at $1B notional), the products approach i128's range. Saturating once at each multiplication step costs nothing. Saturate every multiplication; the cost is zero and you eliminate one whole class of bugs.- Plain
/division, notsaturating_div. Integer division of two positive i128 values cannot overflow (onlyi128::MIN / -1can overflow division, and our values are all positive). Saturating operations are for arithmetic that can overflow; positive-positive division cannot. - The final
saturate_i128_to_i64is the cast that can lose information. If the raw i128 score is2^70, we lose bits when narrowing. The saturating conversion clamps toi64::MAXinstead of wrapping. Width-narrowing conversions need explicit saturation in consensus code.
Step 6: Add the 5 unit tests
Inside the existing #[cfg(test)] mod tests block (which you'll create at the bottom of adl.rs with the standard scaffolding), add:
#[cfg(test)]
mod tests {
use super::*;
use openhl_funding::{Notional, PositionSize};
use proptest::prelude::*;
fn snapshot(account: u64, size: i64, entry: u64, collateral: i64) -> AccountSnapshot {
AccountSnapshot {
account: AccountId(account),
position_size: PositionSize(size),
avg_entry: MarkPrice(entry),
collateral: Notional(collateral),
}
}
// ─── adl_score: None cases ─────────────────────────────────────
#[test]
fn score_none_for_flat_position() {
let s = snapshot(1, 0, 100, 1_000);
assert_eq!(adl_score(&s, MarkPrice(100)), None);
}
#[test]
fn score_none_for_losing_long() {
// Long 1 @ 100, mark 80 → pnl = -20 → not eligible
let s = snapshot(1, 1, 100, 1_000);
assert_eq!(adl_score(&s, MarkPrice(80)), None);
}
#[test]
fn score_none_for_short_at_entry() {
// pnl = 0, not profitable.
let s = snapshot(1, -1, 100, 1_000);
assert_eq!(adl_score(&s, MarkPrice(100)), None);
}
#[test]
fn score_none_for_zero_collateral() {
let s = snapshot(1, 1, 100, 0);
// Even if profitable at mark 120, collateral = 0 makes pnl_pct
// undefined (divide by zero) → ineligible.
assert_eq!(adl_score(&s, MarkPrice(120)), None);
}
// ─── adl_score: ordering ───────────────────────────────────────
#[test]
fn score_higher_for_higher_leverage_winner() {
// Two profitable longs with the same pnl_pct but different
// leverage. Higher leverage → higher score.
// Long 1 @ entry 100, mark 200 → pnl = 100.
// A: collateral 100, equity = 100 + 100 = 200, notional = 200, leverage = 1×
// pnl_pct_bps = 100 × 10_000 / 100 = 10_000
// leverage_bps = 200 × 10_000 / 200 = 10_000
// score = 10_000 × 10_000 / 10_000 = 10_000
// B: collateral 50, equity = 50 + 100 = 150, notional = 200, leverage = ~1.33×
// pnl_pct_bps = 100 × 10_000 / 50 = 20_000
// leverage_bps = 200 × 10_000 / 150 = 13_333
// score = 20_000 × 13_333 / 10_000 = 26_666
let a = snapshot(1, 1, 100, 100);
let b = snapshot(2, 1, 100, 50);
let sa = adl_score(&a, MarkPrice(200)).unwrap();
let sb = adl_score(&b, MarkPrice(200)).unwrap();
assert!(sb > sa, "higher leverage winner should rank above lower");
}
}
Seven things to notice:
- Test module reuses the
snapshothelper pattern from Lessons 4 / 8 / 11 of the Liquidation course — same signature(account, size, entry, collateral), same return type. A reader who learnedsnapshotonce recognizes it across modules. Test helpers should look the same across the crate. - Four None tests + one ordering test. The None tests exercise each branch of the eligibility filter; the ordering test exercises the score's only meaningful property (relative magnitudes). Together they cover what
adl_scorepromises. For pure functions returningOption<T>, test each None branch + one happy-path property. - The ordering test uses
assert!(sb > sa, "…")notassert_eq!. Because the exact score values are fragile (sensitive to fixed-point rounding), but the relative ordering is the load-bearing property. Property-style assertions (>,<,>=) beat value-style assertions (==) for tests whose intent is ordering rather than exact computation. - The ordering test's comment walks the math. Reader sees
pnl_pct_bps = 100 × 10_000 / 50 = 20_000and can re-derive. Samemath-walk in commentsdiscipline as Lesson 13's test comments. Math comments inside tests turn tests into worked examples. score_none_for_short_at_entryis the most subtle None case. A short position at entry haspnl = 0(not negative — exactly at entry). The test confirms that thepnl <= 0predicate correctly catches zero, not just negatives. Boundary tests on signed predicates catch the missing-equals bug.score_none_for_zero_collateralis at mark 120 (profitable!). The test's setup is deliberately misleading — the position is winning. But the divide-by-zero protection catches it. Test the defensive guards on inputs that would otherwise succeed.proptest::prelude::*;is imported, but no proptest in Lesson 1. Staged for Lesson 4 (the proptest lesson). Forward-staged imports keep Lesson 4 a purely additive lesson.
Step 7: Wire lib.rs
Open crates/liquidation/src/lib.rs. Three edits:
First, add pub mod adl; to the existing pub mod ...; block. Insert alphabetically:
pub mod adl;
pub mod compute;
pub mod insurance;
pub mod scanner;
pub mod types;
Second, add an adl re-export line alongside the existing module re-exports:
pub use adl::{adl_score, AdlRecord, AdlReport, AdlScore};
pub use compute::{
account_equity, close_order_spec, liquidation_fee, margin_health, margin_ratio,
notional_value, solvent_close_outcome, underwater_close_outcome, unrealized_pnl,
};
pub use insurance::{InsuranceFund, WithdrawOutcome};
pub use scanner::{CloseOutcomeKind, LiquidationRecord, LiquidationScanner, ScanReport};
pub use types::{
AccountSnapshot, CloseOrderSpec, LiquidationParams, MarginHealth, MarginRatio, SolventClose,
UnderwaterClose, MARGIN_SCALE,
};
Four new public names in one line: adl_score, AdlRecord, AdlReport, AdlScore — alphabetical inside the { }.
Third, optionally update the lib.rs top-of-file roadmap comment if it tracks per-stage shipped state. The exact update depends on what your lib.rs preamble currently says; the answer key marks Stage 10d as in-progress for this commit and complete for the Lesson 4 capstone.
Step 8: Run the tests
cargo test -p openhl-liquidation
Expected output (abbreviated):
running 74 tests
test adl::tests::score_higher_for_higher_leverage_winner ... ok
test adl::tests::score_none_for_flat_position ... ok
test adl::tests::score_none_for_losing_long ... ok
test adl::tests::score_none_for_short_at_entry ... ok
test adl::tests::score_none_for_zero_collateral ... ok
... (69 tests from the Liquidation course)
test result: ok. 74 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
74 tests passing. The ADL module exists with its type vocabulary and scoring function; Lesson 2 adds execute_adl (the orchestration verb) + 5 more unit tests, taking the count to 79.
Common errors:
- **
cannot find function \account_equity` in this scope** — theuse crate::compute::{ ... }import is missing one of the six names. Re-check the import block:account_equity, close_order_spec, notional_value, saturate_i128_to_i64, unrealized_pnl`. type \Option<AdlScore>` does not implement `PartialEq`— the test failure says you forgot a derive. Add#[derive(PartialEq, Eq)]toAdlScore. TheOption<T>: PartialEqblanket impl needsT: PartialEq`.score_higher_for_higher_leverage_winnerfails withscore_a >= score_b— youradl_scoreis dividing in the wrong order. Re-read the formula:pnl_pct = pnl × MARGIN_SCALE / collateral(numerator first, then divide). If you wrotepnl × (MARGIN_SCALE / collateral), integer truncation kills precision and the relative ordering flips for some inputs.score_none_for_short_at_entryfails (returnsSome(...)notNone) — yourpnl <= 0ispnl < 0(strict). Zero is profitable in the strict version; the unified<= 0is what Lesson 1 specifies.
Design reflection
Three load-bearing decisions in this lesson:
-
AdlScoreis a newtype expressly to enable ordering and to forbid arithmetic. The barei64would let you add two scores (no semantic meaning) or subtract them (also no meaning) or compare them to a balance (a real bug class). The newtype encodes the exact operations the domain supports — comparison, equality — and nothing else. Newtypes are subtractive: they take operations off the table. -
Option<AdlScore>for ineligibility, not a sentinel value. ReturningAdlScore(0)for "not eligible" would force every caller to check the value and decide whether 0 means "ineligible" or "eligible but unlucky."Optionlets the Lesson 2 orchestration usefilter_mapand never see the ineligible cases at all.Option<T>is the type-level encoding of "ineligibility"; sentinel values force every caller to re-implement the predicate. -
All four eligibility predicates use
<=, not<. Zero is not a candidate state — flat positions, zero PnL, zero collateral, zero equity are all edge cases that should not produce a score. The unified<=boundary catches the zero case without an additional== 0check. Boundary predicates on signed values usually want<=/>=; the strict-less-than form misses zero.
Answer key
cd ~/code/openhl-reference
git checkout d66b44a
diff -u ~/code/my-openhl/crates/liquidation/src/adl.rs ./crates/liquidation/src/adl.rs
diff -u ~/code/my-openhl/crates/liquidation/src/lib.rs ./crates/liquidation/src/lib.rs
After Lesson 1:
- adl.rs matches Stage 10d's
adl.rsthrough thescore_higher_for_higher_leverage_winnertest. Theexecute_adlfunction and the remaining 16 tests + 5 proptests land in Lessons 2 / 3 / 4. - lib.rs matches Stage 10d's
lib.rsbyte-for-byte for thepub mod adl;line and thepub use adl::{...}re-export.
Common questions
Q1: Why is AdlScore a tuple struct (AdlScore(i64)) and not a record struct (AdlScore { value: i64 })?
Tuple structs are the Rust idiom for single-value wrappers where the wrapping is the only purpose. Record structs are right when the type carries named state. AdlScore is a wrapper, not a state container; tuple is the right shape. Single-field newtypes are tuple structs; multi-field types are record structs.
Q2: Why does AdlRecord store both pnl_gross and pnl_paid and haircut?
Conservation: pnl_gross - haircut = pnl_paid holds for every record. Carrying all three lets the bridge log "paid out X, kept Y for the fund" without doing arithmetic. The redundancy is the readability win. Audit-trail records carry redundant fields; minimal records make callers do math.
Q3: Why does adl_score not also reject accounts with pnl_gross < some_minimum (e.g., positions where the gain is so small that haircut isn't worth the operational cost)?
Because the protocol doesn't have an "operational cost" — every ADL is a bookkeeping mutation, no orderbook touched, no fee charged. Skipping tiny gains would be a fairness choice (which haircut tiny winners vs huge winners), not a cost optimization. Hyperliquid doesn't do this; we follow the convention. If you'd add a threshold for "operational cost," verify the cost exists first.
Q4: Could the score use unrealized_pnl × position_size instead of pnl_pct × leverage?
Yes — that's the score Drift uses for its insurance fund draws (under a different name). It penalizes raw position size rather than leverage relative to collateral. Hyperliquid chose the leverage-based form because it's position-size-independent — a $1M position with 100% pnl_pct scores the same as a $100 position with 100% pnl_pct at the same leverage. The intuition: penalize risk-taking lucky winners, not just big winners. Score conventions encode the protocol's fairness model.
Q5: Why does the Cargo.toml not appear in this lesson?
Because no new dependencies are needed — adl.rs uses only crate::compute, crate::types, openhl_clob, and openhl_funding, all of which the liquidation crate already depends on. A new module file requires Cargo.toml changes only when it introduces new external dependencies.
Q6: Could adl_score be parameterized by a score_fn: F closure so future protocols could swap conventions?
You could, but the cost is real: every call site would need to pass the closure, and the Lesson 2 orchestration would carry a generic parameter through every signature. With one production score (pnl_pct × leverage), the concrete function is cleaner. If a future governance feature lets validators tune the score weights, the parameterization comes then — and it would be a LiquidationParams-style struct, not a closure. Closures parameterize functions; structs parameterize protocols. Pick the one that matches what's actually configurable.
Next lesson (Lesson 2) — execute_adl — the orchestration heart
Lesson 2 implements execute_adl(candidates, mark, deficit) -> AdlReport — the function that takes the 5-test-validated adl_score, applies it to a slice of candidates, sorts the results, and runs the haircut loop until the deficit is absorbed or the candidates exhaust.
The phase structure (5 phases): early-return on non-positive deficit → score and filter → stable-sort with tiebreaker → iterate and haircut → build report. Plus 5 simple unit tests: zero deficit, negative deficit, no candidates, no profitable candidates, single winner full absorb.
After Lesson 2, the scanner is runnable for ADL — 79 tests pass (74 from Lesson 1 + 5 new in Lesson 2). Lesson 3 adds the 6 nuanced absorption tests, Lesson 4 adds the 5 invariant proptests and the Stage 10 quartet retrospective.
Summary (3 lines)
- Three types:
AdlScorenewtype (ratio-confusion safety) +AdlRecord(per-trader log: position + entry + mark + pnl + score) +AdlReport(theexecute_adlreturn type). adl_score = (mark - entry) / |position_size|— saturating arithmetic for consensus determinism.Option<AdlScore>for "not a candidate";Some(score)for candidates.- Pure compute; same shape as openhl-liquidation's discipline. Next lesson:
execute_adl— the 5-phase orchestration heart.