レッスン5 — Overflow 哲学 + 最初の proptest
問い
compute_premium の積が i128 を超えたら何が起きるべきか? panic? wrap? それとも別の何か? consensus システムでは「validator ごとに違う答え」も「1 台だけ halt」も許されない。唯一許される overflow 挙動は何で、それをどうテストするか?
原理(最小モデル)
- consensus で許される overflow は saturate だけ。 panic → validator が halt し network から fork off(liveness 喪失)。wrap → コンパイラ次第で「定義されているが誤った」値 → 誤値 fork or silent corruption。saturate → 全 validator が同じ bounded 値に合意。
- 符号を意識した saturation の override。
i64::try_fromは失敗を報告するが方向を教えない。unwrap_or(if v>0 {MAX} else {MIN})のインライン条件式が方向を復元(固定i64::MAXだとi128::MINが正に flip して符号が壊れる)。 - 手書きトレースと proptest は補完。 proptest のランダムサンプリングは
i128::MAX(2^129 通りの 1 点)にまず当たらない。境界は手書きでしか pin できない。proptest は interior、手書きは corner。 - テストすべきは実際に成立する不変条件、願望でない。 素朴な antisymmetry は magnitude も等しくと書きたくなるが整数除算が壊す。だから weaker な「符号が逆」をテストし、丸めの caveat はコメントに残す。
checked_mul+Resultは解決にならない。 エラーは最終的に bridge に届き、bridge の選択肢は revert(fork) / skip(silent inconsistency) / cap で settle の 3 つ。最後のものは saturate がそのまま実現する。
具体例
「整数が収まらなかった」の失敗モードを validator から見た帰結で並べる:
| モード | Rust 上の挙動 | network への影響 | 判定 |
|---|---|---|---|
Panic (* debug) | スレッド halt | validator 1 台が consensus から永久脱落、network は気づかず前進 | ❌ 自ら fork off(liveness 喪失) |
Wrap (* release) | silent に modulo wrap | 最適化次第で各 validator が別々の誤値、または全員一致で誤値合意 | ❌ 検出不能な fork or silent corruption |
Saturate (saturating_mul) | i128::MAX/MIN に clamp | 全 validator が同じ bounded 値で合意・前進、経済的には capped settlement | ⭕ liveness 維持 — 唯一の選択肢 |
失敗例(誤解)
「境界(i128::MAX 等)は proptest がカバーするから手書きテスト不要」は誤り — proptest のデフォルト戦略は入力空間に uniform にサンプルし、i128::MAX は 2^129 通りの 1 点なのでランダムに当たる確率は実質ゼロ。境界には generator のランダムウォークが届かない特定値を狙い撃つ手書きトレースが要る。
ここまでで「saturate が唯一の consensus-safe な overflow」は着地した。ここから新関数なしで proptest を 1 つ足す(production コード変更なし — メンタルモデルが本体)。コードは完全形。
🛑 予測。
compute_premiumで panic が起きれば validator は halt する。なぜそれが単一ノード障害でなく chain fork になるか?(答え: 他の validator は halt したノードを置き去りに前進する。再起動時には chain head が何ブロックも先で、halt したブロックでの local state が network の view と食い違い sync できない — 自ら network から fork off した形。saturate は validator を lockstep のまま保つ。)
ステップで組み立てる
Step 1: テストモジュールに proptest サポートを追加
compute.rs のテストモジュール冒頭をこう拡張:
#[cfg(test)]
mod tests {
use super::*;
use openhl_clob::AccountId;
use proptest::prelude::*;
fn pos(account: u64, size: i64) -> Position {
Position {
account: AccountId(account),
size: crate::types::PositionSize(size),
}
}
// ... レッスン4 の 4 unit test ...
}
use openhl_clob::AccountId と pos helper はレッスン7 の apply_funding テストで使うが、テストモジュールの import を安定化させるため今入れる。use proptest::prelude::* が proptest!/prop_assert_eq!/strategy combinator を scope に持ち込む。
Step 2: Antisymmetry proptest を追加
4 unit test の後、閉じ } の前に:
proptest! {
/// Premium symmetry: swapping mark and index flips the sign.
/// (Up to integer division rounding, the magnitude is the same — we
/// allow off-by-one to absorb the rounding-toward-zero asymmetry.)
#[test]
fn premium_is_antisymmetric_in_mark_index(
mark in 1u64..1_000_000,
index in 1u64..1_000_000,
) {
let a = compute_premium(MarkPrice(mark), IndexPrice(index));
let b = compute_premium(MarkPrice(index), IndexPrice(mark));
// Cross-multiplied magnitudes must be equal: |a| / mark == |b| / index
// (i.e., the proportional dislocation is the same both ways).
// We test the weaker property that the signs are opposite (or both zero).
if mark == index {
prop_assert_eq!(a, Premium(0));
prop_assert_eq!(b, Premium(0));
} else {
prop_assert!(a.0.signum() == -b.0.signum());
}
}
}
signum() は符号を -1/0/+1 で返す。mark in 1u64..1_000_000 が戦略(デフォルト 256 ケース)。素朴な antisymmetry(magnitude も等しい)は整数除算の丸めで破れるので、weaker な「符号が逆 or 両方ゼロ」をテストし、丸めの caveat をコメントに残す。0 を除外するのは index==0 がレッスン4 でカバー済みかつ「両方ゼロで符号反対」が破れるから。
Step 3: テスト実行
cargo test -p openhl-funding
running 5 tests
test compute::tests::premium_is_antisymmetric_in_mark_index ... ok
... (レッスン4 の 4 つ) ...
test result: ok. 5 passed; ...
256 個のランダム (mark, index) ペアで antisymmetry が満たされる。PROPTEST_VERBOSE=1 で「passed 256 cases」や失敗時の「shrunk to ...」が見える。
答え合わせ
cd ~/code/openhl-reference && git checkout cd94137
diff -u ~/code/my-openhl/crates/funding/src/compute.rs ./crates/funding/src/compute.rs
git checkout main
compute.rs は compute_premium + helper + 4 premium テスト + antisymmetry proptest + テストモジュール imports/helper まで一致(compute_rate/apply_funding はレッスン6/7)。
合格基準
cargo test -p openhl-funding が 5 pass。よくあるエラー: use proptest::*(::prelude::* でない)でマクロ未解決 / prop_assert_eq! を assert_eq! と typo(shrink されない)/ mark==index を else に流して符号テスト失敗。
まとめ(3行)
- consensus 上で bounded behavior を提供する overflow 挙動は saturate だけ(panic=halt fork、wrap=誤値 fork)。
saturate_i128_to_i64のunwrap_orは符号依存にする(固定値だとi128::MINが正に flip)。 - テストするのは実際に成立する不変条件(符号が逆)であって願望(magnitude も等しい)でない。整数除算の丸めで破れる property はコメントに caveat を残す。
- 手書きトレースは境界(generator が届かない
i128::MAX等)を pin、proptest は内部 property(antisymmetry を 256 ケース)を pin。補完的で冗長でない。
次のレッスン(レッスン6)
compute_rate を追加(Premium + FundingParams → FundingRate)。設計判断 3 つ(divisor==0 で funding 無効化 / 割ってから clamp / rate_cap を絶対値 clamp)と unit test 5 つ。