Featured image of post コードテイマー【State Machine】氷の貌のまま、獣は炎を吐いて自壊する〜札を足さず、貌をただ一つに畳む〜

コードテイマー【State Machine】氷の貌のまま、獣は炎を吐いて自壊する〜札を足さず、貌をただ一つに畳む〜

王宮の防衛術式が、味方を撃った。引き継いだ術師は、矛盾が露れるたびに札(boolean フラグ)を一枚ずつ足して塞いできた——どの一枚も、その時は正しかった。だが「待機中」の札と「発動中」の札は別々に立ち、ある中断の手順が両方を灯したまま残す。待機の貌をしたまま、術式は前を横切った味方へ放たれる。札を増やすほど、ありえぬ貌は倍々に増える(n枚で2のn乗通り)。札で数えるのをやめ、貌をただ一つの型に畳めば(Discriminated Union+状態遷移)、「待機中かつ発動中」はそもそも書けなくなる。実行時のifで弾くのでなく、コンパイル時に存在を消す。新しい貌を足したとき道の描き漏らしを型が止める(never網羅検査)TypeScriptの契約の物語。

待機の貌のまま、撃った

盤は「待機」と灯っていた。なのについさっき、術式は前を横切った警備兵へ放った。

僕は王宮の防衛術式を預かっている。石壁に埋め込まれた巨大な迎撃機構で、結界に触れた敵影を捉えると、自ら構え、魔力を充填し、発動する。先代の宮廷術師が遺したものを、僕が引き継いだ。仕様書は、半分も揃っていなかった。

人払いされた制御室は、静かだった。負傷した警備兵は、もう運ばれた後だ。盤の表面には、状態を示す札のランプがずらりと並んでいる。その一番上——「待機」の札が、今も静かに灯っている。待機。撃たない、という意味の札だ。それが灯ったまま、術式は味方を撃った。

魔物使いは、もう盤の前にいた。灯った札と、僕が握りしめた手控え——術式が受けた合図の履歴——とを、黙って見比べている。すぐには何も言わない。

「先代が、書庫に書き遺していたんです」と僕は言った。それが唯一の縁だった。「“手に負えぬ獣は、魔物使いのカイナを頼れ。あれは力でなく、契約で獣を鎮める"と。……あなたが、そのカイナですか」

カイナと名乗ったその人は、手控えから目を上げずにうなずいた。王宮お抱えの術師たちは、誰もこの盤を直せなかった。だから僕は、宮廷の外へ手を伸ばした。

言い添えておくべきことが、もう一つあった。「この防衛術式は、王宮を囲う古い結界の一部なんです。近頃、その結界全体が……時折、軋むような音を立てる。術式の不調も、そのせいかもしれないと」。だがカイナは、その話には深く立ち入らなかった。今日の獣は、目の前の盤にいる。

札は、どれも当時は正しかった

僕は術式を誇れなかった。むしろ、怯えていた。

「待機中は、撃たないはずなんです。そのための札を、僕が足したんですから。なのに——盤は"待機"と灯ったまま、撃った」

カイナは黙って続きを促した。僕は言葉を選びながら、引き継いでからのことを話した。

「先代の術式は、時々おかしな振る舞いをしました。撃つべきでない時に構え、構えるべき時に黙る。原因が露れるたびに、僕は札を足してきたんです。“こういう時は撃たない"と一枚、また一枚。……どれも、その時の事故はちゃんと止まりました。だから、正しいと思っていた。でも、足すほど、別のところがおかしくなる気がして。僕が、どこかで掛け違えたんでしょうか」

確信はなかった。あるのは、自分の積み上げを信じきれない不安だけだ。

カイナは、ようやく口を開いた。札を貶める響きは、なかった。

「お前の札は、どれも当時は正しく効いた。一枚ずつ見れば、間違いは無い。——だが、札は足すほど掛け合わさる。三枚あれば、灯り方は二の三乗で八通り。お前が守りたいのは、そのうち四通りだけだろう」

「八通り……?」僕には意味が掴めなかった。「札は、ただ立てているだけです。それぞれ、上げるか下げるか、それだけの」

「その"それだけ"が、三つ集まる。上げ下げの組み合わせが、八つになる」

三つの札で、状態を数える

僕は防衛術式を写した板を取り出した。難しいことはしていない。だからこそ、穴があるとは思えなかった。

まず、術式が受け取る合図——イベントを説明した。起動、充填、発動、中断、冷却。この五つだけだ。中断の合図は、構えを解いて待機へ戻すためのもの。攻撃そのものを取り消す、外からの取り決めだと思ってくれていい。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 防衛術式の共通の作法:合図(イベント)を一つ受けて、内部の状態を一段進める。
export type WardEvent =
  | { type: "arm"; target: string } // 標的を指定して起動(待機→構え)
  | { type: "charge" } // 魔力を一段、充填する
  | { type: "fire" } // 発動(攻撃)
  | { type: "abort" } // 中断(構えを解いて待機へ戻す合図)
  | { type: "cool" }; // 冷却が一段進む

