レッスン10 — No-catch-up 不変条件 — 1 テストで設計哲学
問い
validator が 10 時間ダウンして reboot し、次の tick() で now - last_settled_at が 10 interval になっている。素朴には「10 tick を replay して追いつく」と考える。だがそれは公平か? clock が 遅れた とき、何が正しい semantics か?
原理(最小モデル)
- No-catch-up は公平性の不変条件。 10 interval のギャップ後は 1 度だけ settle して
nowに advance。現在のスナップショットで 10 回 replay すると、ギャップ中に position を閉じられなかった負け側に 10 倍の懲罰が集中する。funding の目的は equilibration であって遡及的強制でない。 nowへ advance、last_settled + intervalでない。 deadline は実際の settlement 時刻にリセット、見逃した interval を完全に忘れる。これがこのテストで pin する設計判断。- 同じ
nowでの second tick は最も厳しいテスト。 2 呼び出しの間で時間が 1ms も経たず、変わったのは clock の内部 state だけ。遅れた tick でlast_settled_atを更新し忘れる実装を全部捕まえる。 - catch-up ポリシーは clock の外側に。 必要な呼び出し側は中間時点のスナップショットで
tick()を繰り返し呼ぶ wrapper を書く。clock は過去 state にアクセスできない。primitive はミニマルに、policy は呼び出し側に。 - 設計哲学は doc・コード・テストの 3 箇所に住む。 module doc が約束し、
self.last_settled_at = nowが強制し、no_catchup_after_long_gapが証明する。
具体例
時間が大きく飛んだ直後の Choice A vs B:
1_003_600 ── 正常 Tick 成功 ──► last_settled_at = 1_003_600
░░ 障害! 10 時間チェーン停止。trader は position を閉じられない。mark が乖離し続ける ░░
1_039_600 ── 遅れてきた Tick
❌ Choice A (catch-up replay): 現在スナップショットで 10 回連続 settle
負け側 (longs) に毎時 cap 上限が 10 連発、閉じる手段はなかった → retroactive 強制
🟢 Choice B (openhl 採用): 現在スナップショットで 1 度だけ settle、見逃した 9 interval はスキップ
last_settled_at = 1_003_600 ──► 1_039_600 ✨ (1 ステップ)
Choice B では last_settled_at の遷移が常に 1 ステップ(10 時間 gap でも 10 秒 gap でも tick() は 1 回呼ばれ 1 回 advance)= path-independence。これがテスト 1 本で不変条件全体を pin できる理由。
失敗例(誤解)
「テストでは out.settlements のエントリが 1 つか確かめるべき」は誤り — settlement の個数は positions に依存するもので gap に依存しない。balanced_book() なら gap の長さに関わらず settlement は 2 つ。このテストの仕事は tick が 1 回 fire することの検証であって、settlement の個数でない。
ここまでで「no-catch-up = 1 度 settle して now に advance」は着地。ここからマイルストーンテスト 1 つを足してセクション3 を閉じる(production コード変更なし)。コードは完全形。
🛑 予測。 10 時間 gap 後に 現在 スナップショットで 10 tick を replay すると、一番痛い目に遭うのはどの trader?(答え: 負けていた側が 10 倍の打撃。gap 中 mark が index 上に乖離し続けたなら longs が overpay の状態。Choice A は現在 rate で 10 回 replay、全部 longs から charge。基準の負け側はすでにいたのに 10 倍払い、しかも gap 中は position を閉じられなかった(チェーンが止まっていた)。catch-up は動けなかった時間への retroactive な charge。Choice B はスキップして今から fresh に — revenue には悪いが trader にフェア。)
ステップで組み立てる
Step 1: マイルストーンテストを追加
capped_rate_when_premium_extreme の後に:
#[test]
fn no_catchup_after_long_gap() {
// If 10 intervals elapse before the next tick, we settle ONCE and
// advance to `now`. We don't replay 10 settlements with stale state.
let params = FundingParams::hyperliquid_default();
let mut clock = FundingClock::new(params, 1_000_000);
let way_later = 1_000_000 + 10 * 3600;
let out = clock.tick(way_later, MarkPrice(101), IndexPrice(100), &balanced_book());
assert!(out.is_some(), "elapsed >> interval → tick fires");
assert_eq!(clock.last_settled_at(), way_later);
// Immediately ticking again at the same moment does NOT settle.
let again = clock.tick(way_later, MarkPrice(101), IndexPrice(100), &balanced_book());
assert!(again.is_none(), "no duplicate settlement at same now");
}
2 パート: Part 1(長い gap でも settle は 1 度・now に advance — out.is_some() + last_settled_at() == way_later、見逃した interval を完全に忘れる)/ Part 2(同じ now で再 fire しない — again.is_none()、可能な限り最も厳しいテスト:時間が経たず変わるのは state だけ。last_settled_at == way_later なら guard way_later < way_later + 3600 が true で正しく None)。
Step 2: テスト実行
cargo test -p openhl-funding が 22 pass(手書き 20 + proptest 2)。セクション3 が閉じ、crates/funding/ が cd94137 に一致。
答え合わせ
cd ~/code/openhl-reference && git checkout cd94137
diff -u ~/code/my-openhl/crates/funding/ ./crates/funding/ --recursive
git checkout main
crates/funding/ 全体が cd94137 と一致、diff は空。セクション3 完了。
合格基準
cargo test -p openhl-funding が 22 pass。よくあるエラー: Part 1 out.is_none()(guard の比較方向間違い)/ Part 1 last_settled_at() != way_later(self.last_settled_at = now でなく += interval の catch-up 版 typo)/ Part 2 again.is_some()(Part 1 の tick で last_settled_at 未更新)。
まとめ(3行)
- 長い gap でも settle は 1 度・advance 先は
now(+intervalでない)= no-catch-up。replay は負け側に集中懲罰を、position を閉じる機会も与えず課す。funding は equilibration であって遡及強制でない。 - 同じ
nowでの second tick が最も厳しいテスト(時間が経たず変わるのは state だけ)— 遅れた tick でlast_settled_at更新忘れを全部捕まえる。 - catch-up ポリシーは clock の外(中間時点スナップショットで繰り返し
tick())。primitive はミニマル、policy は呼び出し側。22 tests でcrates/funding/がcd94137に一致。
次のレッスン(レッスン11)
capstone。新規コードなし。pipeline をスケッチし、先送りした 5 項目(oracle / balance 更新 / liquidation / multi-market / funding-as-EVM-event)を名指し、それぞれが将来どこに置かれるかをたどる。