FABRKNT
Step 5. Liquidation:レバレッジ環境における非単調性の発見と清算エンジンの構築
保険基金
レッスン 9 / 14·CONTENT25 分50 XP
コース
Step 5. Liquidation:レバレッジ環境における非単調性の発見と清算エンジンの構築
レッスンの役割
CONTENT
順序
9 / 14

レッスン8 — InsuranceFund — クレートが純粋でなくなる地点

問い

計算パートの compute.rs は pure だった(どの関数も引数からの決定的投影で、いつでも再計算できた)。だが insurance fund の balance は 履歴 の事実(deposit/withdraw の系列で変わる)— snapshot 1 つでは表現できない。crate に初めて state を導入し、複数の呼び出し側にまたがって balance ≥ 0 を保つには?

原理(最小モデル)

  • state はコードに現れるのは入力から再導出できなくなる地点だけ。 fund の balance は「これまで起きた全 deposit/withdraw」の事実 — snapshot(1 アカウント 1 瞬間)では表現できない、genuinely state。
  • balance ≥ 0 は型不変条件。 フィールドは i64(crate 全体と算術型を揃える)だが、不変条件はコードで守る — 型システムでないnew(-500)→0 にクランプ、deposit(-50)→no-op、withdraw→0 で飽和。すべての public メソッドを「不変条件を保つ遷移」として書く。
  • 境界の防御 vs 関数の防御。 compute は入力を信用(in-crate コードから、入力は構築済み)。InsuranceFund境界そのもの(bridge/scanner/ADL が異なるレイヤーから呼ぶ)— 多くの呼び出し側を集約する境界でこそ defensive coding が意味を持つ。
  • consensus state では saturating 演算。 deposit+ でなく saturating_add+ は debug で overflow panic(1 validator crash → fork)、release で silent wrap(validator ごとに異なる i64 → fork)。saturating_add は全ビルドで i64::MAX に clamp(全 validator が同じ値)。

具体例

なぜ state がここに現れるか:

   計算パート — pure compute (compute.rs)
     margin_health/margin_ratio/close_order_spec — すべて入力からの投影、永遠に再計算可能
                          │
   保険基金パート — state machine (insurance.rs)
     InsuranceFund { balance: i64 }  ← fund が蓄積
       .deposit(fee) / .withdraw_shortfall(amount) / .balance()
     balance は *履歴* の事実。(deposit, withdraw) の系列が違えば balance も違う(最終呼び出しの引数が同一でも)
                          │
   スキャナパート — scanner (scanner.rs, L11-12)
     InsuranceFund を所有し liquidation event ごとに deposit/withdraw_shortfall を呼ぶ

pure compute は返す。stateful なモジュールは蓄積する。

失敗例(誤解)

pub fn new(initial: u64) にすれば不変条件は型で守られコードで守る必要がない」は誤り、問題 3 つ: (1) crate 他箇所は i64 を fungible amount に使う(1 境界だけ型を変えると全呼び出し地点でキャスト)。(2) i64 を使う validator コードが fee 計算で u64::try_from の checked を要求される(saturation で足りる所に panic を植える)。(3) balance ≥ 0 はどのみちコードで enforce されるので型レベル安全性は屋上屋。周辺の型規律に合わせ、crate 他箇所と同じ場所で不変条件を防御する。


ここまでで「state は履歴が effective なレイヤーに現れる」は着地した。ここから insurance.rs の半分(construction + deposit)を着地させる(withdraw は レッスン9)。コードは完全形。

🛑 予測。 balance 1 つの state machine で複数の呼び出し側にまたがり balance ≥ 0 を保つには、new(initial)/deposit(fee)/withdraw(amount) のどこで何を防御する?(答え: 3 つすべて。new は負の初期値を 0 にクランプ。deposit は負の fee を no-op(素通しすると fund がこっそり drain)。withdraw は負の shortfall を amount=0 の Covered 扱い + balance 超を 0 まで drain して残りを surface。public API が複数レイヤーから呼ばれるので、bad な呼び出し 1 つで型不変条件を破ってはならない。L8 は new/deposit、L9 が withdraw。)

ステップで組み立てる

Step 1: src/insurance.rs を作成(module doc)