export interface Ward {
  send(event: WardEvent): void;
  // 「今この瞬間、術式が放つ標的」。null なら撃たない。外から安全を問う唯一の窓口。
  currentShot(): string | null;
}

「状態は、三つの札で管理しています」と僕は言った。「待機、構え、発動。起動の合図で構えに入り、充填が満ちたら発動。中断の合図で、待機に戻す。……戻している、つもりです」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// Before: 状態を独立した boolean 札で管理する。各札は引き継ぎ後、事故が出るたびに一枚ずつ足された。
export class FlagWard implements Ward {
  private standby: boolean; // 待機中(最後に足した安全札)。盤の表示ランプと arm 受理ガードに使う——だが currentShot は見ない
  private charging: boolean; // 構え=充填中
  private firing: boolean; // 発動中
  private charge: number; // 充填量
  private target: string | null;

  constructor() {
    this.standby = true;
    this.charging = false;
    this.firing = false;
    this.charge = 0;
    this.target = null;
  }

  send(event: WardEvent): void {
    switch (event.type) {
      case "arm":
        this.target = event.target;
        this.charging = true;
        this.standby = false; // 起動したので待機を下ろす
        break;
      case "charge":
        this.charge++;
        break;
      case "fire":
        this.firing = true;
        this.charging = false; // 発動に移る
        break;
      case "abort":
        // 「構えを解いて待機へ」のつもりで書いた。だが発動中(firing)の中断は想定しておらず、
        // firing の札を下ろし忘れている。=引き継ぎ後、最後に足した standby との掛け違い。
        this.standby = true;
        this.charging = false;
        this.charge = 0;
        break;
      case "cool":
        this.firing = false;
        this.standby = true;
        this.target = null;
        break;
    }
  }

  currentShot(): string | null {
    // 撃つ判定は firing の札だけを見る(standby は外への合図・arm ガード用で、ここでは見ない)。
    // standby と firing が両立すると、盤は「待機」と灯るのに firing が標的を返す=待機の貌のまま撃つ。
    return this.firing ? this.target : null;
  }
}

僕は、最後の currentShot を指して言った。「外の誰かが"今、撃つのか"を問うときは、ここを見ます。撃つかどうかは、発動の札——firing が立っているかどうか、それだけで決める」

「待機の札は」とカイナが訊いた。「ここでは、見ないのか」

「ええ。待機の札は、盤に"待機中"と表示して、警備兵に安全を知らせるためのものです。あとは、起動の合図を受け付けてよいかの見張りに。撃つ判定そのものには——」

言いかけて、僕は黙った。撃つ判定は firing だけを見る。待機の札は、別の場所で使う。同じ「状態」のはずなのに、二つの札は、別々のところを指している。その食い違いに、その時の僕は、まだ名前をつけられなかった。

札を足すほど、ありえぬ貌が増える

「あの一撃には、ちゃんと筋がある」とカイナは言った。「合図を、ある順で送れば、必ずあの貌が現れる。その順を、ここで、こちらの手で組む」

札が一枚なら、食い違う場所が無い

カイナがまず組んだのは、合図を一つずつ手で送る仕掛けだった。

「この術式に渡す合図を、こちらが順に決める。一つ送って、状態を一段進める。また一つ送る。実際には一瞬も待たない。合図の並びだけを、こちらで作る」

これは大事なところだった。術式の send は、合図を一つ受けて状態を進めるだけだ。時計の針も、外からの割り込みも、ここには無い。だから模擬戦では、合図の列をそのまま順に流せばいい。実時間はゼロ。同じ列を流せば、何度やっても、行き着く貌はただ一つに定まる。結果は、時の運に左右されない。状態は、合図の並びだけで決まる。

1
2
3
4
5
6
// 模擬戦の道具:events を順に send するだけの同期の手番。実時間も乱れも無い=決定的。
export function drive(ward: Ward, events: WardEvent[]): void {
  for (const event of events) {
    ward.send(event);
  }
}

「お前の術式は、並行も、まぐれも無い」とカイナは言った。「合図の順番だけで、貌が決まる。なら、順番を、こちらで握ればいい」

そう言って、カイナはまず札の少なかった頃へ巻き戻した。「引き継いだ当初、お前の術式に、撃つ判定を司る札は firing 一枚だった。素直な事故の列を流してみろ。起動、充填二度、発動」

