🦔

『異世界TDD転生 第二章~ボウリングと境界の再定義~』

に公開

前作はこっち
https://note.com/iepyon/n/n0bb990117938

第一章:単純ループの罠

あれから1週間。

俺——神崎ハジメは、新しいプロジェクトに取り組んでいた。ボウリングスコア計算システム。古典的なTDD演習題材だ。

「Red Firstだ。まずテストから」

def test_gutter_game():
    """全部ガター(0本)の場合、スコアは0"""
    game = BowlingGame()
    for _ in range(20):  # 10フレーム×2投
        game.roll(0)
    assert game.score() == 0

def test_all_ones():
    """全部1本ずつの場合、スコアは20"""
    game = BowlingGame()
    for _ in range(20):
        game.roll(1)
    assert game.score() == 20

RED。当然だ。BowlingGameクラスすら存在しない。

最小限の実装を書く。

class BowlingGame:
    def __init__(self):
        self.rolls = []
    
    def roll(self, pins):
        self.rolls.append(pins)
    
    def score(self):
        return sum(self.rolls)

GREEN!テストが通った。

「よし、次はスペアだ」

def test_one_spare():
    """スペア:2投で10本。次の1投がボーナス"""
    game = BowlingGame()
    game.roll(5)
    game.roll(5)  # スペア!
    game.roll(3)  # ボーナス対象
    for _ in range(17):
        game.roll(0)
    assert game.score() == 16  # (5+5+3) + 3 = 16

RED

スコアは13になる。ボーナスの3点が計算されていない。

「うーん…」

俺は修正を試みた。

def score(self):
    total = 0
    for i, pins in enumerate(self.rolls):
        total += pins
        # スペア判定
        if i > 0 and self.rolls[i-1] + pins == 10:
            if i + 1 < len(self.rolls):
                total += self.rolls[i+1]
    return total

RED。今度は別のテストが壊れた。all_onesが40点になってしまう。

「くそっ、ループの中でボーナスを足すと、位置関係がめちゃくちゃになる…」

俺は3時間格闘したが、どうしてもうまくいかない。

その時、画面が再び赤く染まった。

『構造的限界ヲ検出。単純ループデハ解決不能。境界ノ再定義ガ必要。転送ヲ開始』

「また異世界かよ!」

第二章:TDDワールドの危機再び

気づくと、俺は再びあの虚空に立っていた。

今回は一つの扉だけ。『TDD/AUTOPOIESIS SYNTHESIS WORLD』——二つの世界が融合している。

扉を開けると、レッドとマトゥラーナが待っていた。

「ハジメさん!助けてください」レッドが叫ぶ。

「ボウリング大聖堂が崩壊しかけているんです!」

目の前には、巨大な建造物。「ボウリング大聖堂」と呼ばれる、この世界のスコア計算システムの中枢だ。

建物の構造が単純な一本道——投球の列が一直線に並んでいる。

マトゥラーナが説明する。「この聖堂は『投球列システム』として自己産出している。各投球を要素とし、単純加算で境界を維持していた」

「でも、スペアとストライクが出現してから…」レッドが続ける。

建物の至る所に亀裂が入っている。スペアとストライクが投げられるたび、ボーナス計算が歪みを生み出し、構造が矛盾していく。

「これは…」俺は気づいた。「俺が現実世界で直面してた問題と同じだ!」

第三章:境界の誤認

マトゥラーナが言う。

「問題の本質は『境界の定義』だ。今、このシステムは『投球』を基本単位として自己を定義している」

建物を見ると、確かに20個の部屋が一列に並んでいる。各部屋が1投を表している。

「でも、ボウリングの真の単位は『フレーム』ではないのか?」

俺の言葉に、レッドが反応した。

「フレーム…10個のフレーム、各フレームは最大2投…」

マトゥラーナが興奮した声で言う。

「そうか!これはオートポイエーシスの『組織閉鎖性の再定義』問題だ。システムが自己の境界を誤認している!」

俺は理解し始めた。

現在の境界定義

  • 基本単位:投球(roll)
  • 境界:20個の投球の列
  • 自己産出:各投球を順に加算

真の境界定義

  • 基本単位:フレーム(frame)
  • 境界:10個のフレーム
  • 自己産出:各フレームがスコアを産出し、次のフレームに影響

