レッスン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_max は i64::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 が -500(if >= 0 typo or クランプ忘れ — > 0 を確認)/ deposit_saturates_at_max が overflow panic(+= を saturating_add に)/ deposit_negative_is_noop が 50(if fee > 0 ガード忘れ — saturating add だけでは不変条件を守れない)。
まとめ(3行)
- state は履歴が effective なレイヤーに現れる — fund の balance は deposit/withdraw 系列の事実で snapshot では表現できない。計算パートは一方向の境界、保険基金パートが反対側を踏み出す。
balance ≥ 0はコードで enforce(型でない)— crate 内のi64型統一性がフィールド単位の符号なしに勝つ(不変条件が 1 行コードで済むなら)。- defensive code は境界(bridge/scanner/ADL が集まる点)に集中。
newは負をクランプ、depositはsaturating_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 する。