僕は合図を流した。起動で構えに入り、充填が二段で満ち、発動。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
it("札が firing 一枚相当なら矛盾の起きる場所が無い(素直な術式)", () => {
  const ward = new FlagWard();
  // abort を挟まない素直な事故列(起動・充填満・発動)。
  // standby=false のまま firing=true に至り、二つの札は食い違わない=矛盾は起きない。
  drive(ward, [
    { type: "arm", target: "ally" },
    { type: "charge" },
    { type: "charge" },
    { type: "fire" },
  ]);
  assert.equal(ward.isFiring(), true);
  assert.equal(ward.isStandby(), false); // 札は一致したまま=矛盾の起きる場所が無い
});

(盤の札を直に読む観測窓——isStandby()isFiring() など——は、模擬戦のために添えてある。盤のランプを覗くのと同じことだ。)

「起動した時点で、待機の札は下りる」とカイナは言った。「だから発動に至っても、待機と発動が同時に灯ることはない。札が、撃つ判定を司る一枚だけなら——矛盾の起きる場所が、そもそも無い」

「やがて、撃つ前の"構え"を区別したくて、charging を足したんです」と僕は言った。「でも、それも単独では、おかしくならなかった」

「ああ。矛盾は、最後の一枚を足したときに開く」カイナは僕を見た。「お前が引き継いで、最後に足した札。盤に"待機中"と示すための standby。あれが、何をしているか——お前自身の手で、辿ってみろ」

僕が足した札が、新しい穴を開けた

カイナが指したのは、abort の処理だった。中断の合図を受けたとき、待機の札を灯す——僕が、最後に書き足した一行だ。

「同じ事故の手順を、今度は中断まで流す」とカイナは言った。「起動、充填二度、発動。そして、発動の直後に——“やはり撃つな"の中断」

僕は合図を流した。自分の手で、一つずつ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
it("\"待機中は撃たない\"安全札(standby)を足すと、同じ事故列でstandby×firingが両立する新たな矛盾が開く", () => {
  const ward = new FlagWard();
  // 起動・充填満・発動・直後の中断(やはり撃つな、の差し込み)。
  const accident = [
    { type: "arm" as const, target: "ally" },
    { type: "charge" as const },
    { type: "charge" as const },
    { type: "fire" as const },
    { type: "abort" as const },
  ];
  drive(ward, accident);
  // abort ハンドラは standby=true にするが、firing を下ろし忘れている=矛盾の再現。
  assert.equal(ward.isStandby(), true);
  assert.equal(ward.isFiring(), true);
});

盤を見て、僕は凍りついた。「待機」のランプと、「発動」のランプが、両方灯ったまま、消えない。

中断の合図は、待機の札を灯した。書いたとおりに。だが——発動の札を、下ろしていなかった。僕は、発動中の中断を、想定していなかった。中断とは、構えている最中に来るものだと思い込んでいた。だから abort の処理は、待機を灯し、構えを解き、充填を空にする。発動の札には、指一本触れていない。

「currentShot を、見てみろ」とカイナが言った。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
it("その矛盾状態でcurrentShot()が標的を返す(待機の貌のまま味方を撃つ再現)", () => {
  const ward = new FlagWard();
  const accident = [
    { type: "arm" as const, target: "ally" },
    { type: "charge" as const },
    { type: "charge" as const },
    { type: "fire" as const },
    { type: "abort" as const },
  ];
  drive(ward, accident);
  // 盤は「待機」と灯っている(isStandby() === true)のに、currentShot は firing を見て標的を返す。
  assert.equal(ward.isStandby(), true);
  assert.equal(ward.currentShot(), "ally");
});

待機の札が灯っているのに、currentShot は標的を返した。味方の名を。

「……僕が、たった今、足したんです」と僕は言った。声が掠れた。「この矛盾を。安全を示すために足した札が、別の札と食い違って、新しい穴を開けた」

カイナは静かだった。「お前の害の正体は、これだ。同じ"状態"を、別々の札が、別々の場所で見ている。盤は standby を見て"待機"と灯す。撃つ判定は firing を見て標的を返す。二つが食い違ったとき——盤は安全を掲げ、術式は引き金を引く。横切った警備兵は、盤の"待機"を信じていた」

カイナは、盤から視線を落としたまま、手元の羊皮紙に素早くインクを走らせた。 「お前が頭の中で繋ぎ合わせた合図の流れは、こうだ。実時間はなく、ただ順序だけが獣の歩みを決めている」 彼が描いたのは、我々が犯した過ちの、あまりにも無慈悲な順序だった。

Infographic sequence diagram showing a race condition bug where inconsistent state flags (standby=true and firing=true simultaneously) cause an accidental target lock in a magical defense system

「盤が安全を示す『待機』の貌をしているのに、術式の奥深くでは『発動』の魔力で滾ったまま放置されている」と、カイナは言った。「その二つが同時に立ち上がるなど、お前も、お前の先代も考えなかった。だから、発動中の割り込みというたった一つの隙間に、この歪みが落ち込んだ」

