Day 075 — 2026-10-15 | マクロ① macro_rules! ⑳ 入れ子の状態(Alarm ⊃ 鳴動中/消音中)を 4 つの書き方で作り、同じ仕様に揃うかを全数で確かめる:平らな表・中身つき表・statig 0.4.1 の #[superstate]・表の解釈器の 4 実装は 9,331 本 × 2 のイベント列で 0 件の食い違い。差が出たのは「誤りを入れたとき」——11 通りの誤りのうち 6 件はビルドが通り(うち 4 件は警告も 0 件)、等価性テストだけが 204〜2,078 本の食い違いで止めた。statig は exit を 1 か所に書くので同じ誤りが書けない代わりに、依存 9 クレート・クリーンビルド 0.26 s → 2.8 s・引数名 event 以外は状態の中身として扱われる罠がある

2026-10-15 🟣 上級者 / Phase 3 設計・アーキテクチャ マクロ①(宣言的マクロ macro_rules!)
フェーズ: 上級者 (Phase 3) | 問題タイプ: 設計・アーキテクチャ | テーマ: マクロ①(宣言的マクロ macro_rules!)

📚 背景知識(読んでから問題へ)

Day 064〜074 で育ててきた fsm! は平らな表です。状態は Locked, Unlocked, Alarm のような名前の並びで、1 行が「(状態, イベント) → 行き先 [アクション]」を表します。

実務の状態機械はすぐに入れ子になります。警報(Alarm)の中に「鳴動中(Ringing)」と「消音中(Muted)」があり、どちらにいても「リセットで施錠へ戻る」「失敗はログだけ残す」は共通、という形です。UML のステートチャート(Harel, 1987)はこれを上位状態(superstate)と、状態に入る・出るときに必ず走る entry/exit アクションで表します。

入れ子が平らな表で辛くなる理由は、行数よりアクションの合成にあります。

  • 鳴動中から出るときは必ず音を止める(exit Ringing = StopSound)
  • 警報から出るときは必ず警報の終了を記録する(exit Alarm = ClearAlarm)
  • すると「リセット」1 つでも、鳴動中からなら [StopSound, ClearAlarm]、消音中からなら [ClearAlarm] と、どの下位状態から出たかで出るアクションが変わる

平らな表では、この合成を人が行ごとに手で書きます。書き忘れても型は正しいので、コンパイラには止める手段がありません。Rust の型システムが守るのは「値が正しい形をしているか」で、「この遷移で exit を忘れていないか」は型に現れないからです。Day 074 の K7(捕獲の事故、出力 200・警告 0 件)と同じ「黙って通る」種類の誤りです。

statig(crates.io 累計 4,664,592 DL、直近 90 日 256,733 DL、最終更新 2025-07-05、2026-10-15 に crates.io API で取得)は、この合成をライブラリが行います。README の遷移の節(ローカルのレジストリ statig-0.4.1/README.md、2026-10-15 に読んだ)はこう書いています。

"statig will perform a transition sequence by traversing the graph from the current source state to the target state by taking the shortest possible path. When this path is going upwards from the source state, every state that is passed will have its exit action executed."

今日は設計問題です。fsm! に入れ子を入れるか、statig に任せるか、表をデータにして解釈するか——を、実装して測って決めます。

📝 問題

環境: rustc 1.99.0(stable)と 1.85.1、Apple M5 Pro・48GB(sysctl で確認。Day 074 までの「M1・8GB」は今日の機械と違う)、statig =0.4.1。

仕様(v1)

#いる状態イベント条件行き先出るアクション(順番どおり)
1LockedUnlockUnlockedBeep
2LockedFailfailures >= 2Alarm.RingingLog(f+1), Notify(entry Alarm), SoundAlarm(entry Ringing)
3LockedFailLocked(留まる)Log(f+1)
4UnlockedLockLocked—
5Alarm.RingingMuteAlarm.MutedStopSound(exit Ringing)
6Alarm.MutedTimeoutAlarm.RingingSoundAlarm(entry Ringing)
7Alarm.*(どちらでも)ResetLocked(Ringing なら StopSound), ClearAlarm(exit Alarm)
8Alarm.*(どちらでも)Fail留まるLog(f+1)
—それ以外無視—

entry/exit: Alarm = Notify/ClearAlarm、Ringing = SoundAlarm/StopSound、Muted は無し。

仕様(v2): 下位状態を 1 つ足す

Alarm に Escalated(警備員呼び出し済み) を足す。entry Escalated = CallGuard、exit は無し。(Ringing, Timeout) → Escalated(出るのは StopSound, CallGuard)。Reset・Fail は 7・8 行と同じく Alarm の共通動作に従う。

比べる 4 案