「でも、どうやって境界を再定義する?既存のシステムを壊さずに…」

第四章:リファクタリングという構造変換

マトゥラーナが言った。

「生物は進化の過程で、自己の組織を再定義してきた。しかし、それは一瞬で起こるのではない。『構造的ドリフト』——徐々に、生きたまま、変化していく」

レッドが続ける。

「TDDでは、それを『リファクタリング』と呼びます。Greenの状態(動いている状態)を保ちながら、内部構造を変えていく」

「つまり…」俺は閃いた。「テストを壊さずに、実装だけを変える!」

俺たちは聖堂の再構築に取り組んだ。

ステップ1:境界の可視化

まず、現在の20個の投球部屋の上に、10個のフレーム層を追加した。

「投球データは保持したまま、フレームという新しい抽象化層を重ねる」

class BowlingGame:
    def __init__(self):
        self.rolls = []  # 既存の境界
    
    def roll(self, pins):
        self.rolls.append(pins)
    
    def score(self):
        total = 0
        roll_index = 0  # 投球列での位置
        
        for frame in range(10):  # フレームという新しい境界
            # まだ単純加算
            total += self.rolls[roll_index]
            roll_index += 1
            total += self.rolls[roll_index]
            roll_index += 1
        
        return total

聖堂に変化が起きた。20個の投球部屋の上に、10個の大きな空間が現れた。フレーム空間だ。

GREEN!既存のテストは全て通る。

「よし、境界を壊さずに構造を変えた」

ステップ2:スペアの自己産出

次に、スペアのボーナス計算を組み込む。

def score(self):
    total = 0
    roll_index = 0
    
    for frame in range(10):
        if self.rolls[roll_index] + self.rolls[roll_index + 1] == 10:
            # スペア:このフレームの2投 + 次の1投
            total += 10 + self.rolls[roll_index + 2]
            roll_index += 2
        else:
            # 通常:このフレームの2投のみ
            total += self.rolls[roll_index] + self.rolls[roll_index + 1]
            roll_index += 2
    
    return total

聖堂の各フレーム空間が光り始めた。スペアが発生すると、そのフレームが次のフレームと一時的に接続し、エネルギー(ボーナス点)を受け取る。

GREEN!スペアのテストが通った!

レッドが歓声を上げる。「できた!フレームを境界にしたことで、ボーナスが自然に計算できる!」

マトゥラーナが解説する。

「これが『構造的カップリング』だ。各フレームは自律的(組織閉鎖的)だが、隣接フレームと構造的に結合している。スペアという事象が、その結合を活性化させる」

第五章:ストライクという摂動

「でも、まだストライクが残ってる」俺は言った。

def test_one_strike():
    """ストライク:1投で10本。次の2投がボーナス"""
    game = BowlingGame()
    game.roll(10)  # ストライク!
    game.roll(3)
    game.roll(4)
    for _ in range(16):
        game.roll(0)
    assert game.score() == 24  # (10+3+4) + 3 + 4 = 24

RED

問題は、ストライクの場合、フレームは1投しかない。roll_indexの進み方が変わる。

「これは…」マトゥラーナが言う。「システムに新しい『行動パターン』を追加する必要がある。環境からの摂動——ストライクという事象に、システムが新しい反応を産出する」

俺は実装を修正した。

def score(self):
    total = 0
    roll_index = 0
    
    for frame in range(10):
        if self.rolls[roll_index] == 10:
            # ストライク:この1投 + 次の2投
            total += 10 + self.rolls[roll_index + 1] + self.rolls[roll_index + 2]
            roll_index += 1  # ストライクは1投だけ進む
        elif self.rolls[roll_index] + self.rolls[roll_index + 1] == 10:
            # スペア:この2投 + 次の1投
            total += 10 + self.rolls[roll_index + 2]
            roll_index += 2
        else:
            # 通常:この2投のみ
            total += self.rolls[roll_index] + self.rolls[roll_index + 1]
            roll_index += 2
    
    return total

聖堂が完全な形で輝き始めた。

ストライクが発生すると、そのフレーム空間は凝縮され、1投分の大きさになる。そして次の2投と強く結合し、大きなエネルギー流が発生する。

GREEN!全てのテストが通った!

第六章:圏論で見る構造変換

マトゥラーナと俺は、聖堂の屋上に立っていた。

