レッスン3 — 6 つの nuanced absorption テスト
問い
L2 で 5 つの degenerate-path テストを ship した。では execute_adl の interesting な中域(複数 winner が deficit を分け合う、tie が break される、loser と flat が winner と共存する)を、どんな入力で証明すればいいのか?
原理(最小モデル)
6 テストは 2 軸マトリクス {single winner, multiple winners} × {full absorb, partial absorb, mixed eligibility} の各セルだ。
- 各テストは コメントに math を書いた hand-computed worked example(black-box assertion ではない)。
- テスト名は
adl_<scenario>_<expected_outcome>規約 —cargo test --listが関数の 仕様 になる。 - 進展は simple → compound: single-winner edge → multi-winner ordering → tiebreaker → mixed-eligibility integration。
具体例
A(coll 100、score 10_000)と B(coll 50、score 26_666)、deficit = 80:
sort → [B, A] (B のほうが高 score)
B haircut = min(80, 100) = 80 → remaining = 0 → break
A は loop body に入らない → record なし
report.records.len() == 1 (B のみ)
失敗例(誤解)
「1 関数に 6 テストは過剰」は誤り。execute_adl は 5 phase × 複数の入力次元(deficit size / candidate count / score 分布 / eligibility mix)を持つ。マトリクスは 6 よりずっと大きく、16 unit test は「最小カバレッジ」に近い。
ここまでで「マトリクスのどのセルを埋めるか」は着地した。ここから 6 テストを worked example として組み立てる。
🛑 予測。
execute_adl([A_score_10000, B_score_26666], deficit=80)で A の record が zero-haircut として含まれず、record 自体が無い のはなぜか?
(答え: Phase 4 の if remaining <= 0 break; が、A が処理される 前 に loop を exit するから。B が 80 で haircut された後 remaining = 0 → break → A は loop body に入らない。break が report の record count を「実際に force-close された数」として意味あるものに保つ。)
ステップで組み立てる(既存 mod tests に 6 テスト append)
Test 1: full-PnL haircut で payout がゼロ
#[test]
fn adl_single_winner_partial_haircut_at_full_pnl() {
// PnL = 100, deficit = 100 → full haircut, payout = 0.
let candidates = vec![snapshot(1, 1, 100, 100)];
let report = execute_adl(&candidates, MarkPrice(200), 100);
let rec = &report.records[0];
assert_eq!(rec.haircut, 100);
assert_eq!(rec.pnl_paid, 0);
assert_eq!(report.deficit_remaining, 0);
}
haircut == pnl_gross の boundary。winner がすべてを支払い payout = 0、だが deficit はちょうど covered で remaining = 0。
Test 2: Deficit が winner の PnL を超える — remainder が propagate
#[test]
fn adl_single_winner_exhausted_with_remaining_deficit() {
// PnL = 100, deficit = 250 → full haircut, 150 remains.
let candidates = vec![snapshot(1, 1, 100, 100)];
let report = execute_adl(&candidates, MarkPrice(200), 250);
assert_eq!(report.records.len(), 1);
assert_eq!(report.deficit_absorbed, 100);
assert_eq!(report.deficit_remaining, 150);
}
min(250, 100) = 100 — winner の PnL が haircut を cap する。保存則 100 + 150 == 250。deficit_remaining = 150 が bridge への「cover できなかった」シグナル(policy は bridge の責任)。
Test 3: Multi-winner ranking — より高い leverage が勝つ
#[test]
fn adl_multiple_winners_in_score_order() {
// Two long winners; the higher-leverage one ranks first.
// A: coll 100, pnl 100 → score 10_000 (per レッスン1's score derivation)
// B: coll 50, pnl 100 → score 26_666
// deficit = 80 → B haircut = 80, pnl_paid = 20; A untouched.
let candidates = vec![snapshot(1, 1, 100, 100), snapshot(2, 1, 100, 50)];
let report = execute_adl(&candidates, MarkPrice(200), 80);
assert_eq!(report.records.len(), 1, "deficit smaller than B's pnl → only B");
assert_eq!(report.records[0].account, AccountId(2));
assert_eq!(report.records[0].haircut, 80);
}
同じ PnL でも B の高 leverage が score を押し上げ rank 1 に。score-math コメントがテストの spec。A の record が absent なのは予測 callout の payoff。
Test 4: Deficit が rank 1 を drain し rank 2 を partial に cover
#[test]
fn adl_drains_first_winner_then_partially_second() {
// Both winners contribute to a large deficit.
// A: coll 100, pnl 100 → score 10_000, rank #2
// B: coll 50, pnl 100 → score 26_666, rank #1
// deficit = 150 → B haircut = 100 (full), A haircut = 50 (partial)
let candidates = vec![snapshot(1, 1, 100, 100), snapshot(2, 1, 100, 50)];
let report = execute_adl(&candidates, MarkPrice(200), 150);
assert_eq!(report.records.len(), 2);
assert_eq!(report.records[0].account, AccountId(2)); // B first
assert_eq!(report.records[0].haircut, 100);
assert_eq!(report.records[0].pnl_paid, 0);
assert_eq!(report.records[1].account, AccountId(1)); // A second
assert_eq!(report.records[1].haircut, 50);
assert_eq!(report.records[1].pnl_paid, 50);
assert_eq!(report.deficit_absorbed, 150);
assert_eq!(report.deficit_remaining, 0);
}
Test 3 と同じ入力、大きい deficit(150)で quota exhaustion を isolate。B 完全 haircut(100) → A residual(50)。保存則 100 + 50 + 0 == 150。順序 assertion がソート規律を将来の refactor から守る。
Test 5: Equal score → ascending account_id が勝つ
#[test]
fn adl_tiebreaker_by_account_id_ascending() {
// Two structurally identical winners. Tiebreaker is account_id
// ascending → smaller account_id is force-closed first.
let candidates = vec![
snapshot(7, 1, 100, 50), // identical except account
snapshot(3, 1, 100, 50),
];
let report = execute_adl(&candidates, MarkPrice(200), 50);
assert_eq!(report.records.len(), 1);
assert_eq!(report.records[0].account, AccountId(3));
}
account_id 以外を identical に揃え tiebreaker だけを isolate。入力 vector で 2 番目に渡した AccountId(3) が勝つ — 入力順ではなくソート規律(then_with ascending)が matter する。
Test 6: Mixed population で loser と flat が filter される
#[test]
fn adl_does_not_touch_losers_or_flats() {
let candidates = vec![
snapshot(1, 1, 100, 50), // winner @ mark 200
snapshot(2, 1, 100, 1_000), // loser? — same mark applies, see below
snapshot(3, 0, 100, 1_000), // flat
];
// All evaluated at mark = 200 → only acct 1 is profitable.
let report = execute_adl(&candidates, MarkPrice(200), 10);
assert_eq!(report.records.len(), 1);
assert_eq!(report.records[0].account, AccountId(1));
}
acct 3 は flat → Phase 2 で filter_map が drop。acct 2 は profitable だが collateral が高く低 score → deficit = 10 が acct 1 で exhaust され Phase 4 の break で touch されない。1 テストが 2 つの防衛 layer(eligibility filter + quota break)を同時に証明する。 コメントの // loser? は drafting 履歴を honest に残したもの。
テストの進展を俯瞰
simple ─────────────────────────────────────────► compound
Test 1,2 ── single winner edge ──── Phase 4 (boundary)
Test 3,4 ── multi-winner ordering ─ Phase 3 (sort) + Phase 4 (break)
Test 5 ── tiebreaker isolation ── Phase 3 (then_with, 決定論性)
Test 6 ── mixed integration ───── Phase 2 + Phase 4 composition
答え合わせ
cd ~/code/openhl-reference && git checkout d66b44a
diff -u ~/code/my-openhl/crates/liquidation/src/adl.rs ./crates/liquidation/src/adl.rs
6 nuanced テストまで参照実装と一致するはず(残り 5 proptest は L4)。
合格基準
cargo test -p openhl-liquidation adl::tests::adl_
→ 16 テスト pass(L1 の 5 + L2 の 5 + L3 の 6)。Full な ADL unit-test マトリクスが特定入力に対してカバーされる。
まとめ(3行)
- 6 テストは 2 軸マトリクスのセル — single/multi winner × full/partial/mixed。
- 期待値はコメントに math-walk で hand-compute(assertion を proof に変える)。
- Test 6 が eligibility filter と quota break の 2 層を 1 つの mixed 入力で証明する。