Lesson 4 — compute_premium — first math, first tests
Question
First actual math function. compute_premium(mark: MarkPrice, index: IndexPrice) -> Premium computes (mark − index) / index, scaled by RATE_SCALE, in i128 with saturation. Four hand-traced unit tests start the crate's test suite.
Principle (minimum model)
- Signature.
pub fn compute_premium(mark: MarkPrice, index: IndexPrice) -> Premium— distinct input types, so swapped arguments don't compile. - Upcast before subtracting.
i128::from(mark.0) - i128::from(index.0)— subtracting twou64s would wrap formark < index. - Multiply, then divide.
diff.saturating_mul(i128::from(RATE_SCALE)) / i128::from(index.0)keeps the fractional digits; dividing first would round every sub-100% premium to zero. - Saturate back to
i64. The private helpersaturate_i128_to_i64clamps toi64::MAX/i64::MINinstead of wrapping. index == 0→Premium(0). Graceful degradation when there's no oracle price — and it pre-empts a divide-by-zero panic.- Four canonical tests.
premium_zero_when_mark_equals_index,premium_positive_when_mark_above_index,premium_negative_when_mark_below_index,premium_saturates_to_zero_when_index_is_zero— descriptive names, paper math in the comments. - Pure compute. No async, no I/O — callable from unit tests, proptests, and the clock alike.
Worked example + steps
Lesson 4 — compute_premium — first math, first tests
Goal
Concepts you'll grasp in this lesson:
- Pick integer width by the intermediate range, not the inputs —
markandindexareu64, but(mark - index) * RATE_SCALEcan hit ~1.8e28; i128 intermediates are non-optional, and the upcasts go in before the subtraction so signs survive. - Multiply before divide preserves precision —
(mark - index) / indexin integer math rounds to zero for any sub-100% premium; scaling byRATE_SCALEfirst turns the fraction into i128 magnitude, then the divide produces a meaningful integer. - Subtraction in
u64is the canonical sign bug —MarkPrice(99) - IndexPrice(100)wraps tou64::MAXand produces a huge positive premium when truth is a small negative one. Thei128::from(...)upcast is what makes the subtraction algebraically correct. - Graceful degradation for missing oracle —
index == 0returnsPremium(0)instead of erroring. Funding propagates through the bridge as balance updates; anErrwould surface as a transaction failure on unrelated payloads. Zero is the right answer when there's no signal to drive a rate. - Test comments as paper math —
// (101-100) * 1e9 / 100 = 10_000_000next to the assertion lets any future debugger verify the test against the formula, not against the test author's promise.
Verification:
cargo test -p openhl-funding
…passes 4 unit tests.
Specific changes:
The openhl-funding crate goes from "all type definitions" to "type definitions + first piece of math":
crates/funding/src/compute.rs— new file with the module doc + 2 functions:compute_premium(mark, index) -> Premium— derives(mark - index) / index, scaled byRATE_SCALE.saturate_i128_to_i64(v) -> i64— clamp helper (private). 3 lines.
- 4 hand-traced unit tests in
compute.rs's#[cfg(test)] mod testsblock:premium_zero_when_mark_equals_indexpremium_positive_when_mark_above_indexpremium_negative_when_mark_below_indexpremium_saturates_to_zero_when_index_is_zero
crates/funding/src/lib.rs— addspub mod compute;+ re-exportscompute_premium.
This is the first lesson with actual math. From now on, every code change has the potential to silently shift wealth between accounts. The hand-traced tests pin the expected output against specific input values you can verify by paper math.
Recap
After Lesson 3:
- 9 types +
RATE_SCALEintypes.rs— Stage 8b's complete type roster. - Zero behavior yet. The crate compiles but does nothing.
Lesson 4 introduces the first function. The function is short (~10 lines of body) but encodes 3 design decisions: graceful handling of index == 0, i128 intermediates for overflow safety, and saturation rather than wrap/panic.
Plan
Three edits:
- Create
crates/funding/src/compute.rs— module doc + imports +compute_premium+ privatesaturate_i128_to_i64helper. - Add
#[cfg(test)] mod teststocompute.rswith 4 hand-traced unit tests. - Update
crates/funding/src/lib.rs— addpub mod compute;declaration and re-exportcompute_premiumat the crate root.
(Answer: u64::MAX * 1e9 overflows i64 by 10 orders of magnitude. Worst case mark = u64::MAX, index = 0 (we handle this separately), or mark = u64::MAX, index = 1 → (u64::MAX - 1) * 1e9 ≈ 1.8e28. i64::MAX is ~9.2e18; we need i128 for the intermediate. After the divide by index, we're back in i64 range — but the divide must happen after the multiply, so the intermediate must fit i128. i128 is mandatory for the product; saturation handles the rare cases where even the final result overflows i64.)
Walk-through
Step 1: Create compute.rs with the module doc
Create crates/funding/src/compute.rs. Initial content:
//! Pure funding-rate math.
//!
//! Three building blocks, each stateless:
//! - [`compute_premium`] derives the mark/index gap as a signed fraction
//! - [`compute_rate`] divides + caps to produce a per-interval rate
//! - [`apply_funding`] turns a rate + position snapshot into settlements
//!
//! Each function is deterministic and saturates on overflow rather than
//! wrapping. Validators that disagree about funding fork the chain, so the
//! cost of an unexpected overflow has to be bounded behavior, not panic.
use crate::types::{
FundingParams, FundingRate, IndexPrice, MarkPrice, Notional, Position, Premium, Settlement,
RATE_SCALE,
};
Two things to notice:
The module doc previews 3 functions but we only ship 1 in Lesson 4. The cross-references [compute_rate] and [apply_funding] will be broken until Lessons 6 and 7. Tolerate the warnings — same as the Lesson 1/2 [FundingRate] cross-refs we let resolve incrementally.
The use statement imports types we don't all use yet. FundingParams, FundingRate, Notional, Position, Settlement are needed by Lesson 6/7's functions. Importing them now means the import block stabilizes after Lesson 4 — same logic as Lesson 1's [dev-dependencies] proptest. Stabilize boilerplate early; iterate on logic.
Step 2: Add compute_premium
After the use block:
/// Compute the premium `(mark - index) / index`, scaled by [`RATE_SCALE`].
///
/// Returns `Premium(0)` if `index == 0` — the safest behavior, since with no
/// reliable reference price the funding rate should not push capital around.
/// Real deployments should guard upstream (e.g., refuse to tick when the
/// oracle is missing); the saturation here is the second line of defense.
#[must_use]
pub fn compute_premium(mark: MarkPrice, index: IndexPrice) -> Premium {
if index.0 == 0 {
return Premium(0);
}
// (mark - index) as i128 so we can't lose sign on subtraction; multiply
// by RATE_SCALE in i128 to avoid overflow before the divide.
let diff = i128::from(mark.0) - i128::from(index.0);
let scaled = diff.saturating_mul(i128::from(RATE_SCALE));
let premium = scaled / i128::from(index.0);
// Saturate back to i64 — at i64 range with index prices in u64::MAX
// territory, this only clips at network-pathological inputs.
Premium(saturate_i128_to_i64(premium))
}
Drawing where types widen, saturate, and narrow inside this function makes the spine of the logic visible at a glance:
MarkPrice(u64) ──► i128 ──┐
▼
IndexPrice(u64) ──► i128 ─► [ subtract ] ──► diff (i128: sign preserved)
│
▼
RATE_SCALE(i64) ──► i128 ────────────────► [ saturating_mul ] ──► scaled (i128, overflow clamped)
│
▼
IndexPrice(u64) ──► i128 ──────────────────────────────────────► [ / divide ] (index == 0 already guarded above)
│
▼
premium (i128)
│
▼
Premium(pub i64) ◄──────────────────────────────────── [ saturate_i128_to_i64 ]
Three things this picture pins down: (a) the inputs (MarkPrice / IndexPrice / RATE_SCALE) and the output (Premium) are deliberately narrow types, while only the intermediates get widened to i128; (b) overflow is absorbed at two places — saturating_mul in the middle and saturate_i128_to_i64 at the end — but diff itself doesn't need saturation, because i128's headroom is more than enough for the subtraction; (c) the "multiply-then-divide" order is what the label scaled represents on the diagram.
10 lines of body. Four moving parts:
-
Early return on
index == 0. A zero index means the oracle hasn't delivered a price (boot state) or the asset has no spot reference. Either case should produce zero funding — there's no meaningful (mark - index) to compute when there's no index. ReturningPremium(0)is graceful degradation; an error would propagate as a transaction-level failure through the bridge, which is the wrong response to a transient oracle issue. This early return also pre-empts a divide-by-zero panic: the next line,scaled / i128::from(index.0), would otherwise be reached with a zero denominator. Graceful degradation and "never panic" share the same two lines. -
i128::from(mark.0) - i128::from(index.0). Both operands upcast toi128before the subtraction. Subtracting twou64s would underflow formark < index— the result would wrap to nearu64::MAXinstead of producing a negative number. Upcasting to signed i128 makes the subtraction algebraically correct. -
diff.saturating_mul(i128::from(RATE_SCALE)). The multiply usessaturating_mul, not regular*. At worst case (markclose tou64::MAX,indexvery small), the product can approachi128::MAX— and would overflow with regular multiplication.saturating_mulclamps toi128::MAX/i128::MINinstead of panicking. -
scaled / i128::from(index.0). The division comes after the multiplication. If we divided first, we'd lose precision —(mark - index) / indexin integer math would produce 0 for any premium less than 1.0 (the entire useful range!). Multiplying byRATE_SCALEfirst preserves the fractional digits as integer magnitude, then the divide produces the scaled premium.
Then saturate_i128_to_i64 clips back to the Premium's i64 range.
Step 3: Add the saturate_i128_to_i64 helper
After compute_premium:
/// Clamp an `i128` into the `i64` range. Used wherever an intermediate
/// product can exceed `i64::MAX` at network-pathological inputs (e.g., a
/// `u64::MAX` index price). Saturation, not wrapping — see the module-doc
/// comment on why panicking would be a worse failure mode.
fn saturate_i128_to_i64(v: i128) -> i64 {
i64::try_from(v).unwrap_or(if v > 0 { i64::MAX } else { i64::MIN })
}
Three lines of body. i64::try_from(v) returns Result — Ok(value) if v fits in i64, Err otherwise. unwrap_or(...) provides the default for the Err case: clamp to i64::MAX if the overflow was positive, i64::MIN if negative.
This function is private to the module (fn, not pub fn). Callers don't need it — they pass MarkPrice / IndexPrice in, get Premium back, and the saturation happens behind the scenes. Keeping it private prevents accidental misuse and keeps the public surface clean.
Lesson 7's apply_funding will be the second caller of this helper; that's why it's a helper and not inlined into compute_premium.
(Answer: i64::MAX. i128::MAX is ~1.7e38, way beyond i64::MAX (~9.2e18). i64::try_from(i128::MAX) fails; unwrap_or(if v > 0 { i64::MAX } else { i64::MIN }) evaluates the closure since v > 0, returning i64::MAX. Symmetric on the negative side: i128::MIN clamps to i64::MIN.)
Step 4: Add the test module + 4 unit tests
At the end of compute.rs:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn premium_zero_when_mark_equals_index() {
let p = compute_premium(MarkPrice(100), IndexPrice(100));
assert_eq!(p, Premium(0));
}
#[test]
fn premium_positive_when_mark_above_index() {
// mark 101, index 100 → premium = 1/100 = 0.01 → 10_000_000 ppb
let p = compute_premium(MarkPrice(101), IndexPrice(100));
assert_eq!(p, Premium(10_000_000));
}
#[test]
fn premium_negative_when_mark_below_index() {
let p = compute_premium(MarkPrice(99), IndexPrice(100));
assert_eq!(p, Premium(-10_000_000));
}
#[test]
fn premium_saturates_to_zero_when_index_is_zero() {
let p = compute_premium(MarkPrice(1_000_000), IndexPrice(0));
assert_eq!(p, Premium(0));
}
}
Four hand-traced tests. Each is short, but each pins a specific meaning:
-
premium_zero_when_mark_equals_index— the symmetry case. Mark = index means no dislocation. The math is straightforward:(100 - 100) * 1e9 / 100 = 0. This test catches an off-by-one or sign-flip in the formula. -
premium_positive_when_mark_above_index— the longs-overpaying case. Mark 101 > Index 100 → positive premium. The expected value10_000_000is the paper math:(101-100) * 1e9 / 100 = 1e9 / 100 = 1e7 = 10_000_000. In ppb: 1% premium. This test catches an inverted sign convention. -
premium_negative_when_mark_below_index— the shorts-overpaying case. Mark 99 < Index 100 → negative premium. Same magnitude as test 2, opposite sign. Catches the "subtract as u64 → underflow" bug specifically. -
premium_saturates_to_zero_when_index_is_zero— the graceful-degradation case.Premium(0)is the expected output, not a panic or error. Catches anyone who deletes the early-return guard "for simplicity."
The comment // mark 101, index 100 → premium = 1/100 = 0.01 → 10_000_000 ppb in test 2 is the paper math, written in the test. Anyone debugging this in the future can verify by hand that the assertion is correct — no need to trust the test author got it right.
Step 5: Update lib.rs
Open crates/funding/src/lib.rs. The current state:
//! `openhl-funding` — funding-rate state machine.
//! ...
pub mod types;
pub use types::{
FundingParams, FundingRate, IndexPrice, MarkPrice, Notional, Position, PositionSize,
Premium, Settlement, RATE_SCALE,
};
Add the compute module declaration + re-export:
//! `openhl-funding` — funding-rate state machine.
//! ...
pub mod compute;
pub mod types;
pub use compute::compute_premium;
pub use types::{
FundingParams, FundingRate, IndexPrice, MarkPrice, Notional, Position, PositionSize,
Premium, Settlement, RATE_SCALE,
};
Two changes:
pub mod compute;— declares the new module.pub use compute::compute_premium;— re-exports the function at the crate root. Callers can writeuse openhl_funding::compute_premium;instead ofuse openhl_funding::compute::compute_premium;.
Module declarations stay alphabetical (compute before types). Same for the pub use ordering. Consistency matters in long re-export blocks.
Step 6: Run tests
cargo test -p openhl-funding
Expected output:
Compiling openhl-funding v0.1.0 (/Users/.../my-openhl/crates/funding)
warning: unresolved link to `compute_rate`
warning: unresolved link to `apply_funding`
warning: unresolved link to `FundingClock`
Finished `test` profile [unoptimized + debuginfo] in 0.6s
Running unittests src/lib.rs
running 4 tests
test compute::tests::premium_negative_when_mark_below_index ... ok
test compute::tests::premium_positive_when_mark_above_index ... ok
test compute::tests::premium_saturates_to_zero_when_index_is_zero ... ok
test compute::tests::premium_zero_when_mark_equals_index ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
4 tests pass. First green run in the crate. The 3 rustdoc warnings are still expected (compute_rate/apply_funding/FundingClock — resolved by Lessons 6/7/8).
Common errors:
assertion failed: left=0 right=10_000_000on the positive test — yourcompute_premiumis missing the* RATE_SCALEstep. Without that scaling, integer division(101 - 100) / 100rounds to 0.assertion failed: left=18446744073709541616 right=-10_000_000on the negative test — you did the subtraction inu64instead of upcasting toi128. The huge positive number isu64::MAX + (99 - 100)underflow-wrapped. Add thei128::from(...)upcasts on both operands.- Panic in test — you used regular
*instead ofsaturating_mul. Regular multiplication panics on overflow in debug builds. Switch tosaturating_mul. error: cannot find function 'saturate_i128_to_i64'— the helper is defined belowcompute_premiumin the same file. Either move it above the caller, or leave it below — Rust doesn't care about declaration order in modules.
Design reflection
Four load-bearing decisions in this lesson:
-
index == 0returnsPremium(0), not an error. Graceful degradation when the oracle is unavailable. Erroring would propagate as a transaction failure through the bridge, blocking unrelated payload work. Zero is the right answer for "we have no information to drive a rate." -
i128intermediates, neveru64. The subtraction can be negative; the multiplication can exceedu64::MAX. Both operations need signed and wider arithmetic. Choose the integer width by the intermediate range, not the input range. -
saturating_mul, not*. Overflow during the multiply would either panic (debug) or wrap (release). Both are worse than saturation: panic = chain fork via halt, wrap = chain fork via wrong value. Saturation is the only bounded-behavior option for consensus-critical math. -
Test comments are the paper math.
// (101-100) * 1e9 / 100 = 10_000_000next to the assertion lets any future debugger verify the assertion against the formula, not against the test author's promise. Tests are documentation; their comments are the doc body.
Answer key
cd ~/code/openhl-reference
git checkout cd94137
diff -u ~/code/my-openhl/crates/funding/src/compute.rs ./crates/funding/src/compute.rs
diff -u ~/code/my-openhl/crates/funding/src/lib.rs ./crates/funding/src/lib.rs
After Lesson 4:
- compute.rs matches Stage 8b through
compute_premium+saturate_i128_to_i64+ the 4 hand-traced premium tests.compute_rate,apply_funding, the rate tests, and the proptests are Lessons 5–7. - lib.rs has
pub mod compute;and thecompute_premiumre-export.apply_funding,compute_rate, and the clock module are Lessons 5–8.
Return:
git checkout main
Common questions
Q: Why does compute_premium use i128 everywhere instead of just for the dangerous step?
The conversion i128::from(u64) is free (it's just a zero-extend). Doing the whole calculation in i128 is one mental model — "this function uses i128 arithmetic" — vs the mixed model "u64 here, i128 there." Uniform width is a readability win at zero cost. The final saturation back to i64 is the only conversion that has any semantic weight.
Q: Why is RATE_SCALE upcast via i128::from(RATE_SCALE) and not just RATE_SCALE as i128?
from is the idiomatic, non-truncating conversion. as i128 works here (i64 → i128 doesn't truncate), but from documents intent: "this is a widening, not a reinterpretation." Use from for widening, as only when you've already verified no truncation can happen. A future engineer reading as i128 has to verify safety; from documents that the conversion is safe.
Q: Why is the helper named saturate_i128_to_i64 and not just clamp_to_i64?
"Saturate" is the established term for "clamp at type boundary" — same word as u64::saturating_mul, i128::saturating_sub. Using the standard vocabulary makes the function's behavior obvious to any Rust dev. "Clamp" can mean any user-defined bounds; "saturate" specifically means type-bound clamping.
Q: Should compute_premium be pub(crate) instead of pub?
pub because external callers (the integration layer — openhl wires it in Stage 15a — or external observers querying funding state for telemetry) need it. pub(crate) would forbid that. The function is part of the public API. saturate_i128_to_i64 is the implementation detail; compute_premium is the contract.
Next lesson (Lesson 5)
Lesson 5 doesn't add a new function. Instead, it does a deep dive on the overflow philosophy: why saturation is the only acceptable behavior for consensus-critical math, what the alternatives look like and why they fork the chain, and how saturate_i128_to_i64's edges behave under pathological inputs. The lesson also adds 1 proptest (premium_is_antisymmetric_in_mark_index) — the property that swapping mark and index flips the premium sign. First proptest in the crate.
Summary (3 lines)
compute_premium(mark, index) -> Premium=(mark − index) × RATE_SCALE / indexini128, saturated back toi64;index == 0returnsPremium(0).- Four hand-traced tests (equal / above / below / zero index); descriptive naming, paper math in comments. The first proptest comes next lesson.
- Pure compute = reusable everywhere. Pinned to SHA
cd94137. Next: overflow philosophy + first proptest.