FABRKNT
Step 4. Funding:決定論的数学パイプラインと Funding ステートマシンの構築
Clock state machine
レッスン 10 / 12·CONTENT30 分60 XP
コース
Step 4. Funding:決定論的数学パイプラインと Funding ステートマシンの構築
レッスンの役割
CONTENT
順序
10 / 12

レッスン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_fundingcompute_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_signsapply_funding のテストと重複、out.rate だけ確認すれば十分」は誤り — このテストの要点は compositionapply_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_rate125_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 スキップより悪いか(負け側に集中懲罰)を説明する。