この害は、平時には決して露れない。中断が、発動の最中に差し込まれた、ちょうどこの一続きの手順でだけ、二枚が灯る。だから先代も、僕も、長いこと見つけられなかった。

足す方向は、終わらない

カイナは盤の札を、指でゆっくりなぞった。

「三枚の札は、灯り方が八通りある。standby だけ、charging だけ、firing だけ——お前が守りたいのは、この"どれか一枚だけ灯る"四通りだ。残る四通り——二枚灯る、三枚灯る、一枚も灯らない——は、起きてはいけない貌だ」

そして、物差しの話をした。「前に、別の山で、狭い通路に一度に通す数を絞る門番を立てたことがある。あれが数えたのは"今、何頭が通路を占めているか”——同時の数、いわば場所だ。配水の井戸では、器に"一続きの刻に何杯通ったか"を数えさせた——流量、いわば時間だ。だが、ここは場所でも時間でもない」

カイナは札の列を指した。「お前は、貌を、札の掛け算で数えている。札が n 枚なら、灯り方は二の n 乗。一枚足すたび、ありえぬ貌は倍に増える。守りたい"どれか一枚だけ"の掟は、札が増えるほど破れやすくなる。お前は、増える穴を、後から塞いでいた」

僕は、ようやく腑に落ちた。自責が、理解に変わっていく。

「だから……終わらなかったのか」僕は呟いた。「塞ぐたびに札が増えて、増えるほど別の組み合わせが生まれて。札を足すほど、ありえぬ貌が倍々に膨れていた。僕は、その膨れていく穴を、ずっと後から追いかけていただけだ」

複数の独立した boolean を別々に持つと、その組み合わせの大半が無効な状態になる。この、状態の数の膨れ上がりが、僕の盤を蝕んでいた正体だった。

札で数えず、貌をひとつに畳む

足すな、畳め

「直し方は、もう一枚札を足すことじゃない」とカイナは言った。「足すな、畳め。札を一枚増やすたびに掛け算が膨らむなら、掛け算そのものをやめればいい」

「畳む……?」

「札で"待機か否か・構えか否か・発動か否か"を別々に数えるのを、やめる。貌そのものを、ただ一つにする。待機の貌、構えの貌、発動の貌、鎮まりの貌——術式は、そのどれか一つだけを、常に持つ。札を何枚上げても、獣の貌はひとつだ。なら、最初から貌をひとつだけ持たせればいい」

カイナは、それを TypeScript でどう刻むかを示した。

「取りうる貌を、ただ並べるんじゃない。それぞれの貌に名札を一つ付ける——どの貌かを示す判別子だ。そして、その貌でだけ持つ持ち物を、貌と一緒に束ねる。待機の貌に"標的"は無い。標的を持つのは、構えと発動の貌だけだ」

カイナが刻もうとしていたのは、Discriminated Union(判別可能なユニオン型)——取りうる貌を、それぞれの判別子(kind)と持ち物ごと束ねて並べた型だった。

ありえぬ貌は、書けない

カイナが刻んだ型は、これだ。

1
2
3
4
5
6
7
8
9
const REQUIRED = 2; // 発動に要る充填量
const COOLDOWN = 2; // 冷却の段数

// 貌は「名札 kind + その貌でだけ持つ持ち物」を一つに束ねた直和。ありえぬ組み合わせは、ここに書けない。
export type WardState =
  | { kind: "standby" } // 待機:標的を持たない
  | { kind: "charging"; target: string; charge: number } // 構え:標的と充填量を持つ
  | { kind: "firing"; target: string } // 発動:標的を持つ
  | { kind: "cooldown"; remaining: number }; // 鎮まり:残り段数を持つ

「待機の貌は、{ kind: "standby" } だけだ」とカイナは言った。「標的を持つ枠が、無い。だから——{ kind: "standby", target: "味方" } と書こうとしても、書けない。“待機中なのに標的を持つ"という貌は、この型の語彙に、存在しない」

僕は、それが何を意味するか、ゆっくり理解した。さっきの矛盾——盤は待機、なのに標的を返す——は、standby の札と firing の札が別々に立てたから起きた。だが貌をひとつに畳めば、「待機の貌」が標的を持つことは、書きようがない。

「八通りのうち、ありえぬ四通りを、後から弾くんじゃない」とカイナは続けた。「畳めば、四つの正しい貌しか、最初から書けない。残りは、消える」

カイナは、札三枚の掛け算を**直積(product type)と呼んだ。二の三乗で、八通りを生む数え方だ。畳んだ後の四つの貌は、そのどれか一つ——こちらは直和(sum type)**だという。掛け算で膨れるのを、足し算で必要な分だけに畳む。ありえぬ組み合わせは、型に書けないから、存在しない。

