Lesson 9 — Interval-gating invariant — three deeper tests
Question
The interval-gating invariant: funding fires exactly once per interval, never more, never less. Three tests that prove the FundingClock satisfies it under all input sequences.
Principle (minimum model)
- Invariant. Between two consecutive Funds, exactly one
intervalof time has passed. Never more (no catch-up), never less (no double-fire). - Test 1: exact boundary. Tick at
last + interval→ fires. Tick atlast + interval - 1→ doesn't. Tick atlast + interval + 1→ fires. - Test 2: subsequent boundaries. After firing at T, next fires at
T + interval. Not atT + interval - 1, not atT + 2 * interval. - Test 3: missed interval. Tick at
last + 2 * interval. Fires once (atlast + 2 * interval);lastis updated to that. Critical: does not "catch up" to the missed interval. - Why no catch-up? Catch-up creates retroactive payments — users would pay funding for a period they didn't expect. Hyperliquid's design: funding is forward-looking, never retroactive.
- Proptest version. Generate a random sequence of ticks; assert the number of Funds equals
floor(total_elapsed / interval). Universal claim. - Why three tests, not one. Each test exercises a different boundary condition. Mixing them all into one proptest hides the actual failure cases.
Worked example + steps
Lesson 9 — Interval-gating invariant — three deeper tests
Goal
Concepts you'll grasp in this lesson:
- Single-call tests verify behavior; multi-call tests verify state machines — Lesson 8 confirmed the guard can return
Someonce. Lesson 9'ssecond_tick_requires_another_full_intervalconfirms the guard re-engages after firing. A buggy implementation could fire once and never gate again; you need three sequential calls to catch that. - Composition tests catch wiring errors — even when every step is unit-tested, the wiring between steps is a separate concern.
tick()could callapply_fundingbeforecompute_rate, or passindexwheremarkis expected. A full math composition test (premium_drives_settlement_signs) catches what unit tests can't. - Invariants must be re-verified at every layer they traverse —
compute_rate's cap is unit-tested in Lesson 6, butcapped_rate_when_premium_extremere-verifies it throughtick(). A wiring bug (e.g., overwritingparams.rate_capmid-call) would slip past lower-layer tests. - Boundary tests as pairs: just-before and exactly-at —
now == last_settled_at + interval - 1(none) andnow == last_settled_at + interval(fires) is the standard pair. Both directions catch off-by-one in the guard condition. Adding+1doesn't catch a different class of bug. - Failure leaves state unchanged — when
tick()returnsNone,last_settled_atstays put. Three sequential calls (fire, gated, fire) reveal this sub-invariant by the success time of the third call.
Verification:
cargo test -p openhl-funding
…passes 21 tests (18 from Lessons 4–8 + 3 new).
Specific changes:
No new production code. The three new tests deepen our coverage of clock semantics across multiple operations:
premium_drives_settlement_signs— full math composition flows through the clock. mark > index → positive premium → settlement signs match.second_tick_requires_another_full_interval— interval-gating is persistent across ticks. A successful tick doesn't permanently unlock the clock.capped_rate_when_premium_extreme—compute_rate's cap behavior surfaces correctly throughtick(). Layers compose without losing semantics.
The teaching focus is invariants across multiple operations, not just one. Lesson 8's tests verified the guard works once; Lesson 9's tests verify it works across ticks and that the layered composition doesn't introduce subtle bugs.
Recap
After Lesson 8:
FundingClockexists withtick()returningOption<FundingTick>.- 3 sanity tests confirm: guard works, boundary fires, empty positions still advance.
- All 3 Module 2 functions compose through
tick().
Lesson 8's tests run the clock at most once. Lesson 9 exercises the clock across multiple calls, with non-trivial inputs, to validate the invariant holds beyond a single operation.
Plan
One file edit:
- Append 3 tests to
crates/funding/src/clock.rs— inside the existing#[cfg(test)] mod testsblock, after the 3 sanity tests from Lesson 8.
No production code, no lib.rs changes, no imports beyond what Lesson 8 already added.
(Answer: One successful tick says the guard can return Some. It doesn't say the guard re-engages afterward. A buggy implementation could fire on the first interval boundary, then never gate again — every subsequent tick() would return Some regardless of time. The invariant "at most one settlement per interval" requires testing that the second tick is rejected unless another full interval has passed. Single-operation tests verify behavior; multi-operation tests verify state machines.)
Walk-through
Step 1: Add premium_drives_settlement_signs
After the Lesson 8 tests in mod tests, add:
#[test]
fn premium_drives_settlement_signs() {
let params = FundingParams::hyperliquid_default();
let mut clock = FundingClock::new(params, 1_000_000);
// mark 101, index 100 → premium = 0.01 = 10_000_000 ppb
// rate = 10_000_000 / 8 = 1_250_000 ppb
// long size 100 * mark 101 * rate / RATE_SCALE = 100*101*1.25e6 / 1e9
// = 1.2625e10 / 1e9 = 12 (floor)
// long pays → -12; short receives → +12.
let out = clock
.tick(1_003_600, MarkPrice(101), IndexPrice(100), &balanced_book())
.expect("tick should fire");
assert_eq!(out.premium, Premium(10_000_000));
assert_eq!(out.rate, FundingRate(1_250_000));
assert_eq!(out.settlements.len(), 2);
assert_eq!(out.settlements[0].delta, Notional(-12));
assert_eq!(out.settlements[1].delta, Notional(12));
}
This is the full math composition test for the clock. Every Module 2 function gets exercised in sequence:
compute_premium(MarkPrice(101), IndexPrice(100))→Premium(10_000_000)(1% premium).compute_rate(Premium(10_000_000), hyperliquid_default)→FundingRate(1_250_000)(0.125%, after divisor 8).apply_funding(&[Pos(1, 100), Pos(2, -100)], MarkPrice(101), FundingRate(1_250_000))→[Settlement(-12), Settlement(+12)].
The 5-line block comment is the paper math. Anyone debugging this test can verify the arithmetic by hand: 100 × 101 × 1_250_000 = 12_625_000_000. Divided by RATE_SCALE = 1_000_000_000 (with integer rounding toward zero), that's 12. With the sign flip from apply_funding, long gets -12, short gets +12. The comment is documentation; tests are the spec.
Why does this test exist if every step is already tested individually? Because composition is its own concern. tick() could conceivably call the wrong function in the wrong order — e.g., apply_funding before compute_rate, or pass index where mark is expected. Composition tests catch wiring errors that unit tests miss.
Step 2: Add second_tick_requires_another_full_interval
After premium_drives_settlement_signs:
#[test]
fn second_tick_requires_another_full_interval() {
let params = FundingParams::hyperliquid_default();
let mut clock = FundingClock::new(params, 1_000_000);
// First tick at +3600.
clock
.tick(1_003_600, MarkPrice(101), IndexPrice(100), &balanced_book())
.expect("first tick fires");
// +3599 from first tick → not enough.
let early = clock.tick(1_007_199, MarkPrice(101), IndexPrice(100), &balanced_book());
assert!(early.is_none());
// +3600 from first tick → fires.
let on_time = clock.tick(1_007_200, MarkPrice(101), IndexPrice(100), &balanced_book());
assert!(on_time.is_some());
}
Three tick calls, three assertions. The structure tells the story:
- First tick at
1_003_600— fires (boundary case from Lesson 8). After this,last_settled_at = 1_003_600. - Second tick at
1_007_199—1_007_199 - 1_003_600 = 3599. One second short of an interval. ReturnsNone. - Third tick at
1_007_200—1_007_200 - 1_003_600 = 3600. Exactly an interval. ReturnsSome.
The invariant being tested: "the interval guard re-engages after every successful tick." A naive implementation that only checks against genesis_time (instead of last_settled_at) would fire on every tick after 1_003_600 — and this test catches it.
The minimal counterexample: between Lesson 8's first_tick_at_exact_interval_fires and Lesson 9's second_tick_requires_another_full_interval, the only thing being verified is that last_settled_at is the gating reference, not genesis_time. Three calls is the minimum to test state-machine persistence.
(Answer:
- After tick 1 (success):
1_003_600. - After tick 2 (None — gated): unchanged, still
1_003_600. - After tick 3 (success):
1_007_200.
The clock doesn't advance on a gated call. That's the second part of the interval-gating invariant: failure leaves state unchanged. The test doesn't explicitly assert last_settled_at after tick 2, but the success of tick 3 at exactly 1_003_600 + 3600 implies it.)
Laying the three calls out on a timeline makes the "what moves vs. what stays" picture (= gate re-engagement) immediate:
timeline (seconds)
1_000_000 ── Genesis (FundingClock::new, last_settled_at = 1_000_000)
│
│ +3,600 s (= exactly one interval)
▼
1_003_600 ── Tick 1: success ✨
now ≥ last_settled_at + interval → fire
returns Some(FundingTick { settled_at: 1_003_600, ... })
──► last_settled_at reset to 1_003_600
│
│ +3,599 s (still one second short)
▼
1_007_199 ── Tick 2: rejected 🛑
now < last_settled_at + interval (1_007_200) → guarded → None
──► last_settled_at stays at 1_003_600 (state untouched)
│
│ +1 s more (one full interval reached)
▼
1_007_200 ── Tick 3: success ✨
now ≥ last_settled_at + interval again → fire
──► last_settled_at reset to 1_007_200
All timestamps in this timeline are Unix seconds. So +3600 is one full hour, and +3599 is exactly "one second short" (not a millisecond-scale delta).
The load-bearing point of this test is that Tick 1's success does not permanently unlock the clock — Tick 3 only fires because another full interval has elapsed since Tick 1. That "gate closes again" invariant only becomes observable with three calls in sequence.
Step 3: Add capped_rate_when_premium_extreme
After second_tick_requires_another_full_interval:
#[test]
fn capped_rate_when_premium_extreme() {
let params = FundingParams::hyperliquid_default();
let mut clock = FundingClock::new(params, 1_000_000);
// mark 200, index 100 → premium = 1.0 = 1e9 ppb
// raw rate = 1e9 / 8 = 1.25e8; cap = 4e7 → clamps to 4e7.
let out = clock
.tick(1_003_600, MarkPrice(200), IndexPrice(100), &balanced_book())
.unwrap();
assert_eq!(out.rate, FundingRate(40_000_000));
}
Tests that compute_rate's cap clamps correctly when called through tick(). The math:
compute_premium(MarkPrice(200), IndexPrice(100))→Premium(1_000_000_000)(100% premium).compute_rate(Premium(1_000_000_000), {divisor=8, cap=40M})→ raw =1_000_000_000 / 8 = 125_000_000. After clamp to±40_000_000→FundingRate(40_000_000).
Why does this test exist if compute_rate's tests already cover clamping? Because we need to know tick() doesn't unwrap, fiddle with, or bypass the rate before applying it. The cap surfaces through the clock unchanged.
What this test really protects is the invariant that the type-safe relay assembled across Lessons 4–6 stays lossless inside tick(). Drawing the data path:
[MarkPrice(200), IndexPrice(100)] ──► compute_premium ──► Premium(1_000_000_000)
│
▼
FundingParams { divisor: 8, cap: 4e7 } ──► compute_rate ──► FundingRate(40_000_000)
│
※ does this pass through
losslessly?
▼
FundingTick { rate: FundingRate(40_000_000), .. }
│
▼
out.rate == FundingRate(40_000_000) ✨
The actual assertion is "compute_rate's return value is forwarded into FundingTick's rate field byte-for-byte." If tick() accidentally stripped the newtype via .0, recomputed against different FundingParams, or rebuilt FundingRate from a derived value, the result here would diverge from 40_000_000 and the test would catch it immediately. The test proves the type relay is intact by actually pushing a value through it.
A subtle wiring bug — say, compute_rate(premium, FundingParams { rate_cap: FundingRate(0), ..params }) — would break this test (zero cap → zero rate → no settlements at all). The composition test catches what unit tests can't.
Step 4: Run tests
cargo test -p openhl-funding
Expected:
running 21 tests
test clock::tests::capped_rate_when_premium_extreme ... ok
test clock::tests::empty_positions_yield_empty_settlements_but_still_advance_clock ... ok
test clock::tests::first_tick_at_exact_interval_fires ... ok
test clock::tests::first_tick_before_interval_returns_none ... ok
test clock::tests::premium_drives_settlement_signs ... ok
test clock::tests::second_tick_requires_another_full_interval ... ok
... (15 tests from Lessons 4–7 compute.rs)
test result: ok. 21 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
21 tests, all green. 6 of them now live in clock::tests (3 from Lesson 8 + 3 from Lesson 9).
Common errors:
premium_drives_settlement_signsfails withNotional(-13)orNotional(-11)— off-by-one from rounding. Re-check the math:100 × 101 × 1_250_000 = 12_625_000_000. Divided by1_000_000_000is12.625. Integer division truncates toward zero →12. The sign flip →-12. If your number is different, check whether you're using*(which panics on debug overflow),saturating_mul, orwrapping_mul.second_tick_requires_another_full_intervalfails on the second tick — your guard is comparing togenesis_timeinstead oflast_settled_at. Re-read the Lesson 8 code: the guard isnow < self.last_settled_at.saturating_add(...), notnow < self.params.genesis_time + ....capped_rate_when_premium_extremereturnsFundingRate(125_000_000)— yourcompute_rateisn't clamping. Re-check Lesson 6: theraw.clamp(-cap, cap)line should be present.
Design reflection
Four load-bearing decisions in this lesson:
-
Composition tests catch wiring errors. Even when every step is unit-tested, the wiring between steps is a separate concern. A 3-step pipeline needs at least 3 composition tests (one for each step's correct placement) plus a multi-step composition test.
premium_drives_settlement_signsis the latter. -
State machines need multi-call tests. A single operation can satisfy an invariant by accident; only multiple operations confirm the state machine enforces it consistently.
second_tick_requires_another_full_intervalexists becausefirst_tick_at_exact_interval_firesalone is insufficient. -
Boundary tests at every gate. Both the inclusive boundary (
now == last_settled_at + interval) and the exclusive boundary (now == last_settled_at + interval - 1) need to be tested. One-second-short and one-second-after are the standard pair. -
Each layer's invariants need their own surface tests.
compute_ratetests prove the cap clamps.ticktests prove the cap survives the composition. Composition can lose semantics; verify each invariant at every layer it traverses.
Answer key
cd ~/code/openhl-reference
git checkout cd94137
diff -u ~/code/my-openhl/crates/funding/src/clock.rs ./crates/funding/src/clock.rs
After Lesson 9:
- clock.rs matches Stage 8b through 6 of 7 tests. Only
no_catchup_after_long_gapremains — that's Lesson 10's milestone test.
Return:
git checkout main
Common questions
Q: Why does second_tick_requires_another_full_interval not also test +3601?
Because +3600 exactly and +3599 together pin both sides of the boundary. +3601 would just be slightly more than +3600 — same direction. Two boundary cases (just-before and exactly-at) suffice. Adding more cases doesn't catch a different class of bug.
Q: Could we have caught the "genesis vs last_settled_at" bug with a proptest?
You could — random (t1, t2) pairs with t2 < t1 + interval should produce None on the second tick. But the hand-traced test makes the intent clearer: "after a tick at t1, the next tick at t1 + 3599 is gated." Proptests excel at properties; hand-traced tests excel at named scenarios. State-machine behaviors are usually scenarios.
Q: Why does the test not include a third tick at, say, +7200 (two intervals after first)?
Because that wouldn't add information. The second tick at +3600 already establishes that the clock fires at the correct cadence; a third tick is just more of the same. Tests should distinguish themselves by what they verify, not by adding repetition.
Q: What if the test author had genesis_time = 0 instead of 1_000_000?
The math would be identical, but the test would be less helpful. Using 1_000_000 (and the corresponding 1_003_600, etc.) makes the "clock advanced by 3600 seconds" pattern visible at every assertion. Test data should be readable, not just correct.
Next lesson (Lesson 10)
Lesson 10 closes Module 3 with the no-catch-up invariant: the milestone test no_catchup_after_long_gap. The scenario: validator reboots after 10 hours of downtime, so now - last_settled_at = 36000 (10 intervals). The naive expectation might be "catch up by replaying 10 ticks," but the design choice is to settle once and advance to now. The lesson explains why catch-up would be worse than skipping ticks, and the test confirms the design choice is enforced. One test, one invariant, the design philosophy in action.
Summary (3 lines)
- Interval-gating invariant: exactly one Fund per interval; never more, never less.
- Three tests: exact boundary, subsequent boundaries, missed interval (critical: no catch-up). Proptest universalises.
- No catch-up = forward-looking funding only; matches Hyperliquid's design. Next: no-catch-up invariant proptest.