「さて、圏論使いとして、この変換を解釈してみよう」マトゥラーナが言う。

俺はノートに書いた。

射のレベル:個別の変換

旧システム:
roll₁ → roll₂ → roll₃ → ... → roll₂₀ → sum

新システム:
frame₁ → frame₂ → ... → frame₁₀ → sum
各フレームは内部で:
  (roll, roll) → frame_score
  または
  (roll) → frame_score  ※ストライク時

関手のレベル:構造の保存

関手 F: RollSystem → FrameSystem

  • 対象の対応:
    • 20個の投球 → 10個のフレーム
    • 単純加算 → フレーム加算 + ボーナス
  • 射の対応:
    • 「次の投球」→「次のフレーム」または「フレーム内次投」
    • 「加算」→「フレームスコア計算」

重要:この関手はテストスイート(境界条件)を保存する!

F(all_ones_test) = all_ones_test ✓
F(spare_test) = spare_test ✓

自然変換のレベル:本質の不変性

二つの計算方法(投球ベース vs フレームベース)の間の自然変換 η

η: sum(rolls) ⇒ sum(frame_scores)

各フレームfで、以下が成立:

sum(rolls in f) + η(f) = frame_score(f)

ここで η(f) は「ボーナス項」。通常フレームでは η(f)=0。

η が自然変換である理由

  • どちらの方法でも「最終スコア」は同じ(可換図式)
  • 構造(ボウリングルール)を保存

「つまり…」レッドが言う。「リファクタリングは、圏論的には『自然変換の発見』なんですね」

「そうだ」俺は頷いた。「二つの異なる実装が、実は『自然に同値』であることを示している」

第七章:オートポイエーシス的理解

マトゥラーナが補足する。

「オートポイエーシスの観点から見ると、今起きたのは『組織の自己再記述』だ」

彼は聖堂を指差した。

「最初、システムは自己を『投球の列』として定義していた。しかし、環境(ボウリングルール)との構造的カップリングがうまくいかなかった——スペアとストライクという摂動に対応できなかった」

「そこで、システムは自己の『組織閉鎖性の単位』を再定義した——投球ではなくフレームを基本単位として」

「これは生物進化と同じだ」マトゥラーナは続ける。「単細胞生物から多細胞生物への進化も、『細胞』という基本単位は変えずに、『組織』という新しい階層を追加した」

俺は理解した。

境界の再定義プロセス

  1. 古い境界:投球(20個)
  • 単純だが、環境(ルール)に対応できない
  1. 構造的ドリフト(リファクタリング):
  • 投球データは保持(下位互換)
  • フレームという新階層を追加
  1. 新しい境界:フレーム(10個)
  • 複雑だが、環境に適応できる
  • 投球は内部要素として保存

「システムは死なずに、生きたまま、自己の定義を変えた」

第八章:現実世界への帰還

『構造変換ヲ理解シタ。帰還ヲ許可』

俺は元の世界に戻った。

モニターには、俺が格闘していたボウリングゲームのコード。

「わかった。単純ループじゃダメなんだ。境界——基本単位——を変える必要がある」

俺は異世界で得た洞察を実装した。

class BowlingGame:
    def __init__(self):
        self.rolls = []
    
    def roll(self, pins):
        self.rolls.append(pins)
    
    def score(self):
        """フレームを基本単位として計算"""
        total = 0
        roll_index = 0
        
        for frame in range(10):
            if self._is_strike(roll_index):
                total += self._strike_score(roll_index)
                roll_index += 1
            elif self._is_spare(roll_index):
                total += self._spare_score(roll_index)
                roll_index += 2
            else:
                total += self._frame_score(roll_index)
                roll_index += 2
        
        return total
    
    def _is_strike(self, roll_index):
        return self.rolls[roll_index] == 10
    
    def _is_spare(self, roll_index):
        return self.rolls[roll_index] + self.rolls[roll_index + 1] == 10
    
    def _strike_score(self, roll_index):
        return 10 + self.rolls[roll_index + 1] + self.rolls[roll_index + 2]
    
    def _spare_score(self, roll_index):
        return 10 + self.rolls[roll_index + 2]
    
    def _frame_score(self, roll_index):
        return self.rolls[roll_index] + self.rolls[roll_index + 1]

全てのテストを実行。

GREEN

俺は更にテストを追加した。