次に、カイナは状態を進める術式を刻んだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 今の貌とイベントから、次の貌を返す純粋・同期関数。許される移ろいだけを刻む。
export function transition(state: WardState, event: WardEvent): WardState {
  switch (state.kind) {
    case "standby":
      // 待機に許されるのは「起動」だけ。fire も abort もここでは意味を持たない=貌を変えない。
      return event.type === "arm"
        ? { kind: "charging", target: event.target, charge: 0 }
        : state;
    case "charging":
      if (event.type === "charge")
        return { kind: "charging", target: state.target, charge: state.charge + 1 };
      if (event.type === "fire" && state.charge >= REQUIRED)
        return { kind: "firing", target: state.target };
      if (event.type === "abort")
        return { kind: "standby" }; // 構えの中断は、まだ撃っていないので待機へ直帰できる
      return state;
    case "firing":
      // 発動してしまった術式は、待機へ直帰させない。中断も冷却を経て鎮める=暴れ立てを直に止めない。
      if (event.type === "cool" || event.type === "abort")
        return { kind: "cooldown", remaining: COOLDOWN };
      return state;
    case "cooldown": {
      if (event.type !== "cool") return state;
      const remaining = state.remaining - 1;
      return remaining <= 0 ? { kind: "standby" } : { kind: "cooldown", remaining };
    }
    default:
      return exhaustiveGuard(state); // 新しい貌を足して道を描き忘れると、ここでコンパイルエラー
  }
}

// 撃つ標的は「今の貌」だけで決まる。firing の貌のときだけ標的を持つ=待機の貌は標的を返しようがない。
export function currentShot(state: WardState): string | null {
  return state.kind === "firing" ? state.target : null;
}

transition は、今の貌とイベントを受け取り、次の貌を返す。純粋関数——同じ入力には必ず同じ出力を返し、外の何も書き換えない関数だ。許される移ろいだけを、この地図に刻む。

充填の閾値 REQUIRED と冷却の段数 COOLDOWN を、どちらも 2 に置いた。事故の手順が充填を二度送るのは、この閾値に揃えてある——二段で満ちて、発動へ移る。発動の貌での中断も、冷却を二段経て、待機へ戻る。題材を最小の刻みに絞るための値で、「二で満ち、二で鎮まる」を物差しにした(その値が術式として妥当かどうかは、型の守る話ではない。後でカイナが触れる)。

「肝は、ここだ」とカイナは firing の分岐を指した。「発動してしまった術式は、中断が来ても、待機へ直帰させない。冷却の貌を経て、鎮める。だから——“待機の貌のまま発動が残る"という道が、地図のどこにも引かれていない。引かれていない道は、辿りつけない」

currentShot も変わった。もう札を見ない。今の貌だけを見る。firing の貌のときだけ標的を持つ。待機の貌は、標的を返しようがない。同じ「状態」を別々の札が別々に見る、あの食い違いは、貌をひとつに畳んだことで、根元から消えた。

貌を一つに畳む型(Discriminated Union)と、許される移ろいだけを刻む関数(transition)。この二つが組み合わさったものを、有限状態マシン(State Machine)——取りうる状態と、状態の間で許された移り変わりだけを定めた仕組み——という。地図にすると、こうだ。

カイナが指先で示したのは、防衛術式が進むべき、もう一つの盤面――新しい地図だった。それは、かつて僕が足し算で増やし続けた迷路とは異なり、簡潔で、どこか美しさすら感じさせる一本の小径に似ていた。

Infographic state transition diagram showing a safe State Machine architecture preventing invalid states in a magical defense system using TypeScript’s type system

「standby の貌には target の枠がない。つまり、待機の貌でいる限り、標的を捕らえること自体が許されない」 カイナの指先が、地図の standby を指した。 「『待機の貌のまま撃つ』というあり得ぬ事態は、この型という契約の上で、そもそも存在を許されていないんだ」

同じ手順を、畳んで通す

「同じ事故の手順を、今度はこの地図で畳む」とカイナは言った。

純粋関数は、貌を畳むのに reduce を使える。初めの貌——待機——に、合図を一つずつ食わせて、次の貌、その次の貌、と畳んでいく。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
it("同じ事故の手順を流しても「待機の貌のまま発動」へ到達しない(firingのabortはcooldownへ着地)", () => {
  const accident: WardEvent[] = [
    { type: "arm", target: "ally" },
    { type: "charge" },
    { type: "charge" },
    { type: "fire" },
    { type: "abort" },
  ];
  const final: WardState = accident.reduce(transition, { kind: "standby" } as WardState);
  // 発動済みの abort は待機へ直帰せず、鎮まり(cooldown)を経る=「待機の貌のまま発動」という道が無い。
  assert.equal(final.kind, "cooldown");
  assert.equal(currentShot(final), null);
});