案書き方
A平らな flat_fsm!(Day 073 の fsm! と同じ。AlarmRinging・AlarmMuted を別の状態として並べる)
B中身つきの nested_fsm!(Alarm(Sub)。行の左辺に Alarm(_)・Alarm(s) などのパターンを書ける)
Cstatig 0.4.1(#[superstate] fn alarm、entry_action/exit_action、Super で上位へ委ねる)
D表をデータ(&[Rule])で持ち、実行時に親をたどって LCA(最も近い共通の祖先)まで exit・そこから entry を実行する 47 行の解釈器

Q1: 4 案は同じ仕様になるか、どう確かめるか

4 案が同じ振る舞いだと主張するには何を比べればよいかを決め、実際に比べてください。「何本のイベント列を、何と突き合わせたか」を数で答えます。

Q2: v1 → v2 で何か所を書き換えるか

Escalated を足すとき、各案で書き換える行数と、同じ事実(exit Ringing = StopSound など)を何か所に書いているかを数えてください。

Q3: 誤りを 1 つずつ入れて、誰が止めるか

次の 11 通りの誤りを入れたとき、(a) コンパイラ(エラー/警告)と (b) Q1 の等価性テストのどちらが止めるか、それとも誰も止めないかを予想してから確かめてください。

#案入れる誤り
M1A(AlarmRinging, Reset) の StopSound を書き忘れる
M2B(Alarm(Sub::Ringing), Reset) と (Alarm(_), Reset) の順番を入れ替える
M3BAlarm(Sub::Ringing) を Alarm(Ringing) と書く
M3bBAlarm(Sub::Ringng) と誤字
M3cBAlarm(Ringng) と誤字(Sub:: も省く)
M4Cringing の _ => Super を _ => Handled にする
M5Cringing の _ => Super を消す
M6A(v2)(AlarmEscalated, Fail) 行を書き忘れる
M7B(v2)(Alarm(Sub::Ringing), Timeout) を (Alarm(_), Timeout) と広く書く
M8Csuperstate = "alarm" を "alarn" と誤字
M9C状態の引数名を event ではなく ev にする

Q4: ADR を書く

fsm! を使っているプロダクト(ドアの制御・医療機器の警報・決済の状態など)に入れ子が必要になった、という前提で、A〜D のどれを採るかを 採らなかった案・その理由・前提が変わったら判断がどう変わるか つきで決めてください。

💡 ヒント

ヒント1(方向性)
  • 「同じ仕様か」は、仕様書と目で見比べても確かめられません。4 案のうち 1 つを参照実装にし、他の 3 案を全入力で突き合わせるのが確実です。参照実装は、他の 3 案と書き方が最も違うものを選びます(同じ間違いを共有しにくい)
  • イベントは 6 種類、状態は高々 5 つです。長さ 5 までの全列でも 6⁰+6¹+…+6⁵ = 9,331 本しかありません
ヒント2(アプローチ)
  • 4 案に共通の口(trait Machine { fn new(failures) ; fn step(&mut self, ev) -> (&'static str, Vec<Act>) })を作ると、比較は run::<A>(seq) == run::<D>(seq) の 1 行です
  • B の nested_fsm! で行き先を $to:expr で受けると、直後の [ が follow-set 違反になります(Day 066 の / と同じ)。$to:ident $( ( $($sub:tt)* ) )? で受けます
  • statig は #[state] 関数の引数を名前で見分けます。README の Context 節: "By default this context is mapped to the context argument of the method." event も同じです。それ以外の名前の引数は何として扱われるでしょう(README の "State-local storage" の節)
  • Alarm(Ringing) の Ringing は、パターンの中では「変種」か「新しい束縛」かのどちらかです。どちらになるかは名前解決で決まります
  • 誤りを入れる実験は、作業コピーに置換を 1 つ当てて cargo test するだけのスクリプトで足ります
ヒント3(コード骨格)
// D の核: 現在の葉から親へ規則を探す → 遷移なら LCA まで exit、LCA から entry
pub fn step(cur: N, ev: Ev, c: &Ctx) -> (N, Vec<Act>) {
    for node in chain(cur) {                       // [Ringing, Alarm]
        if let Some(r) = RULES.iter().find(|r| r.from == node && r.ev == ev && (r.guard)(c)) {
            let mut out = (r.acts)(c);
            let Some(to) = r.to else { return (cur, out) };      // Handled
            let (up, down) = (chain(cur), chain(to));
            let lca = up.iter().copied().find(|n| down.contains(n));
            // up を lca の手前まで exit、down を lca の手前から逆順に entry
            return (to, out);
        }
    }
    (cur, vec![])
}

✅ 模範解答

Q1 の答え: 9,331 本 × 2 で 4 案とも食い違い 0 件(実測)

  • 参照実装は D(表の解釈器)にしました。A・B は「人が合成済みのアクションを行ごとに書く」、C は「statig が合成する」、D は「自分で書いた 15 行の LCA 計算が合成する」で、3 通りの合成のしかたが互いに独立です
  • 長さ 0〜5 の全イベント列 9,331 本 × failures ∈ {0, 2}(ガードの真偽の両方)で、各ステップの (状態名, アクション列) を D と比べました
$ cargo test --release v2_all -- --nocapture
9331 sequences x 2: mismatches flat=0 nested=0 statig=0
test v2_all_agree ... ok

v1・v2 とも 0 件、rustc 1.85.1 でも 3 テストすべて通過、clippy(--all-targets)は自分のコードで 0 件。状態の大きさは A・B・statig の State とも 1 バイト、statig の StateMachine<Door> は 2 バイト(初期化済みフラグの分)でした。

注意: 4 案が互いに一致しても、仕様書と一致する保証にはなりません。D の規則表は仕様の表を写したもので、写し間違いは 4 案に共通しえます。そのため「リセットで出るアクションは下位状態ごとに違う」だけは、仕様の表から直接書いたテスト(reset_from_each_substate: Ringing → [StopSound, ClearAlarm]、Muted → [ClearAlarm]、Escalated → [ClearAlarm])を別に置いています。

Q2 の答え: 書き換え量より「同じ事実を何か所に書くか」が効く(実測)

案v1 行数v1→v2 の diff(+/−)v2 で書き換えた中身StopSound を書く場所 v1→v2ClearAlarm v1→v2Log v1→v2
A 平ら278(+6/−2)状態 1・行 +3・名前の対応 12 → 32 → 34 → 5
B 中身つき286(+4/−2)下位状態 1・行 +1・名前の対応 12 → 32 → 23 → 3
C statig548(+6/−2)状態関数 +2 行・entry +1・Timeout の腕 1・名前の対応 11 → 11 → 13 → 3
D 表の解釈479(+5/−4)列挙・親・entry の 3 行を編集・規則 +1・名前の対応 11 → 11 → 11 → 1

(行数・出現数は wc -l・grep -o | wc -l の実測。D の Log が 1 なのは log 関数を 2 つの規則で共有しているため。C の Log が 3 なのは handler の中で push しているため)

diff の行数は 6〜9 行でほぼ同じです。違うのは、A・B では下位状態が 1 つ増えるたびに StopSound を書く場所が増え、C・D では 1 か所のままという点です。exit を書く場所が N か所あれば、N 個すべてを正しく保つ責任が人に残ります。

Q3 の答え: 11 件中 6 件はビルドが通り(4 件は警告 0)、等価性テストだけが止めた(実測)

#誤りコンパイラ等価性テスト判定
M1A: Ringing→Reset の StopSound 忘れ0 件flat 1,243 本不一致テストだけが止めた
M2B: Reset の 2 行を入れ替えwarning: unreachable pattern 1 件(位置はマクロ定義 src/lib.rs:59:24)nested 1,243 本不一致警告とテスト
M3B: Alarm(Ringing)error[E0170] pattern binding Ringing is named the same as one of the variants(+unused variable・snake case の警告)(ビルドできない)コンパイラが止めた
M3bB: Alarm(Sub::Ringng)error[E0599] no variant ... named Ringng found for enum Sub—コンパイラが止めた
M3cB: Alarm(Ringng)warning: unused variable Ringng・snake case の 2 件だけ(unreachable pattern は出ない)nested 204 本不一致警告とテスト
M4C: _ => Handled0 件statig 2,078 本不一致テストだけが止めた
M5C: _ => Super を消すerror[E0004] non-exhaustive patterns—コンパイラが止めた
M6A v2: Escalated の Fail 行忘れ0 件flat 204 本不一致テストだけが止めた
M7B v2: Timeout 行を Alarm(_) で広く0 件nested 204 本不一致テストだけが止めた
M8C: superstate = "alarn"error: superstate not found("alarn" の 7 文字に下線、23:26)+ E0277—コンパイラが止めた
M9C: 引数名 everror[E0061](State::unlocked() に引数が足りない)+ E0027—コンパイラが止めた(文は分かりにくい)

読み取れること:

  1. ビルドが通る誤りは、すべて「アクションの合成」か「どの行が勝つか」に関する誤りです(警告 0 件が M1・M4・M6・M7、警告だけが M2・M3c)。型も借用も正しいので、Rust のどの検査にも引っかかりません。止めたのは参照実装との全数比較だけでした
  2. B の誤字は書き方で 3 通りに分かれました。 Sub:: を省いて変種と同じ名前を書く(M3)と、deny 既定の lint が E0170 のエラーで止めます。Sub::Ringng(M3b)はパスなので E0599 で止まります。しかし Alarm(Ringng)(M3c)はどんな下位状態にも一致する束縛になり、Muted でも Mute を受けて StopSound が出ます(204 本不一致)。警告は「未使用の変数」「snake case でない」の 2 件だけで、行が死ぬわけではないので unreachable pattern は出ません。$sp:pat で受ける限り、パスで書かない誤字は止められない——B を採らない理由の 1 つです
  3. M2 の警告はマクロ定義を指しました(同じクレートのため。Day 071 の規則では別クレートなら nested_fsm! { 全体を指す)。どの行が死んだかは警告から分かりません
  4. M4 は statig 固有の罠です。Handled は「処理した」なので、Ringing で受けた Reset・Fail が上位の alarm に届かず消えます。2,078 本は M1 の 1.7 倍で、上位状態の共通動作を丸ごと失う分、被害が広い
  5. M9 は今日、実際に踏みました。 v2 で fn escalated(_event: &Ev) と書いたところ、statig は _event を「状態ローカルの記憶(state-local storage)」と解釈し、State::escalated() が引数を 1 つ要求する関数になって E0061・E0027 が出ました。README の該当節は "counter is only available in the led_on state" と、引数名 counter がそのまま状態の中身になる例を示しています。名前で意味が変わるのは proc-macro の DSL ならではの性質で、fsm! の ctx c: Ctx;(利用者が名付ける、Day 074 の K0)とは逆の設計です

Q3 を一目で(赤=ビルドが通り警告 0、黄=警告だけ、緑=コンパイラが止めた)

M1 A: StopSound 忘れ
1,243 本不一致
M2 B: 行の入れ替え
unreachable 1 件
M3 B: Alarm(Ringing)
E0170
M3b B: Sub::Ringng
E0599
M3c B: Alarm(Ringng)
警告 2・204 本不一致
M4 C: _ => Handled
2,078 本不一致
M5 C: Super 削除
E0004
M6 A v2: Fail 行忘れ
204 本不一致
M7 B v2: Alarm(_) で広く
204 本不一致
M8 C: "alarn"
superstate not found
M9 C: 引数名 ev
E0061+E0027

コンパイラが止めた例(M8・M9・M3)

error: superstate not found --> src/v1/hsm.rs:23:26 | 23 | #[state(superstate = "alarn", entry_action = "enter_ringing", exit_action = "exit_ringing")] | ^^^^^^^
error[E0061]: this function takes 1 argument but 0 arguments were supplied --> src/v2/hsm.rs:25:89 | 25 | match event { Ev::Mute => Transition(State::muted()), Ev::Timeout => Transition(State::escalated()), _ => Super } | ^^^^^^^^^^^^^^^^-- argument #1 of type `Ev` is missing | note: associated function defined here --> src/v2/hsm.rs:32:8 | 32 | fn escalated(_event: &Ev) -> Outcome<State> { Super } | ^^^^^^^^^ -----------
error[E0170]: pattern binding `Ringing` is named the same as one of the variants of the type `v1::nested::Sub`
⚠ 黙って通った例(M1・M4・M6・M7): 型も借用も正しく、警告 0 件でビルドが通る。止めたのは参照実装 D との全数比較だけ(M1: mismatches flat=1243、M4: statig=2078)。
✔ 4 案の等価性(実測): 9331 sequences x 2: mismatches flat=0 nested=0 statig=0(v1・v2、rustc 1.99.0 と 1.85.1)

Q4 の答え(ADR-075: fsm! の入れ子の扱い)

決定: 入れ子が 2 段以上、または entry/exit アクションが 2 つ以上ある状態機械は C(statig)に移す。それ未満は A のまま残し、どちらも D と同型の参照実装との全数比較テストを置く。B(nested_fsm!)は採らない。

案採否理由(実測)前提が変わったら
A 平ら小さい表では残す依存 0・クリーンビルド 0.26〜0.28 s。ただし下位状態が 1 つ増えるたびに exit を書く場所が増え(2→3)、書き忘れはコンパイラが黙る(M1・M6)entry/exit を持つ状態が 2 つを超えたら C へ
B 中身つき採らない行数は最少(diff 6)だが、exit の合成は A と同じく手書き(StopSound 2→3)。加えて「行の順番」「パターンの広さ」「束縛になる誤字」という A に無い誤り(M2・M7・M3c)が増え、M7 はコンパイラが黙る。得るもの(行数)より増える誤りの種類が多い下位状態の間で値を運ぶ必要(例: Alarm { since: Instant })が出たら再検討。statig の state-local storage と比べる
C statig入れ子・entry/exit がある所で採るexit・entry を 1 か所に書けば合成はライブラリが行う(StopSound は v2 でも 1 か所)。M5・M8 はコンパイラが止める。代償は依存 9 クレート(syn 2.0.119・proc-macro-error2 2.0.1 など)、クリーンビルド 2.79〜2.90 s(3 回、A の約 10 倍)、Handled と Super の取り違え(M4)が黙ること、proc-macro-error2 2.0.1 に将来の rustc で拒否される警告(E0365、issue #127909)が出ていることproc-macro-error2 の警告が hard error になる版までに statig が更新されなければ、C を no_macro 版(README が案内する手書きの trait 実装)に切り替えるか D に移す
D 表の解釈テストの参照実装として採る(本番には使わない)47 行・依存 0 で、仕様の表とほぼ 1 対 1。ただし規則が fn ポインタと実行時の探索なので、行き先の誤字は列挙子で止まるが「規則が無い」は実行時まで分からない状態機械を設定ファイル(SCXML・XState の JSON)から読む要件が出たら、D を本番側に昇格させる

どの案でも等価性テストは要る——これが今日の一番の結論です。M1・M4・M6・M7 の 4 件は警告すら出ない種類で、選択で変わるのは「その誤りを書ける場所の数」だけです。

再現用コード(cargo test --release で 3 テスト、依存は statig =0.4.1 のみ)

Cargo.toml:

[package]
name = "hsm75"
version = "0.1.0"
edition = "2021"

[features]
default = ["hsm"]
hsm = ["dep:statig"]

[dependencies]
statig = { version = "=0.4.1", optional = true }

src/lib.rs(69 行):

//! Day 075: 入れ子の状態(Alarm ⊃ Ringing / Muted)を 4 つの書き方で実装し、同じ仕様に一致するかを比べる
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ev { Unlock, Fail, Lock, Mute, Timeout, Reset }
pub const EVS: [Ev; 6] = [Ev::Unlock, Ev::Fail, Ev::Lock, Ev::Mute, Ev::Timeout, Ev::Reset];

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Act { Beep, Log(u32), Notify, SoundAlarm, StopSound, ClearAlarm, CallGuard }

pub struct Ctx { pub failures: u32 }

/// 4 実装を同じ物差しで動かすための口。step は (遷移後の状態名, 出たアクション) を返す
pub trait Machine {
    fn new(failures: u32) -> Self;
    fn step(&mut self, ev: Ev) -> (&'static str, Vec<Act>);
}

/// A: 平らな表(状態は ident のみ)。Day 073 の guardfsm と同じ形(Ev・Act は外の型を使う)
#[macro_export]
macro_rules! flat_fsm {
    (
        state $State:ident { $($S:ident),* $(,)? }
        ctx $c:ident : $Ctx:ty;
        rows { $( ($s:ident, $e:ident) $(if $g:expr)? => $to:ident [ $($acts:tt)* ] ; )* }
    ) => {
        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
        pub enum $State { $($S),* }
        impl $State {
            pub fn on(self, ev: $crate::Ev, $c: &$Ctx) -> Option<(Self, Vec<$crate::Act>)> {
                #[allow(unused_imports)]
                use $crate::Act::*;
                match (self, ev) {
                    $( ($State::$s, $crate::Ev::$e) $(if $g)? => Some(($State::$to, vec![$($acts)*])), )*
                    _ => None,
                }
            }
        }
    };
}

/// B: 状態に中身(下位状態)を持たせる表。左辺の中身は $sp:pat、
/// 右辺の中身は ( tt ) で受ける($to:expr は follow-set で `[` を置けないため)
#[macro_export]
macro_rules! nested_fsm {
    (
        state $State:ident { $($S:ident $( ( $Sub:ty ) )?),* $(,)? }
        ctx $c:ident : $Ctx:ty;
        rows { $( ($s:ident $( ( $sp:pat ) )?, $e:ident) $(if $g:expr)?
                  => $to:ident $( ( $($sub:tt)* ) )? [ $($acts:tt)* ] ; )* }
    ) => {
        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
        pub enum $State { $($S $( ($Sub) )?),* }
        impl $State {
            pub fn on(self, ev: $crate::Ev, $c: &$Ctx) -> Option<(Self, Vec<$crate::Act>)> {
                #[allow(unused_imports)]
                use $crate::Act::*;
                match (self, ev) {
                    $( ($State::$s $( ($sp) )?, $crate::Ev::$e) $(if $g)?
                        => Some(($State::$to $( ($($sub)*) )?, vec![$($acts)*])), )*
                    _ => None,
                }
            }
        }
    };
}

pub mod v1 { pub mod flat; pub mod nested; pub mod table; #[cfg(feature = "hsm")] pub mod hsm; }
pub mod v2 { pub mod flat; pub mod nested; pub mod table; #[cfg(feature = "hsm")] pub mod hsm; }

src/v1/flat.rs(A、27 行):

use crate::{Act, Ctx, Ev, Machine};
flat_fsm! {
    state Door { Locked, Unlocked, AlarmRinging, AlarmMuted }
    ctx c: Ctx;
    rows {
        (Locked, Unlock) => Unlocked [Beep];
        (Locked, Fail) if c.failures >= 2 => AlarmRinging [Log(c.failures + 1), Notify, SoundAlarm];
        (Locked, Fail) => Locked [Log(c.failures + 1)];
        (Unlocked, Lock) => Locked [];
        (AlarmRinging, Mute) => AlarmMuted [StopSound];
        (AlarmMuted, Timeout) => AlarmRinging [SoundAlarm];
        (AlarmRinging, Reset) => Locked [StopSound, ClearAlarm];
        (AlarmMuted, Reset) => Locked [ClearAlarm];
        (AlarmRinging, Fail) => AlarmRinging [Log(c.failures + 1)];
        (AlarmMuted, Fail) => AlarmMuted [Log(c.failures + 1)];
    }
}
pub struct M(Door, Ctx);
impl Machine for M {
    fn new(f: u32) -> Self { M(Door::Locked, Ctx { failures: f }) }
    fn step(&mut self, ev: Ev) -> (&'static str, Vec<Act>) {
        let acts = match self.0.on(ev, &self.1) { Some((s, a)) => { self.0 = s; a } None => vec![] };
        let n = match self.0 { Door::Locked => "Locked", Door::Unlocked => "Unlocked",
                               Door::AlarmRinging => "Ringing", Door::AlarmMuted => "Muted" };
        (n, acts)
    }
}

src/v1/nested.rs(B、28 行。M の実装は A と同じで、名前の対応だけ Door::Alarm(Sub::Ringing) => "Ringing" などに変える):

use crate::{Act, Ctx, Ev, Machine};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Sub { Ringing, Muted }
nested_fsm! {
    state Door { Locked, Unlocked, Alarm(Sub) }
    ctx c: Ctx;
    rows {
        (Locked, Unlock) => Unlocked [Beep];
        (Locked, Fail) if c.failures >= 2 => Alarm(Sub::Ringing) [Log(c.failures + 1), Notify, SoundAlarm];
        (Locked, Fail) => Locked [Log(c.failures + 1)];
        (Unlocked, Lock) => Locked [];
        (Alarm(Sub::Ringing), Mute) => Alarm(Sub::Muted) [StopSound];
        (Alarm(Sub::Muted), Timeout) => Alarm(Sub::Ringing) [SoundAlarm];
        (Alarm(Sub::Ringing), Reset) => Locked [StopSound, ClearAlarm];   // ← 次の行より先に置く(M2)
        (Alarm(_), Reset) => Locked [ClearAlarm];
        (Alarm(s), Fail) => Alarm(s) [Log(c.failures + 1)];             // s は左辺の束縛をそのまま返す
    }
}

src/v1/hsm.rs(C、54 行):

use crate::{Act, Ev, Machine};
use statig::prelude::*;

pub struct Cx { pub failures: u32, pub out: Vec<Act> }
#[derive(Default)]
pub struct Door;

#[state_machine(initial = "State::locked()", state(derive(Debug, Clone, PartialEq)))]
impl Door {
    #[state]
    fn locked(context: &mut Cx, event: &Ev) -> Outcome<State> {
        match event {
            Ev::Unlock => { context.out.push(Act::Beep); Transition(State::unlocked()) }
            Ev::Fail if context.failures >= 2 => { context.out.push(Act::Log(context.failures + 1)); Transition(State::ringing()) }
            Ev::Fail => { context.out.push(Act::Log(context.failures + 1)); Handled }
            _ => Super,
        }
    }
    #[state]
    fn unlocked(event: &Ev) -> Outcome<State> {
        match event { Ev::Lock => Transition(State::locked()), _ => Super }
    }
    #[state(superstate = "alarm", entry_action = "enter_ringing", exit_action = "exit_ringing")]
    fn ringing(event: &Ev) -> Outcome<State> {
        match event { Ev::Mute => Transition(State::muted()), _ => Super }
    }
    #[state(superstate = "alarm")]
    fn muted(event: &Ev) -> Outcome<State> {
        match event { Ev::Timeout => Transition(State::ringing()), _ => Super }
    }
    #[superstate(entry_action = "enter_alarm", exit_action = "exit_alarm")]
    fn alarm(context: &mut Cx, event: &Ev) -> Outcome<State> {
        match event {
            Ev::Reset => Transition(State::locked()),
            Ev::Fail => { context.out.push(Act::Log(context.failures + 1)); Handled }
            _ => Super,
        }
    }
    #[action] fn enter_alarm(context: &mut Cx) { context.out.push(Act::Notify) }
    #[action] fn exit_alarm(context: &mut Cx) { context.out.push(Act::ClearAlarm) }
    #[action] fn enter_ringing(context: &mut Cx) { context.out.push(Act::SoundAlarm) }
    #[action] fn exit_ringing(context: &mut Cx) { context.out.push(Act::StopSound) }
}

pub struct M(statig::blocking::StateMachine<Door>, Cx);
impl Machine for M {
    fn new(f: u32) -> Self { M(Door.state_machine(), Cx { failures: f, out: vec![] }) }
    fn step(&mut self, ev: Ev) -> (&'static str, Vec<Act>) {
        self.0.handle_with_context(&ev, &mut self.1);
        let n = match self.0.state() { State::Locked {} => "Locked", State::Unlocked {} => "Unlocked",
                                       State::Ringing {} => "Ringing", State::Muted {} => "Muted" };
        (n, std::mem::take(&mut self.1.out))
    }
}

src/v1/table.rs(D、47 行。参照実装):

//! D: 表をデータとして持ち、実行時に解釈する小さな階層状態機械(参照実装)。
//! 規則は statig README の遷移の節と同じ: 現在の葉から親へ規則を探し、遷移なら
//! 共通の祖先(LCA)の手前まで exit を下から順に、LCA の下から行き先まで entry を上から順に実行する
use crate::{Act, Ctx, Ev, Machine};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum N { Locked, Unlocked, Alarm, Ringing, Muted }
fn parent(n: N) -> Option<N> { match n { N::Ringing | N::Muted => Some(N::Alarm), _ => None } }
fn entry(n: N) -> Vec<Act> { match n { N::Alarm => vec![Act::Notify], N::Ringing => vec![Act::SoundAlarm], _ => vec![] } }
fn exit(n: N) -> Vec<Act> { match n { N::Alarm => vec![Act::ClearAlarm], N::Ringing => vec![Act::StopSound], _ => vec![] } }
pub struct Rule { pub from: N, pub ev: Ev, pub guard: fn(&Ctx) -> bool, pub to: Option<N>, pub acts: fn(&Ctx) -> Vec<Act> }
fn t(_: &Ctx) -> bool { true }
fn none(_: &Ctx) -> Vec<Act> { vec![] }
fn log(c: &Ctx) -> Vec<Act> { vec![Act::Log(c.failures + 1)] }
pub const RULES: &[Rule] = &[
    Rule { from: N::Locked, ev: Ev::Unlock, guard: t, to: Some(N::Unlocked), acts: |_| vec![Act::Beep] },
    Rule { from: N::Locked, ev: Ev::Fail, guard: |c| c.failures >= 2, to: Some(N::Ringing), acts: log },
    Rule { from: N::Locked, ev: Ev::Fail, guard: t, to: None, acts: log },
    Rule { from: N::Unlocked, ev: Ev::Lock, guard: t, to: Some(N::Locked), acts: none },
    Rule { from: N::Ringing, ev: Ev::Mute, guard: t, to: Some(N::Muted), acts: none },
    Rule { from: N::Muted, ev: Ev::Timeout, guard: t, to: Some(N::Ringing), acts: none },
    Rule { from: N::Alarm, ev: Ev::Reset, guard: t, to: Some(N::Locked), acts: none },
    Rule { from: N::Alarm, ev: Ev::Fail, guard: t, to: None, acts: log },
];
fn chain(mut n: N) -> Vec<N> { let mut v = vec![n]; while let Some(p) = parent(n) { v.push(p); n = p; } v }
pub fn step(cur: N, ev: Ev, c: &Ctx) -> (N, Vec<Act>) {
    for node in chain(cur) {
        if let Some(r) = RULES.iter().find(|r| r.from == node && r.ev == ev && (r.guard)(c)) {
            let mut out = (r.acts)(c);
            let Some(to) = r.to else { return (cur, out) };
            let (up, down) = (chain(cur), chain(to));
            let lca = up.iter().copied().find(|n| down.contains(n));
            for n in up.iter().take_while(|n| Some(**n) != lca) { out.extend(exit(*n)); }
            for n in down.iter().take_while(|n| Some(**n) != lca).collect::<Vec<_>>().into_iter().rev() { out.extend(entry(*n)); }
            return (to, out);
        }
    }
    (cur, vec![])
}
pub struct M(N, Ctx);
impl Machine for M {
    fn new(f: u32) -> Self { M(N::Locked, Ctx { failures: f }) }
    fn step(&mut self, ev: Ev) -> (&'static str, Vec<Act>) {
        let (s, a) = step(self.0, ev, &self.1);
        self.0 = s;
        (match s { N::Locked => "Locked", N::Unlocked => "Unlocked", N::Ringing => "Ringing", N::Muted => "Muted", N::Alarm => "Alarm(!)" }, a)
    }
}

src/v2/*.rs は v1 をコピーして Q2 の表の差分だけを当てたもの。statig の v2 で足した 2 行は、引数を書かない形にしてあります(M9 の罠を避けるため):

    #[state(superstate = "alarm", entry_action = "enter_escalated")]
    fn escalated() -> Outcome<State> { Super }

tests/equiv.rs(49 行):

//! 4 実装が同じ仕様か: 長さ 0〜5 の全イベント列(6^0+…+6^5 = 9,331 本)× failures ∈ {0, 2} で、
//! 各ステップの (状態名, アクション列) を D(表の解釈器)と突き合わせる
use hsm75::*;
fn run<T: Machine>(f: u32, seq: &[Ev]) -> Vec<(&'static str, Vec<Act>)> {
    let mut m = T::new(f);
    seq.iter().map(|&e| m.step(e)).collect()
}
fn seqs(max: usize) -> Vec<Vec<Ev>> {
    let mut all = vec![vec![]];
    let mut layer = vec![vec![]];
    for _ in 0..max {
        layer = layer.iter().flat_map(|s: &Vec<Ev>| EVS.iter().map(move |&e| { let mut t = s.clone(); t.push(e); t })).collect();
        all.extend(layer.clone());
    }
    all
}
macro_rules! check {
    ($name:ident, $v:ident) => {
        #[test]
        fn $name() {
            let ss = seqs(5);
            let mut diff = [0usize; 3];
            for f in [0, 2] {
                for s in &ss {
                    let oracle = run::<$v::table::M>(f, s);
                    if run::<$v::flat::M>(f, s) != oracle { diff[0] += 1 }
                    if run::<$v::nested::M>(f, s) != oracle { diff[1] += 1 }
                    if run::<$v::hsm::M>(f, s) != oracle { diff[2] += 1 }
                }
            }
            println!("{} sequences x 2: mismatches flat={} nested={} statig={}", ss.len(), diff[0], diff[1], diff[2]);
            assert_eq!(diff, [0, 0, 0]);
        }
    };
}
check!(v1_all_agree, v1);
check!(v2_all_agree, v2);

#[test]
fn reset_from_each_substate() {
    // 同じ Reset でも、出るアクションは「どの下位状態から出たか」で変わる(exit の合成)
    for (pre, want) in [(vec![], vec![Act::StopSound, Act::ClearAlarm]),
                        (vec![Ev::Mute], vec![Act::ClearAlarm]),
                        (vec![Ev::Timeout], vec![Act::ClearAlarm])] {
        let mut seq = vec![Ev::Fail]; seq.extend(pre); seq.push(Ev::Reset);
        let got = run::<v2::hsm::M>(2, &seq);
        assert_eq!(got.last().unwrap(), &("Locked", want));
    }
}

mut.sh(Q3 の 11 件を 1 件ずつ当てる。作業コピー m/ に置換を 1 つ当てて cargo test --release):

#!/bin/bash
# 使い方: ./mut.sh <名前> <ファイル> <旧> <新>
set -u
name=$1; file=$2; old=$3; new=$4
rm -rf m && cp -R st m && rm -rf m/target
python3 - "$file" "$old" "$new" <<'PY'
import sys; f,o,n=sys.argv[1:]; p='m/'+f; s=open(p).read(); assert o in s, o; open(p,'w').write(s.replace(o,n,1))
PY
out=$(cd m && CARGO_TARGET_DIR=../tgt cargo test --release 2>&1)
w=$(echo "$out" | grep -E '^warning: ' | grep -vE 'generated|future version|\(lib' | sort -u | head -3 | tr '\n' ' ')
e=$(echo "$out" | grep -E '^error(\[|:)' | grep -v 'could not compile' | head -2 | tr '\n' ' ')
t=$(echo "$out" | grep -E 'mismatches|panicked|FAILED' | head -2 | tr '\n' ' ')
printf '%s\n  warn: %s\n  err : %s\n  test: %s\n' "$name" "${w:-0}" "${e:-0}" "${t:-pass}"

ビルド時間(クリーンな CARGO_TARGET_DIR から cargo build、debug、3 回。load average 24〜30 の中での測定なので参考値):

構成コンパイルしたクレート数時間
--no-default-features(A・B・D のみ、依存 0)10.26 / 0.26 / 0.28 s
既定(statig あり)92.84 / 2.90 / 2.79 s
src/lib.rs を touch した後の増分ビルド—0.19 s(statig なし)/0.20 s(あり)

🪜 Step-by-Step 解説

Step 1: 「同じ仕様」を全数比較に落とす

4 案に trait Machine を被せ、(状態名, アクション列) の列で比べました。状態名を文字列にそろえるのは、A の AlarmRinging・B の Alarm(Ringing)・C の State::Ringing {} が型としては別物だからです。比べる単位を「型」ではなく「外から見える振る舞い」にすることで、内部表現の違う実装を同じ物差しに乗せられます。

Step 2: 参照実装は「一番書き方が違うもの」にする

A と B はどちらも「合成済みのアクションを人が書く」ので、同じ書き間違いを共有しえます。D は exit/entry の表と LCA の計算から合成するので、A・B と同じ間違いをするには別の経路で間違える必要があります。C と D は同じ規則(statig README の遷移の節)を独立に実装したもので、一致したことが statig の規則の理解が合っている証拠にもなります。

Step 3: 合成を手で書くと、下位状態が増えるたびに責任が増える

Q2 の表の「StopSound を書く場所」は、A・B で 2 → 3、C・D で 1 → 1 でした。exit Ringing という 1 つの事実を N か所に書くと、仕様変更(たとえば「音を止める前にフェードアウト」)で N か所を直す必要があり、1 か所でも漏れると M1 のように黙って通ります。これはデータベースの正規化と同じ話で、同じ事実を 1 か所に置くのが C・D の価値です。

Step 4: B は行を減らすが、誤りの種類を増やす

(Alarm(_), Reset) のようなワイルドカード行は行を減らしますが、match の「上の行が勝つ」規則を表に持ち込みます。M2(順番の入れ替え)は警告 1 件で済みましたが、M7(広すぎるパターン)は警告 0 件でした。Escalated で Timeout を受けると (Alarm(_), Timeout) に一致して Escalated に入り直し、StopSound・CallGuard が出ますが、どの行も到達不能にはならないので(Muted の Timeout 行が先にあるため)警告の材料がありません。Day 065 で見た「ガードの順番」と同じ種類の誤りが、パターンでも起きます。

Step 5: statig の罠は「名前で意味が決まる」こと

#[state] 関数の引数は、event・context・&mut self 以外の名前だと状態の中身になります(M9)。_event と書いただけで State::escalated() の形が変わり、別の行(Transition(State::escalated()))で E0061 が出ます。エラーの位置は誤りを書いた行ではなく呼び出し側でした。Day 074 で「見せたい名前は利用者に書かせる」と決めた fsm! と、「決まった名前で意味を受け取る」statig は、DSL の設計として対照的です。

🧭 設計思想・なぜこう書くのか

  • 型が守るものと、テストが守るもの: Rust の型は「状態が 5 つのどれか」「アクションが 7 種のどれか」を保証しますが、「この遷移で出るべきアクションが全部出ているか」は保証しません。今日の 11 件の誤りのうち、網羅性(E0004)で止まったのは M5 だけで、M3・M3b・M8・M9 は名前の検査、残り 6 件はビルドが通りました(M2・M3c の警告は「何かおかしい」とまでしか言わない)。型で表せない仕様は、全数比較のテストで守る——どの案を選んでも、これは省けません
  • 合成をコンパイル時にするか実行時にするか: statig は proc-macro で State/Superstate の enum と call_entry_action などの match を生成し、LCA の探索は実行時に行います(README 末尾の FAQ が "#[state_machine] simply parses the underlying impl block and derives some code" と書いている)。A は合成を人の頭の中で行い、結果を表に書きます。どちらもゼロコスト抽象化の範囲ですが、「誰が合成するか」が誤りの入り方を決めます
  • 依存を足すことの値段: statig 1 つで syn・quote・proc-macro2・proc-macro-error2 など 9 クレートが入り、クリーンビルドが約 10 倍になりました。さらに proc-macro-error2 2.0.1 は将来の rustc で拒否される予定の書き方(E0365、issue #127909)を含みます。依存は機能と一緒に、その依存の保守状態という前提も持ち込みます

🌐 他言語との比較

言語・ライブラリ入れ子(superstate)entry/exit の合成根拠
Rust 自作 flat_fsm!(A)無し(状態名に埋め込む)人が行ごとに書く実測
Rust statig 0.4.1(C)#[superstate]、Super で委ねるライブラリが LCA で合成実測+README
Rust rust-fsm 0.8.0無し(平らな DSL)無し(Day 067 で確認した出力の形のみ)Day 067 の照合(累計 1,423,423 DL、2026-10-15 crates.io API)
UML ステートチャート/SCXML状態の入れ子規格が LCA 規則で定める記憶ベース(規格の条文は未引用)
TypeScript XStatestates の入れ子ライブラリが合成(entry/exit)記憶ベース(Day 066 で pure transitions の節を引用したのみ)
Java Spring StatemachinewithStates().parent(...)ライブラリが合成記憶ベース
Go(標準)無し。looplab/fsm などは平ら手書き(コールバック)記憶ベース

A は Go の「switch で書く」文化に近く、C は XState・UML の流儀です。Rust で特徴的なのは、C が実行時の解釈ではなくコンパイル時に enum と match を生成する点で、状態は 1 バイト(実測)に収まります。

🏭 実務での使いどころ

  • 医療機器・産業機器の警報: IEC 60601-1-8(医用電気機器の警報)のような規格では、警報の「一時停止(audio paused)」「確認(acknowledged)」などの下位状態を持つのが普通です。exit で必ず音を止める・記録を残す、を 1 か所に書けることは、監査で「どの経路でも必ず記録される」と説明しやすくなります(規格の条文は今日は読んでいません)
  • 決済・注文の状態: 「支払い処理中」の中に「3DS 認証待ち」「与信待ち」がある、取消はどの下位状態からでも同じ後始末、という形は入れ子そのものです。後始末(与信の解放)を書き忘れる M1 型の誤りは、お金の事故になります
  • 全数比較テスト: 状態とイベントが少ない(今日は 5 × 6)なら、長さ 5 の全列でも 9,331 本で、テストは 0.02 s 以内に終わりました。リファクタリング(A → C への移行)の前に、旧実装を参照実装として残し、新旧を全数比較するのは、そのまま移行の安全網になります

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
「入れ子は enum の中に enum を入れれば表せる」状態の形だけを見ている形(B)は表せるが、entry/exit の合成は表せない。StopSound を書く場所は A と同じく増える
「ワイルドカード行で共通動作をまとめれば安全」行が減ると誤りも減ると考える「上の行が勝つ」規則が入り、順番(M2)と広さ(M7)の誤りが増える。M7 は警告 0 件
「Alarm(Ringing) と書いても動く」use Sub::* があると思い込む束縛になる。変種と同名なら E0170 が止めるが、変種に無い誤字(M3c)は警告 2 件で通る
「statig の Handled と Super はどちらでも同じ」どちらも「この状態で止まる」に見えるHandled は上位へ届けない。M4 で 2,078 本が食い違った
「statig の状態関数の引数名は自由」普通の関数の感覚event・context 以外は状態ローカルの記憶になる(M9、_event でも同じ)
「4 実装が一致したから仕様どおり」一致を正しさと取り違える互いの一致は仕様との一致ではない。仕様の表から直接書いたテストを別に置く

🛑 扱わなかった範囲(止め方)

  • statig の async 版・before_transition などのフック・state-local storage の実用は使っていません。M9 で「引数名が中身になる」ことを確かめただけです
  • M2 を別クレートにしたときの警告の位置は測っていません(Day 071 の規則からの予想)
  • 実行速度は測っていません。A・B は Vec を返すたびに確保し、C は context.out に push するので、比べるなら確保の数をそろえる必要があります
  • 3 段以上の入れ子・直交領域(parallel states)・履歴状態(history)は扱っていません。statig が直交領域を持たないこと、UML/SCXML の規則との違いは README と規格を読んでおらず未確認です
  • IEC 60601-1-8・UML・SCXML の条文は引用していません(記憶ベース)
  • ビルド時間は load average 24〜30 の中での 3 回だけです
  • Day 074 の宿題(Span::resolved_at、proc-macro の $crate 問題)と Day 073 の宿題(WARN 注釈、if-let 専用行規則、ui_test)は今日は進めていません。前回の次回予定 (2)(ハーネスに K1・K6・K7 相当を足す)も未実施です

➡️ 次のステップ

  • 発展: C(statig)版の Door に、Day 073 のガードの誤り 13 通りを当てて、エラーの位置が「利用者の書いた行」を指すかを測る(proc-macro の span 割り当てが Day 074 の ADR-074 と同じかの確認になる)
  • 次回予告: マクロ① の残り(〜10/24)。今日の設計の次は実装かデバッグ。候補は (1) Day 074 の次回予定 (2)(fsm! の UI ハーネスに K1・K6・K7 相当のケースを //~ ERROR 注釈つきで足す)、(2) M2・M3c を別クレートに置いたときの警告の位置を測り、nested_fsm! で束縛を禁止する($sp:path に絞る)とどの誤りが消えるかを見るデバッグ問題

🎯 自己評価(解いた後に記入)

Q3 で外した予想(11 件中)

自分の回答(Q1 の比べ方、Q4 の ADR)

気づき・メモ