『異世界TDD転生 第二章~ボウリングと境界の再定義~』
前作はこっち
第一章:単純ループの罠
あれから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。
η が自然変換である理由:
- どちらの方法でも「最終スコア」は同じ(可換図式)
- 構造(ボウリングルール)を保存
「つまり…」レッドが言う。「リファクタリングは、圏論的には『自然変換の発見』なんですね」
「そうだ」俺は頷いた。「二つの異なる実装が、実は『自然に同値』であることを示している」
第七章:オートポイエーシス的理解
マトゥラーナが補足する。
「オートポイエーシスの観点から見ると、今起きたのは『組織の自己再記述』だ」
彼は聖堂を指差した。
「最初、システムは自己を『投球の列』として定義していた。しかし、環境(ボウリングルール)との構造的カップリングがうまくいかなかった——スペアとストライクという摂動に対応できなかった」
「そこで、システムは自己の『組織閉鎖性の単位』を再定義した——投球ではなくフレームを基本単位として」
「これは生物進化と同じだ」マトゥラーナは続ける。「単細胞生物から多細胞生物への進化も、『細胞』という基本単位は変えずに、『組織』という新しい階層を追加した」
俺は理解した。
境界の再定義プロセス:
- 古い境界:投球(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):
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的視点:
- Red First:失敗するテストから始める(スペアテスト)
- Minimal Green:最小限の実装で通す(単純加算)
- Refactor:構造を変えながら動作を保つ(フレーム導入)
オートポイエーシス的視点:
- 環境からの摂動:スペア・ストライクというルール
- 構造的カップリングの失敗:単純加算では対応不能
- 組織の再定義:境界単位を投球→フレームへ
- 構造的ドリフト:生きたまま(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): # フレームが基本単位
# フレーム内の投球を参照可能
# 次フレームの投球も参照可能(ボーナス計算)
...
解決の鍵:「フレーム」という境界を導入することで:
- 現在位置が明確になる
- 「次の投球」との関係が定義できる
- ボーナス計算が自然に表現できる
圏論的まとめ
これは関手の設計原理を示している:
- 悪い関手:構造を保存しない(投球列 → 単純加算)
- 良い関手:構造を保存する(フレーム列 → フレーム加算)
良い抽象化(対象の選び方)が、良い実装(射の設計)を可能にする。
Discussion