待機から始め、起動で構えへ、充填二度で満ち、発動で firing の貌へ。そして中断——発動の貌での中断は、待機へ直帰せず、鎮まりの貌へ落ちる。行き着いた貌は cooldowncurrentShot は、firing の貌でないから、null

「待機の貌のまま撃つ、という道が、地図に無い」とカイナは言った。「だから、どう合図を並べても、そこへは辿りつけない」

カイナは続けて、別の入り方も試した。待機の貌に、いきなり発動の合図——地図に道の無い、許されない入力——を送ってみる。

1
2
3
4
5
it("許されない移ろいは貌を変えない(standbyでfireは無視される)", () => {
  const state: WardState = { kind: "standby" };
  const next = transition(state, { type: "fire" });
  assert.deepEqual(next, { kind: "standby" }); // 道の無い入力は、貌をそのままにする
});

待機の貌は、発動の合図を受けても、待機のままだった。地図に引かれていない移ろいは、ただ無視される——例外も投げず、貌も変えず。transition は純粋関数だから、同じ貌に同じ合図を与えれば、必ず同じ次の貌になる。たまたまこの順で通ったのではない。引いた道だけが通れる地図は、合図をどう並べ替えても、引かれていない道へは入り込ませない。「待機の貌のまま発動」は、ある一つの事故列でたまたま避けられたのではなく、どの合図列からも、構造として辿りつけない。

僕は、ほっとしかけた。だがカイナは、もう一段、見せるものがあると言った。

「ここまでは、走らせて確かめた。合図を流し、行き着いた貌を見た。だが、これは"実行時に弾いた"わけじゃない。transition の中に、矛盾を弾く if は、一つも無い。ありえぬ貌へ向かう道を、最初から引かなかっただけだ。——そして、もう一段、深いところで、型が守っている」

カイナは、新しい板にこう書いた。

1
2
3
4
5
6
import type { WardState } from "./state-machine.ts";

// 「待機の貌に標的を持たせる」は型として書けないことの固定。
// @ts-expect-error 待機の貌(standby)はtargetを持たない=「待機中なのに標的を撃つ」が型として書けない
const bad: WardState = { kind: "standby", target: "ally" };
void bad;

@ts-expect-error は、“次の行は型エラーになるはずだ"という指示だ」とカイナは言った。「待機の貌に標的を持たせる——{ kind: "standby", target: "ally" }。これは型として書けないから、コンパイラが弾く。その弾きを、@ts-expect-error が受け止める」

ここで、術式の検めには二つの層があることを、はっきりさせておかねばならない。模擬戦——node --test——は、術式を実際に走らせて、振る舞いを確かめる。だが、走らせるとき、TypeScript の型の検めは行われない。型は剥がされ、ただの JavaScript として動く。だから「待機の貌に標的を持たせると書けない」ことは、node --test では確かめられない。それを守るのは、もう一つの層——コンパイル時の型検査、tsc だ。

「実行時に if で弾くのと、コンパイル時に書けなくするのは、別のことだ」とカイナは言った。「if で弾くなら、その if を通り抜ける道を、誰かがいつか書ける。だが、型に書けないものは、走らせる前に消える。tsc を通した瞬間に、弾かれる。ありえぬ貌は、本番の盤に乗る前に、消えている」

そして、この @ts-expect-error の妙を、カイナは付け加えた。「この指示には、裏の番もある。もし将来、誰かが型をゆるめて——待機の貌にも標的を持てるようにしてしまったら。その行は、もう型エラーにならない。すると @ts-expect-error は"期待した弾きが来ない"と見なされ、今度はそれ自体がコンパイルエラーになる。ありえぬ貌の排除がゆるんだら、この一行が、すぐに気づかせる」

畳んであれば、安全に増やせる

「これから、新しい構えが要るときは?」と僕は訊いた。これが、ずっと怖かったことだ。「また札を——いえ、また貌を足して、矛盾が増えませんか?」

「貌を一つ足すなら、地図に新しい道を引く」とカイナは言った。「引き忘れた道があれば——型が、見張る」

カイナは、仮の話だと断って、こう書いた。

1
2
3
4
5
6
7
8
// もし「過熱(overheated)」という貌を足したら——
// type WardState = ... | { kind: "overheated"; until: number };
// transition の switch に case "overheated" を描き忘れると、
// default の exhaustiveGuard(state) で、state が never に収まらず、コンパイラが弾く。

function exhaustiveGuard(x: never): never {
  throw new Error(`未処理の貌: ${JSON.stringify(x)}`);
}

