レッスン9 — Interval-gating 不変条件 — 3 つの deeper test
問い
レッスン8 の first_tick_at_exact_interval_fires は tick を 1 度呼んで Some を確認した。だがそれは guard が Some を 返しうる ことしか示さない。guard が fire した 後にも再び engage する こと、レイヤーを重ねた composition が値を歪めないことは、どう検証するか?
原理(最小モデル)
- 単一呼び出しは挙動を、複数呼び出しは state machine を検証する。 1 度 fire したきり gate しなくなる buggy 実装を捕まえるには 3 連続呼び出し(fire / gated / fire)が要る。
- composition テストが接続ミスを捕まえる。 各ステップが unit-test 済みでも、
tick()がapply_fundingをcompute_rateより先に呼ぶ/markを期待する場所にindexを渡す等の接続バグは別の関心事。 - 不変条件は通過する各レイヤーで再検証。
compute_rateの cap はレッスン6 で unit-test 済みだが、tick()経由でも再検証(途中でparams.rate_capを上書きする接続バグは下層テストをすり抜ける)。 - 境界テストはペアで: just-before と exactly-at。
now == last+interval-1(None)とnow == last+interval(fire)。+1を足しても別のバグクラスは捕まらない。 - 失敗は state を変えない。
tick()がNoneのときlast_settled_atは不変。
具体例
3 連続呼び出しを時間軸で並べると、ゲートの再エンゲージが一望できる:
1_000_000 ── Genesis (last_settled_at = 1_000_000)
│ +3,600 秒 (= 1 interval)
1_003_600 ── Tick 1: 成功 ✨ ──► last_settled_at = 1_003_600 にリセット
│ +3,599 秒 (まだ 1 秒足りない)
1_007_199 ── Tick 2: 拒否 🛑 (now < last+interval) ──► last_settled_at = 1_003_600 のまま
│ さらに +1 秒
1_007_200 ── Tick 3: 成功 ✨ ──► last_settled_at = 1_007_200
load-bearing なのは Tick 1 の成功が clock を恒久 unlock しないこと — Tick 3 を fire させるには Tick 1 起点で新たに 1 interval 待つ。3 つ並べないとこの「ゲートが閉じ直す」挙動は観測できない。
失敗例(誤解)
「premium_drives_settlement_signs は apply_funding のテストと重複、out.rate だけ確認すれば十分」は誤り — このテストの要点は composition。apply_funding のテストは pass するのにこれだけ fail するなら、バグは tick() の呼び出し組み立て方にあって apply_funding 内でない。3 レイヤー深ければ最低 3 つの composition テストが要る。
ここまでで「複数呼び出しで state machine を検証」は着地。ここから新規 production コードなしでテスト 3 つを足す。コードは完全形。
🛑 予測。 レッスン8 の単一 tick 成功では、なぜ interval-gating 不変条件の検証として不十分?(答え: 1 度の成功は guard が
Someを 返しうる ことしか示さない。buggy 実装は最初の境界で fire してから二度と gate しないかもしれない(1_003_600以降の全 tick が時間に関係なくSome)。「interval ごと最多 1 settlement」の検証には、別の full interval が経つまで second tick が拒否されることの確認が要る。)
ステップで組み立てる
Step 1: premium_drives_settlement_signs を追加
mod tests のレッスン8 テストの後に:
#[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));
}
clock の full math composition テスト(premium→rate→settlements が順に exercise)。5 行コメントが紙の数学(100×101×1_250_000 = 12_625_000_000、/1e9 = 12、符号反転で long -12/short +12)。各ステップが unit-test 済みでも composition は別の関心事 — 間違った順序・引数取り違えを捕まえる。
Step 2: second_tick_requires_another_full_interval を追加
#[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());
}
tick 3 回・assert 3 つで story を語る。検証する不変条件: 「interval guard は成功 tick ごとに再 engage する」。genesis_time にだけ比較する素朴な実装だと 1_003_600 以降の全 tick が fire する — それを捕まえる。state machine の持続性確認には 3 回呼び出しが最小構成。
Step 3: capped_rate_when_premium_extreme を追加
#[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));
}
compute_rate の cap が tick() 経由でも clamp として効くか検証(premium 100% → raw 12.5% → cap 4% に clamp)。tick() が rate を unwrap/いじる/bypass しないことを示す — 型のリレーが lossless に通り抜けることの証明。rate_cap: FundingRate(0) のような接続バグはここで壊れる。
Step 4: テスト実行
cargo test -p openhl-funding が 21 pass(compute 15 + clock 6)。
答え合わせ
cd ~/code/openhl-reference && git checkout cd94137
diff -u ~/code/my-openhl/crates/funding/src/clock.rs ./crates/funding/src/clock.rs
git checkout main
clock.rs は 7 テスト中 6 つまで一致(no_catchup_after_long_gap のみ残る — レッスン10 のマイルストーン)。
合格基準
cargo test -p openhl-funding が 21 pass。よくあるエラー: premium_drives_settlement_signs が -13/-11(rounding off-by-one — */saturating_mul/wrapping_mul のどれか確認)/ second_tick が second で fail(guard が last_settled_at でなく genesis_time と比較)/ capped_rate が 125_000_000(clamp が効いていない)。
まとめ(3行)
- 単一呼び出しは挙動を、複数呼び出しは state machine を検証 —
second_tick_requires_another_full_intervalの 3 連続呼び出し(fire/gated/fire)が「guard が成功 tick ごとに再 engage する」を pin。 - composition テスト(
premium_drives_settlement_signs)が unit テストの拾えない接続ミス(順序・引数取り違え)を捕まえる。3 レイヤー深ければ最低 3 つ要る。 - 不変条件は通過する各レイヤーで再検証(
capped_rate_when_premium_extremeが cap の clock 経由 surfacing を確認)。境界テストは just-before + exactly-at のペアで。
次のレッスン(レッスン10)
セクション3 を no-catch-up 不変条件で閉じる。no_catchup_after_long_gap:validator が 10 interval のダウンタイム後に reboot しても、replay でなく 1 度だけ settle して now に advance。catch-up がなぜ tick スキップより悪いか(負け側に集中懲罰)を説明する。