def test_perfect_game():
    """パーフェクトゲーム:12回のストライク"""
    game = BowlingGame()
    for _ in range(12):
        game.roll(10)
    assert game.score() == 300

GREEN

「完璧だ…」

第九章:同僚との対話

翌日、同僚のユウキが聞いてきた。

「ハジメ、ボウリングゲーム実装してたよね?どうやって解いた?俺も昨日トライしたけど、スペアのとこで詰まって…」

「ああ、最初は俺も詰まった。単純ループで投球を全部足そうとしてた」

「それな!で、どうした?」

俺はホワイトボードに図を描いた。

[間違った境界認識]
roll → roll → roll → ... → roll
  ↓      ↓      ↓          ↓
  +      +      +          +  → Total

[正しい境界認識]
┌─────────┐  ┌─────────┐  ┌─────────┐
│Frame 1  │  │Frame 2  │  │Frame 3  │
│roll,roll│  │roll,roll│  │roll,roll│
└─────────┘  └─────────┘  └─────────┘
     ↓            ↓            ↓
  score₁      score₂      score₃
     └────────┴────────┴───────→ Total

「境界を変えたんだ。『投球』じゃなくて『フレーム』を基本単位にする」

「おお…」ユウキの目が輝いた。

「これ、オートポイエーシスで言う『組織閉鎖性の再定義』なんだよ」

「オート…何?」

俺は笑った。「システムが『自分とは何か』を定義する単位を変えること。生物も、コードも、同じ原理で進化する」

「そして、圏論的には『自然変換』——二つの異なる実装が、本質的に同じスコアを計算することを保証する変換だ」

ユウキは考え込んでいた。

「つまり…リファクタリングって、ただコードを綺麗にするだけじゃないんだな」

「そう。『システムの自己定義を進化させる』行為なんだ」

第十章:TDDとオートポイエーシスの融合

その夜、俺は自分のノートを整理した。

ボウリングゲームで見えた真理

TDD的視点

  1. Red First:失敗するテストから始める(スペアテスト)
  2. Minimal Green:最小限の実装で通す(単純加算)
  3. Refactor:構造を変えながら動作を保つ(フレーム導入)

オートポイエーシス的視点

  1. 環境からの摂動:スペア・ストライクというルール
  2. 構造的カップリングの失敗:単純加算では対応不能
  3. 組織の再定義:境界単位を投球→フレームへ
  4. 構造的ドリフト:生きたまま(Greenを保ち)進化

圏論的視点

射: roll計算 → frame計算
関手: RollSystem → FrameSystem
  - テストを保存(既存機能を破壊しない)
  - 構造を保存(ボウリングルールに従う)
自然変換: η : sum(rolls) ⇒ sum(frames)
  - 可換性:どちらでも同じスコア
  - ボーナス項の自然な導入

統一原理

三つの視点は、実は一つの真理を指している:

『境界の適切な定義こそが、システムの適応力を決める』

  • TDD:テストという境界が、実装の進化を導く
  • オートポイエーシス:自己定義という境界が、環境への適応を可能にする
  • 圏論:抽象化の境界(対象の選び方)が、構造変換を自然にする

技術的補足:ボウリングゲームの境界問題

なぜ単純ループでは解けないのか

# ❌ 失敗する実装
def score(self):
    total = 0
    for i, pins in enumerate(self.rolls):
        total += pins
        # この時点で「どのフレームか」がわからない
        # スペア/ストライク判定ができない
    return total

問題の本質:投球列には「フレーム境界」という情報が含まれていない。

フレームループが解決する理由

# ✓ 成功する実装
def score(self):
    total = 0
    roll_index = 0
    for frame in range(10):  # フレームが基本単位
        # フレーム内の投球を参照可能
        # 次フレームの投球も参照可能(ボーナス計算)
        ...

解決の鍵:「フレーム」という境界を導入することで:

  1. 現在位置が明確になる
  2. 「次の投球」との関係が定義できる
  3. ボーナス計算が自然に表現できる

圏論的まとめ

これは関手の設計原理を示している:

  • 悪い関手:構造を保存しない(投球列 → 単純加算)
  • 良い関手:構造を保存する(フレーム列 → フレーム加算)

良い抽象化(対象の選び方)が、良い実装(射の設計)を可能にする。​​​​​​​​​​​​​​​​

Discussion