transition の switch は、最後を exhaustiveGuard(state) で閉じている」とカイナは説明した。「すべての貌を処理し終えた後に残る型は、never——何も残らない、という型のはずだ。exhaustiveGuard は、never だけを受け取る関数。だから、もし貌を一つ足したのに、その貌の道を描き忘れると、残りが never に収まらず、ここで型エラーになる」

switch を never で閉じ、貌の取りこぼしをコンパイル時に弾くこの仕掛けを、**網羅性検査(exhaustiveness checking)**という。never は、すべての型に代入できるが、never に代入できるのは never だけ、という性質を持つ。その性質を使って「全部の貌を処理し終えたら、残るのは never のはず」を、コンパイラに突きつける。

「足すたびに掛け算が膨らんだ札とは、逆だ」とカイナは言った。「畳んであれば、貌を足しても、増えるのは"道を引く手間"だけ。引き忘れれば、型が止める。お前が増やすのを、型が見張る。——畳んで初めて、安全に増やせる」

貌を、確かめる

模擬戦と、型の検め。二つの層を、まとめて走らせた。

まず、実際に走らせる模擬戦。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ node --test
▶ After: 状態マシン(transition+Discriminated Union)
  ✔ 同じ事故の手順を流しても「待機の貌のまま発動」へ到達しない(firingのabortはcooldownへ着地)
  ✔ currentShotはfiringの貌のときだけ標的を返す(standby/charging/cooldownはnull)
  ✔ 許されない移ろいは貌を変えない(standbyでfireは無視される)
▶ Before: フラグ札(standby/charging/firing)
  ✔ 札が firing 一枚相当なら矛盾の起きる場所が無い(素直な術式)
  ✔ "待機中は撃たない"安全札(standby)を足すと、同じ事故列でstandby×firingが両立する新たな矛盾が開く
  ✔ その矛盾状態でcurrentShot()が標的を返す(待機の貌のまま味方を撃つ再現)
ℹ tests 6
ℹ pass 6
ℹ fail 0

六本、すべて緑。Before の盤は、札が食い違わない素直な列なら矛盾を起こさず、同じ事故の列に中断を差し込んだ途端、待機と発動を両立させて味方を撃った。After の地図は、そっくり同じ事故の列を流しても、待機の貌のまま発動へは至らず、許されない移ろいは貌を変えなかった。

そして、走らせる前の検め。型が、ありえぬ貌を書けなくしていることを確かめる。

1
2
3
$ tsc --noEmit
$ echo $?
0

何も言わずに、通った。tsc は、間違いを見つけたときだけ口を開く。黙って通ったのは、ありえぬ貌——待機の貌に標的を持たせる一行——が、ちゃんと型エラーになり、@ts-expect-error がそれを受け止めたからだ。もしその排除がゆるんで——誰かが待機の貌にも標的を持てるようにしてしまったら——@ts-expect-error は受け止める弾きを失って宙に浮き、その途端、この exit は 0 でなくなる。

Before は、走らせて初めて事故が露れた。After は、走らせるより前に、ありえぬ貌が消えている。これが、二つの層の違いだ。

唯一の差分は、状態の数え方だけだった。受け取る合図も、流す事故の列も、外から問う窓口 currentShot も、Before と After で一つも違わない。変えたのは、独立した札の掛け算(直積)を、判別子ひとつの貌(直和)に畳んだこと。それだけが、ありえぬ貌を消した。

盤から、札が消える

防衛術式が、待機の貌のときは、決して撃たなくなった。盤を見ると——もう、並んだ札は無い。灯るのは、ただ一つ。「今の貌」だけだ。

「僕は、増える穴を、後から追いかけていました」と僕は言った。「畳んでしまえば、穴の開く場所そのものが、無い。……これからは、足すんじゃなく、畳んでから、増やせる」

先代の遺産を、増改築で歪めてしまった負い目が、初めて少しだけ、前を向いた。

「次に構えを増やすときは」と僕は続けた。「まず、地図を描きます。どの貌から、どの貌へ、どの合図で移れるのか。引き忘れたら、型が止めてくれる」

カイナはうなずいて、最後に一つだけ、別の鎖の話をした。「貌ごとに"振る舞い"そのものまで分けて持たせたいなら、別の作法もある。状態ごとの処理を、貌ごとの部品に閉じる——そういう設計だ。だが今日の獣の病は、振る舞いの置き場所じゃない。ありえぬ貌が、書けてしまうことだった。だからまず、貌を畳んだ。それで足りた」

カイナは制御室を出る前に、一度だけ、結界の軋む方を見た。

「貌が一つに定まらず、ありえぬ形に崩れていく。——この獣の病は、この術式だけのものか」

僕は、問い返せなかった。ただ、遠くで軋む結界の音と、たった今まで盤を蝕んでいた矛盾とが、どこかで重なる予感だけが、後に残った。