//! Insurance fund state machine (保険基金パート).
//!
//! The insurance fund is the venue's pooled buffer that absorbs the
//! deficit when a Liquidatable account's close turns underwater, or when
//! an Underwater account is liquidated outright. It accumulates the
//! liquidation fees that solvent closes pay in. The scanner (スキャナパート) will
//! own an [`InsuranceFund`] and call its deposit / withdraw operations
//! from the per-account liquidation loop.
//!
//! ### Why stateful here when the rest of the crate is pure
//!
//! Margin classification, fee math, and close-outcome computation
//! ([`crate::compute`]) are pure functions over per-account snapshots —
//! they can be re-evaluated lossless at any time. The insurance fund's
//! balance, in contrast, accumulates effects from many liquidation events
//! across many blocks; it is genuinely state. The shape mirrors
//! `openhl_funding::clock` — a small state machine, owned by the bridge,
//! mutated only on well-defined boundary events.
//!
//! ### Sign discipline
//!
//! The balance is `i64` internally for arithmetic uniformity with
//! [`crate::compute`], but the type invariant is **`balance ≥ 0`** —
//! every public operation preserves it. Withdrawals that exceed the
//! balance saturate at 0 and surface the unfilled portion via
//! [`WithdrawOutcome`].
//!
//! ### Deposit semantics
//!
//! `deposit` accepts a non-negative fee amount. Negative deposits are
//! treated as zero (saturating semantics, no panic) — defensive coding
//! against accidental misuse from the caller. Saturating-add caps at
//! `i64::MAX` for network-pathological accumulated balances.

冒頭は型でなく 役割 から(最初の 1 文で safety-net cascade のどこに座るか分かる)。スキャナパート/ADL を先回り引用(計画された arc の一部)。Sign-discipline は型が enforce しない 不変条件の話。openhl_funding::clock を錨に(既知パターンを指す)。

Step 2: InsuranceFund 構造体とコンストラクタ

/// The insurance fund's accumulating balance.
///
/// Owned by the bridge (スキャナパート+), exposed via deposit / withdraw
/// operations that maintain the `balance ≥ 0` invariant.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct InsuranceFund {
    balance: i64,
}

impl InsuranceFund {
    /// Create a fund with the given initial balance.
    ///
    /// Negative initial balances are clamped to zero — defensive against
    /// accidental misuse. A negative initial balance can't represent any
    /// physical state of the fund and would violate the type invariant.
    #[must_use]
    pub const fn new(initial_balance: i64) -> Self {
        Self {
            balance: if initial_balance > 0 {
                initial_balance
            } else {
                0
            },
        }
    }

    /// An empty fund; equivalent to [`InsuranceFund::new(0)`].
    #[must_use]
    pub const fn empty() -> Self {
        Self { balance: 0 }
    }

    /// Current balance of the fund. Always `≥ 0`.
    #[must_use]
    pub const fn balance(&self) -> i64 {
        self.balance
    }
}

impl Default for InsuranceFund {
    fn default() -> Self {
        Self::empty()
    }
}

フィールドは private(balance ≥ 0 を enforce する仕組みのすべて — public だと fund.balance = -1 で契約を破れる)。new は負を 0 にクランプ(Result でも panic でもない — 借金を持つ fund は fund でない、最も近い valid へ)。empty()(意図が読める + Default が呼ぶ先)。全メソッド const fn(mutation しない処理は const-evaluable)+ #[must_use]Default は手動 impl で Self::empty()(意図を明示)。

Step 3: WithdrawOutcome enum scaffold

レッスン9 の変更を impl への純粋追加で済むよう、enum を今宣言する(impl の上に):

/// Outcome of attempting to absorb a shortfall via
/// [`InsuranceFund::withdraw_shortfall`].
///
/// The three variants are exactly the three transitions across the
/// "Layer 2 → Layer 3" boundary in the safety-net cascade:
///   - [`WithdrawOutcome::Covered`] — the fund had enough; Layer 2
///     fully absorbed the deficit.
///   - [`WithdrawOutcome::PartiallyDrained`] — the fund drained to
///     zero and covered part of the shortfall; the remainder must
///     escalate to Layer 3 (ADL).
///   - [`WithdrawOutcome::Depleted`] — the fund was already empty
///     before the call; nothing covered, full shortfall escalates.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WithdrawOutcome {
    /// Fund had enough balance to cover the request in full.
    Covered {
        /// Amount paid out of the fund (= requested shortfall).
        amount: i64,
    },
    /// Fund partially covered the shortfall before draining to zero.
    PartiallyDrained {
        /// Amount actually paid out (= fund's prior balance).
        amount: i64,
        /// Remaining shortfall that the caller must escalate to ADL.
        unfilled: i64,
    },
    /// Fund was already empty; nothing was paid out.
    Depleted {
        /// Full shortfall that must escalate to ADL.
        unfilled: i64,
    },
}

enum の存在自体が public surface の物語(メカニズムの前に語彙を見せる)。各 variant が自分の payload を運ぶ(amount/unfilled)。doc の Layer 2 → Layer 3 boundary が cascade アーキを明示(margin=Layer1 / fund=Layer2 / ADL=Layer3)。

Step 4: deposit メソッド

impl InsuranceFund に追加:

    /// Credit the fund with a fee. Returns the new balance.
    ///
    /// Negative inputs are treated as a no-op (defensive against the
    /// caller passing a signed value where the contract expects a credit).
    /// Saturates at `i64::MAX` for network-pathological accumulated
    /// balances.
    pub fn deposit(&mut self, fee: i64) -> i64 {
        if fee > 0 {
            self.balance = self.balance.saturating_add(fee);
        }
        self.balance
    }