それから、もう一つ、カイナは断っておくと言った。「この地図が消したのは、ありえぬ貌だ。だが、地図に引いた道そのものが、術式として正しいか——たとえば、充填がいくつ満ちたら発動してよいのか——それは、型の守る話じゃない。お前が決めることだ。それに、合図が一つずつ順に届くなら、この地図は寸分たがわず畳む。だが、複数の手元から同時に合図が届くようになったら——それは、また別の獣だ」

その「別の獣」が何なのか、その時の僕は知らなかった。ただ、貌を一つに畳んだこの術式が、もう待機の貌のまま味方を撃つことはない。それだけは、確かだった。


📜 カイナの魔獣契約録(Tamer’s Registry)

  • 魔獣名(クラス/パターン名): 千変の変幻獣カメレオン(氷の貌のまま炎を吐く矛盾状態)/ 不正状態の暴走(State Machine+Discriminated Union=貌をひとつに畳む/独立 boolean フラグの直積=ありえぬ状態の組合せ爆発)
  • 危険度(難易度/バグの影響度): ★★★★★(データ破壊ではなく安全性の崩壊。盤は「待機」と灯ったまま発動が残り、無害と信じて前を横切った味方を撃つ。矛盾形態のまま魔力が暴走すれば、術式そのものが焼き切れて自壊する)
  • 主な生態(アンチパターンの特徴):
    • 状態を独立した boolean フラグ(standby/charging/firing)の直積で数えると、n 枚で 2 の n 乗通りの組合せが生まれ、その大半(3枚なら8通り中4通り)がありえぬ無効状態になる。塞ぐために札を足すほど、無効な組合せは倍々に膨れる
    • 害の機序は「同じ"状態"を、別々の札が、別々の場所で参照して食い違う」こと。standby は盤の表示ランプと arm 受理ガードに使われ、撃つ判定 currentShot は firing だけを見る。両者が別管理ゆえ standby=true かつ firing=true が成立すると、盤は「待機」と灯るのに標的を返す
    • 中断(abort)が発動(firing)の最中に差し込まれた一続きの手順でだけ二枚が灯る。平時・構え中には露れず、再現条件が手順依存ゆえデバッグが難しい
  • 契約のポイント(設計の要点):
    • Discriminated Union=貌に判別子 kind を一つ付け、その貌でだけ持つ持ち物を束ねた直和。standbytarget の枠が無く「待機中なのに標的を持つ」が型として書けない=ありえぬ組合せを型の語彙から排除(直積2^n → 直和の必要な貌だけに畳む)
    • transition(state, event)=今の貌と合図から次の貌を返す純粋・同期関数。許される移ろいだけを刻み、「待機の貌のまま発動」へ至る道を地図に引かない=到達不能。currentShot も札でなく今の貌だけを見る
    • 二層検証:node --test(実行時)で事故列が矛盾へ到達しないことを、tsc --noEmit(コンパイル時)でありえぬ貌が書けないことを守る。Node のネイティブ実行は型を剥がすため、型の保証は tsc が担う
    • never 網羅性検査=switch (state.kind)exhaustiveGuard(x: never) で閉じる。新しい貌を足して道を描き忘れると残りが never に収まらずコンパイルエラー=増改築の番人(畳んで初めて安全に増やせる)
    • 1:1 の単一差分:同一 WardEvent・同一の事故列・同一の外部窓口 currentShot を保ち、差分は状態の数え方(独立 boolean の直積 → 判別子ひとつの直和)のみ。無関係なバグ修正を混ぜていない
  • 契約外事項(保証しないこと):
    • 合図が一つずつ順に届く前提で畳む。複数の手元から同時に合図が届く並行入力・状態の分散同期は守備範囲外=別の獣(データレース/楽観ロック)の領分
    • 型はありえぬ貌を消すが、正しい貌から正しい貌への道そのものが業務的に妥当か(例:充填閾値が適切か)は保証しない。道の妥当性は別
    • 過剰符号化への戒め:充填量や冷却残りまで全て型パラメータへ符号化しようとしない。目的は不正状態の排除であって、あらゆる細部の型表現化ではない(charge: number は値で持ってよい)
    • GoF State パターン(状態ごとの振る舞いをオブジェクトにカプセル化)とは焦点が違う。本話は「ありえぬ貌が書けてしまう」ことを型で消す方=FSM+Discriminated Union
  • 現在のステータス: 🟢 貌をひとつに畳み、許される移ろいだけを地図に刻んで「待機の貌のまま発動」を型から消した契約成立(実行時の if でなくコンパイル時に存在を消す)/合図の並行到着・状態の分散同期は別の獣として後日。同時の数(門番)・流量(器)に続き、状態空間の妥当性で、暴走を律する物差しがまた一つ揃った
comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。