fee > 0 strict(ゼロは no-op、「意味があるか」テスト)。負は silent に無視(panic は consensus に致命的、Result は全呼び出し側に unwrap/threading を強要)。saturating_add+ でない — debug panic / release wrap はどちらも fork)。新 balance を返す(log しやすい、HashMap::insert 形)。

Step 5: lib.rs に配線

pub mod compute;
pub mod insurance;
pub mod types;

pub use compute::{
    account_equity, close_order_spec, margin_health, margin_ratio, notional_value, unrealized_pnl,
};
pub use insurance::{InsuranceFund, WithdrawOutcome};
pub use types::{
    AccountSnapshot, CloseOrderSpec, LiquidationParams, MarginHealth, MarginRatio, MARGIN_SCALE,
};

型と enum を一度に re-export(利用者は呼ぶものを import、レッスン9 で lib.rs に触らない)。

Step 6: 9 個の unit test

#[cfg(test)]
mod tests {
    use super::*;

    // ─── construction ──────────────────────────────────────────────

    #[test]
    fn new_with_positive_balance() {
        let f = InsuranceFund::new(1_000);
        assert_eq!(f.balance(), 1_000);
    }

    #[test]
    fn new_with_zero_is_empty() {
        let f = InsuranceFund::new(0);
        assert_eq!(f.balance(), 0);
    }

    #[test]
    fn new_with_negative_clamps_to_zero() {
        let f = InsuranceFund::new(-500);
        assert_eq!(f.balance(), 0);
    }

    #[test]
    fn empty_is_zero() {
        let f = InsuranceFund::empty();
        assert_eq!(f.balance(), 0);
    }

    #[test]
    fn default_is_empty() {
        let f = InsuranceFund::default();
        assert_eq!(f.balance(), 0);
    }

    // ─── deposit ───────────────────────────────────────────────────

    #[test]
    fn deposit_accumulates() {
        let mut f = InsuranceFund::empty();
        assert_eq!(f.deposit(100), 100);
        assert_eq!(f.deposit(250), 350);
        assert_eq!(f.balance(), 350);
    }

    #[test]
    fn deposit_zero_is_noop() {
        let mut f = InsuranceFund::new(100);
        assert_eq!(f.deposit(0), 100);
    }

    #[test]
    fn deposit_negative_is_noop() {
        // Defensive: negative deposits must not silently drain the fund.
        let mut f = InsuranceFund::new(100);
        assert_eq!(f.deposit(-50), 100);
        assert_eq!(f.balance(), 100);
    }

    #[test]
    fn deposit_saturates_at_max() {
        let mut f = InsuranceFund::new(i64::MAX - 10);
        assert_eq!(f.deposit(1_000), i64::MAX);
    }
}

セクション区切りでグループ化。new_with_negative_clamps_to_zero は防御 surface を直接テスト(リファクタが「デッドコード」を消したら捕まえる)。deposit_negative_is_noop// Defensive マーカー。deposit_saturates_at_maxi64::MAX - 10 から(saturation が実際に発火する余地)。

Step 7: テスト実行

cargo test -p openhl-liquidation が 33 pass(compute 24 + insurance 9)。

答え合わせ

cd ~/code/openhl-reference && git checkout 260883b
diff -u ~/code/my-openhl/crates/liquidation/src/insurance.rs ./crates/liquidation/src/insurance.rs
diff -u ~/code/my-openhl/crates/liquidation/src/lib.rs ./crates/liquidation/src/lib.rs
git checkout main

insurance.rs は 260883b の 118 行目まで一致(withdraw_shortfall/proptest は レッスン9)。lib.rs は pub mod + InsuranceFund/WithdrawOutcome re-export について byte-for-byte 一致。

合格基準

cargo test -p openhl-liquidation が 33 pass。よくあるエラー: new_with_negative_clamps_to_zero-500if >= 0 typo or クランプ忘れ — > 0 を確認)/ deposit_saturates_at_max が overflow panic(+=saturating_add に)/ deposit_negative_is_noop50if fee > 0 ガード忘れ — saturating add だけでは不変条件を守れない)。

まとめ(3行)

  • state は履歴が effective なレイヤーに現れる — fund の balance は deposit/withdraw 系列の事実で snapshot では表現できない。計算パートは一方向の境界、保険基金パートが反対側を踏み出す。
  • balance ≥ 0 はコードで enforce(型でない)— crate 内の i64 型統一性がフィールド単位の符号なしに勝つ(不変条件が 1 行コードで済むなら)。
  • defensive code は境界(bridge/scanner/ADL が集まる点)に集中。new は負をクランプ、depositsaturating_add + 負を no-op(panic も wrap も fork を招く)。

次のレッスン(レッスン9)

withdraw_shortfall で insurance fund モジュールの drain path を閉じる。レッスン8 で宣言した WithdrawOutcome が variant を返すメソッドを得る(Covered / PartiallyDrained / Depleted = Layer2→Layer3 境界の 3 遷移)。4 つの proptest が保存則を enforce する。