Featured image of post コードパペッティア【Blackboard Pattern】互いの出番を譲り合う二人の裏方人形〜暗く沈む舞台と、共有された進行表〜

コードパペッティア【Blackboard Pattern】互いの出番を譲り合う二人の裏方人形〜暗く沈む舞台と、共有された進行表〜

灯りと大道具、二体の人形が出番を譲り合ったまま暗転する舞台。内部状態を直接読み書きする密結合設計はなぜ二度目で壊れるのか。共有する進行表と短い制御ループで疎結合に協調させるBlackboard PatternのPython実装を追う。

舞台の高いところで、滑車がひとつ、軋みました。見上げると、綱元の格子から差し込む昼下がりの光の筋に、埃がゆっくりと舞っています。大道具の車輪がころがる音、誰かの掛け声、木づちの乾いた響き――稽古の物音が、いつもよりずっと賑やかに、舞台のあちこちから聞こえてくる日でした。

旅回りの一座との合同公演が、二日後に迫っていました。

第1幕: 予兆と調律 ── 「お先にどうぞ」が、微笑ましい停滞を生む

点検簿を片手に、舞台と工房を行き来していると、遠くでアンとすれ違いました。

「今日はやけに騒がしいのね。あなたが何か壊す前に、片付けておくことね」

いつもの調子で言い捨てて、アンは奥へ消えていきます。皮肉の切れ味は、少しも鈍っていませんでした。それが、なんだか無性に安心する朝でした。

支配人の案内で、見慣れない女の人が舞台袖に現れたのは、それからすぐのことです。日に焼けた、快活な物腰。旅仕込みの、着慣れた実用的な外套には、あちこちに継ぎが当たっていて、それがかえって、場数を踏んだ貫禄のように見えました。稽古の様子をひとしきり眺めてから、彼女は感心したように言いました。

「評判通り、息の合った仕事をするのね」

フェアファクス女史――旅回りの一座を率いる、女座長でした。用件は簡潔で、興行師らしい早口です。二日後、彼女の一座とこの劇場の人形たちが、初めて肩を並べる大掛かりな一幕を披露する。舞台の袖を指し示しながら、彼女は身振りを交えて説明しました。暗転で客席を闇に沈め、その隙に大道具を丸ごと入れ替え、再び灯りを上げる――この呼吸を、幕間なしに休みなく三度続ける演目だということでした。これまでこの劇場が手がけてきたどの演目よりも、規模も速さも一段上のものでした。

「うちの一座は台詞と身のこなしはお手のものだけど、機械仕掛けの息合わせは門外漢でね。任せたわよ、お嬢さん」

コレットの肩を一度叩き、フェアファクス女史は懐から取り出した携帯用の予定表に何かをさっと書き込むと、支配人とともに、風のように去っていきました。長居はしない人のようでした。次の劇場へ向かう馬車が、もう表で待っているのかもしれません。

稽古は、何事もなかったように続きます。ちょうど、場面転換の練習に差し掛かるところでした。照明担当の人形と、大道具担当の人形――二体とも、この劇場に長く仕える顔なじみです。照明担当の人形は、いつも大道具の支度をよく確かめてから灯りを動かす、律儀な性分で、いつからか私たちは「石橋を叩くランプ人形」と呼ぶようになっていました。大道具担当の人形は、力こそ強いものの腰が重く、灯りが変わるのを見届けてからでないと動かない――こちらは「腰の重い書割人形」です。

合図があると、ランプ人形が小さなオルゴール仕掛けの声で、律儀に告げます。

「お先にどうぞ」

すると書割人形が、低く実直な声で返します。

「いや、そちらから」

「お先にどうぞ」

「いや、そちらから」

二言が、二度、三度と往復しました。ランプ人形の吊り腕は、下ろしかけては引っ込め、下ろしかけては引っ込め――その都度、天井の滑車がきゅ、きゅ、と控えめな音を立てます。書割人形のほうも、車輪を軋ませて半歩踏み出しては、律儀に後ずさりを繰り返していました。互いに一歩を譲り合ったまま、どちらも動きません。周りの人形たちが、くすくすと笑い声を立てています。木づちを持ったままの大工人形まで、手を止めて眺めていました。私は慌てて駆け寄り、声をかけました。

「はい、いいわよ、ランプさんから」

その一声で、二体はあっさりと動き出しました。灯りが落ち、大道具が入れ替わり、灯りが戻る。何の問題もなく、いつも通りの光景です。

けれど、笑いながら次の作業に戻ろうとして、ふと手が止まりました。――そういえば、いつもこうだった。二人の律儀な譲り合いは、いつだって私かマエストロの一声で、あっさり解けていた。誰かが背中を押してあげることが、当たり前すぎて、意識したことすらなかったのです。

二日後の本番では、この場面転換が、休みなく三度続きます。その都度、私が舞台袖に張り付いて、一声かけて回らなければならないのでしょうか。三度なら、まだいい。けれど本番の緊張の中で、間を外さず声をかけ続けられる自信は、正直ありませんでした。しかも本番には、旅の一座の演者たちも舞台に上がります。私の合図一つに、見知らぬ土地から来た人たちの芝居まで振り回されるのかと思うと、肩のあたりがずしりと重くなる気がしました。

工房の隅で、マエストロが道具の手入れをしながら、ちらとこちらへ目を向けたのが分かりました。何も言いません。いつもの、見守る姿勢です。

第2幕: 絡糸と破綻 ── 弾んだ掛け合いが、唐突に途切れる

「私が、二人の間を橋渡ししてあげればいい」

そう思いついたのが、始まりでした。ランプ人形と書割人形、それぞれの中にある「今、自分がどんな姿でいるか」を示す小さな値を、私が代わりに読み取って、次に何をすべきか指図してあげよう、という考えです。灯りが落ちているか。大道具が動いたか。その二つの値さえ見ていれば、誰が先に動くべきかは、私が判断してあげられる――そう信じて疑いませんでした。

指図役を書き上げるのに、さほど時間はかかりませんでした。空いた作業台に紙を広げ、三つの分かれ道を、順番に書き出していきます。まだどちらも動いていなければ、灯りを落とす。灯りが落ちていて、大道具がまだなら、大道具を動かす。両方済んでいれば、灯りを戻す。書きながら、我ながら筋が通っている、と思いました。単純で、見通しがよく、これなら二人の間に立って、私が代わりに判断してあげられる――そう信じて疑いませんでした。

机の上で試すと、狙い通りに動きました。灯りが落ち、大道具が入れ替わり、灯りが戻る――一度目の場面転換は、危なげなく完了したのです。私は安堵し、それから小さく独りごちました。

「動いてるけど、これじゃいつか糸が絡まる……」

手応えの裏に、うっすらとした違和感がありました。けれどその日は、他にも点検すべき人形が山ほどあります。違和感は、すぐに次の作業の下に埋もれてしまいました。

本番同様、続けて二度目の場面転換を試したのは、その日の夕方です。ランプ人形が、小さな声で切り出しかけました。

「お先に――」

そこで、ふつりと途切れたのです。

いつもなら、すかさず返ってくるはずの「いや、そちらから」が、聞こえてきません。灯りは、半分落ちたところで止まっています。舞台の半分が薄闇に沈み、残り半分だけがガスランプの色を保っている、ちぐはぐな明るさでした。書割人形の腕は、持ち上がりかけた姿勢のまま、静止していました。物音ひとつしません。さっきまでの、賑やかな稽古の物音が嘘のように、舞台袖だけが凪いだ水面のように静まり返っていました。

私は焦って、行き詰まったら二人まとめて動かしてしまおう、ととっさに指図役へ一手を書き足しました。けれど灯りが落ちきらないうちに、大道具の腕が勢いよく動き出し――危うく、二体がぶつかりかけたのです。冷や汗が背筋を伝いました。その一手は、すぐに取り下げました。

舞台は、暗く沈んだまま、動きません。ランプ人形と書割人形を、代わる代わる見つめます。何をどうすればいいのか、分かりませんでした。少し離れたところで、マエストロが道具の手入れの手を止め、こちらを見ているのに気づきました。

第3幕: 示唆と編み直し ── 譜面を示すだけ

マエストロが、埃をかぶった指揮棒と、小さな譜面台を持ち出してきたのは、それからすぐのことでした。譜面台には、何も乗っていません。空っぽの譜面立てを、マエストロは一度、静かに指し示しました。

「指揮者は、奏者の指を掴んでは動かさぬ。譜面を示すだけだ」

間を置かず、続けます。

「奏者が見るのは指揮者の顔ではない、譜面台の楽譜だ」

それだけ言うと、道具を置いて、工房の隅へ戻っていきました。答えは、示されないままです。

私は指図役のコードを手に、アンのところへ向かいました。アンは扇を広げたまま、紙面に目を落とし、しばらく黙って読んでいましたが、やがて扇の先で、三つの分かれ道が並んだ箇所を、こつん、こつんと叩きました。一瞥するなり、辛辣な声が返ってきます。

「あなた、二人分の糸を両手いっぱいに握りしめて、絡めているだけよ。人形遣いが両手を使い切ったら、その先はどうするつもり?」

「でも」私は思わず言い返しました。「一回目は、ちゃんと動いたのに……」

「一回きりなら、行き当たりばったりでも“たまたま”うまく動いて見えるものよ。同じことをもう一度させて、初めて馬脚を露わすの」

言葉が、すとんと腑に落ちました。私は、二人の間に自分から割り込んで、両方の内側を覗き込んで指図する必要などなかったのです。二人が見られる場所に、たった一枚の記録を置いて、それぞれが自分の持ち場だけを書き込めばいい。指揮者は、奏者の指を掴まない。譜面を示すだけだ。

作業台に向き直り、紙を新しく一枚広げます。頭の中で組み立て直すより先に、まず手を動かしたほうが早い性分でした。古い指図役の三つの分かれ道を、一つずつ書き写しながら、どこが悪かったのかを確かめていきます。なぜ、一回目はうまくいって、二回目で壊れたのか。まず、そこに正直に向き合わなければなりません。旧い仕立てを、お見せします。

before.py

 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
52
53
54
55
56
57
58
59
60
61
62
# file: agents/code-puppeteer/tests/multi-agent-orchestration/before.py
"""指図役StageDirectorによる二体の人形の内部フラグ直接操作(Before): リセット漏れ。

照明担当のランプ人形と大道具担当の書割人形、二体の内部フラグ(is_down/has_moved)を
StageDirectorが外から直接読み書きする。1回目の場面転換はどの分岐も条件が噛み合って
正しく完了するが、3つ目の分岐でhas_movedのリセットを書き忘れているため、2回目に
入った瞬間、どの分岐の条件も二度と噛み合わずtick()は恒久的にFalseを返し続ける
(核心公理=繰り返しに耐える状態管理は、状態の置き場所と読み書きの規律を一箇所に
決めて初めて成立する、への違反)。
"""
import sys
import os

# sys.path に `agents/code-puppeteer` を追加して `base.py` から `BaseAgent` をインポートする
current_dir = os.path.dirname(os.path.abspath(__file__))
code_puppeteer_dir = os.path.abspath(os.path.join(current_dir, "../.."))
if code_puppeteer_dir not in sys.path:
    sys.path.append(code_puppeteer_dir)

from base import BaseAgent


class LightPuppet(BaseAgent):
    """照明担当のからくり人形。is_down は自分の今の姿を示す値のはずが、外から自由に読み書きされる"""

    def __init__(self) -> None:
        super().__init__(
            name="石橋を叩くランプ人形",
            system_prompt="大道具の支度が整うのを確かめてから、灯りを動かしなさい。",
        )
        self.is_down: bool = False


class StagehandPuppet(BaseAgent):
    """大道具担当のからくり人形。has_moved も同様に外部から直接操作される"""

    def __init__(self) -> None:
        super().__init__(
            name="腰の重い書割人形",
            system_prompt="灯りが変わるのを確かめてから、大道具を動かしなさい。",
        )
        self.has_moved: bool = False


class StageDirector:
    """コレットが書いた、両人形の内部フラグを直接読み書きする指図役"""

    def __init__(self, light: LightPuppet, stagehand: StagehandPuppet) -> None:
        self.light = light
        self.stagehand = stagehand

    def tick(self) -> bool:
        if not self.light.is_down and not self.stagehand.has_moved:
            self.light.is_down = True  # 灯りを落とす(1歩目)
            return True
        if self.light.is_down and not self.stagehand.has_moved:
            self.stagehand.has_moved = True  # 大道具を動かす(2歩目)
            return True
        if self.light.is_down and self.stagehand.has_moved:
            self.light.is_down = False  # 灯りを戻す(3歩目)
            return True  # ここで has_moved のリセットを忘れている
        return False  # 押し付け合ったまま、どちらも動けない

短いコードです。短くて、そして三つ目の分かれ道――self.light.is_down = Falseのすぐ後――に、すべての欠陥が詰まっています。灯りを戻したその直後、self.stagehand.has_movedを元に戻す一行が、ありません。

一回目の場面転換は、この抜け落ちに一度も触れずに終わります。is_downFalse→True→True→Falsehas_movedFalse→False→True→Trueと、三つの分かれ道すべてが順番に、行儀よく条件を満たしていくからです。けれど二回目に入った瞬間、状態はis_down=False、has_moved=Trueのまま――三つの分かれ道のどれも、この組み合わせを想定していません。tick()は、それきりFalseを返し続けます。

机上の模擬公演でも、その通りになりました。

1
2
3
4
5
6
7
director = StageDirector(LightPuppet(), StagehandPuppet())
for _ in range(3):
    director.tick()  # 1回目の場面転換は、これで正しく完了する

results = [director.tick() for _ in range(10)]

self.assertEqual(results, [False] * 10)

一回目は、律儀に三手ともTrueを返します。ところが、続けて十回呼んでも、結果はすべてFalse。灯りも大道具の状態も、指一本動きません。過去に問題が起きなかったのは、そもそも場面転換を二度以上繰り返す機会が、これまで一度もなかったからです。しかも、これまでは私かマエストロの一声が、実質的な合図として二人の背中を押していました。今回、初めて人の声を挟まず自動でやらせようとして、ずっと寝ていた欠陥が、初めて目を覚ましたのです。

もちろん、抜け落ちていた一行――self.stagehand.has_moved = False――を書き足すだけでも、二回目はきっと動いたでしょう。けれど、それでは同じ種類の見落としを、また別の場所に仕込むだけです。もし三体目の人形――たとえば効果音を担当する人形――が加わったら、指図役の分かれ道はさらに増え、また別のどこかで、同じような戻し忘れが起きかねません。人形が増えるたびに、私が全員の内部フラグを、頭の中で正しく数え続けられる自信は、正直ありませんでした。

さて、編み直しです。

なぜ「二人の内部フラグを、私が直接読み書きする」という設計が壊れるのか――答えは、状態の記録がどこにもきちんと置かれていない、という一点に尽きます。二体の人形の「今どんな姿か」を示す値が、それぞれの内側に、外から自由に触れられる形で転がっている。一回きりの実行なら、行き当たりばったりの分かれ道でも、たまたま条件が噛み合って正しく見えます。けれど同じ手順をもう一度繰り返す段になって、戻し忘れがひとつでもあれば、以後どの分かれ道の条件も、二度と噛み合いません。

二人の人形と、私が書いた指図役。三者のあいだに、実際どんな糸が渡っていたのか、あらためて紙の上に描き起こしてみることにしました。

指図役StageDirectorから二本の糸が伸び、ランプ人形LightPuppetの内部フラグis_downと、書割人形StagehandPuppetの内部フラグhas_movedを、それぞれ外から直接読み書きしているBeforeの密結合構造図。糸は人形の枠を越えて内側まで届いている。二体の人形の間に直接の糸は一本もなく、互いの姿を見ていない

描いてみて、初めて分かりました。ランプ人形と書割人形は、お互いの姿を一度も見ていません。二人のあいだに立っていたのは私の指図役だけで、その両手は、それぞれの人形の内側――本来は人形自身しか触れないはずの値――に、遠慮なく突っ込まれていたのです。

行き詰まったときに、力ずくで両方を同時に動かしてしまうという、その場しのぎの手も、根本の解決にはなりませんでした。原因――状態の一貫した記録がどこにもないこと――を放っておいたまま、症状にだけ後手で手当てすると、今度は逆向きの事故が起きます。灯りが落ちきる前に、大道具の腕が動き出す。壊れ方の形が変わるだけで、内部状態を直接いじり回す設計そのものは、何も変わっていないのです。

要るのは、Blackboardパターン――複数の主体が互いの内部状態を直接参照せず、共有された記録を介してのみ間接的に協調する設計です。二人が見られる場所に、公開された一枚の記録を置き、各人形は自分の持ち場だけを書き込む。記録の変化を見て、次に動くべき担当を決める役目は、制御コンポーネント――共有された記録を読むだけで、両者の内部機構には一切触れない、ごく単純な部品――に任せます。

after.py

  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
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
# file: agents/code-puppeteer/tests/multi-agent-orchestration/after.py
"""進行表CueBoard + 指し矢run_cue_sequenceによる二体の人形の協調(After): Blackboard Pattern。

両人形はもう互いの内部フラグに触れない。共有の進行表(CueBoard)には「灯り」と
「大道具」それぞれの持ち場のフィールドだけがあり、各人形はcan_contributeで出番を
判断し、contributeで自分の持ち場だけを書き換える。指し矢(run_cue_sequence)は
進行表の状態だけを見て次の担当を選び、両者の内部機構には一切触れない。場面完了
ごとのリセットは指し矢側の一箇所に集約され、書き忘れが構造的に起きにくい。
Beforeと同じく人形の役目と場面転換3回の回数は変えていない(単一差分は状態の
置き場所と読み書き規律のみ)。
"""
import sys
import os
from typing import Literal, Protocol

from pydantic import BaseModel

# sys.path に `agents/code-puppeteer` を追加して `base.py` から `BaseAgent` をインポートする
current_dir = os.path.dirname(os.path.abspath(__file__))
code_puppeteer_dir = os.path.abspath(os.path.join(current_dir, "../.."))
if code_puppeteer_dir not in sys.path:
    sys.path.append(code_puppeteer_dir)

from base import BaseAgent


class CueBoard(BaseModel):
    """進行表: 灯りと大道具、それぞれの持ち場だけを書き込む共有状態"""

    lights: Literal["up", "down"] = "up"
    set_moved: bool = False


class CuePlayer(Protocol):
    """知識源の共通インターフェース。継承は要求せず、このメソッド形を満たせば適合する(構造的な型付け)"""

    def can_contribute(self, board: CueBoard) -> bool:
        """進行表を見て、自分の出番かどうかを判断する(相手の内部には触れない)"""
        ...

    def contribute(self, board: CueBoard) -> CueBoard:
        """自分の持ち場のフィールドだけを書き換えた進行表を返す"""
        ...


class LightPuppet(BaseAgent):
    """照明担当のからくり人形。Beforeと役目は同じだが、内部フラグは持たず進行表だけを見る"""

    def __init__(self) -> None:
        super().__init__(
            name="石橋を叩くランプ人形",
            system_prompt="大道具の支度が整うのを確かめてから、灯りを動かしなさい。",
        )

    def can_contribute(self, board: CueBoard) -> bool:
        return (board.lights == "up" and not board.set_moved) or (
            board.lights == "down" and board.set_moved
        )

    def contribute(self, board: CueBoard) -> CueBoard:
        board.lights = "down" if board.lights == "up" else "up"
        return board


class StagehandPuppet(BaseAgent):
    """大道具担当のからくり人形。Beforeと役目は同じだが、内部フラグは持たず進行表だけを見る"""

    def __init__(self) -> None:
        super().__init__(
            name="腰の重い書割人形",
            system_prompt="灯りが変わるのを確かめてから、大道具を動かしなさい。",
        )

    def can_contribute(self, board: CueBoard) -> bool:
        return board.lights == "down" and not board.set_moved

    def contribute(self, board: CueBoard) -> CueBoard:
        board.set_moved = True
        return board


def run_cue_sequence(
    board: CueBoard,
    players: list[CuePlayer],
    scenes: int,
    max_rounds: int = 20,
) -> CueBoard:
    """指し矢: 進行表の状態だけを見て、次の担当を選ぶ。両人形の内部機構には一切触れない"""
    completed = 0
    for _ in range(max_rounds):
        if completed >= scenes:
            break
        candidates = [p for p in players if p.can_contribute(board)]
        if not candidates:
            break  # 指し矢が誰も指せない=収束しない異常。無理に進めない
        board = candidates[0].contribute(board)
        if board.lights == "up" and board.set_moved:
            completed += 1
            board.set_moved = False  # 次の場面転換のため進行表自身がリセットする(人形は関与しない)
    return board

can_contributecontribute――どちらも引数は進行表(CueBoard)だけです。相手の人形のインスタンスは、一切受け取っていません。ランプ人形も書割人形も、もう互いの内部を覗きません。進行表の公開されたフィールド(lightsset_moved)だけを見て、自分の出番かどうかを判断します。

CuePlayerは、typing.Protocol――継承を要求せず、指定したメソッドの型さえ満たしていれば適合とみなされる、構造的な型付けの仕組み――で定義しました。ランプ人形も書割人形も、CuePlayerを明示的に継承してはいません。ただ、同じ形のメソッドを備えているというだけで、指し矢からは同じ顔ぶれとして扱われます。

指し矢――run_cue_sequence――のやることは、素っ気ないほど単純です。進行表を読んで、can_contributeTrueを返す人形を集め、その一体にcontributeを任せる。灯りが戻り、大道具も動き終えたら、場面が一つ完了したとみなして、進行表自身がリセットする――このリセットは、指し矢側のたった一箇所にしかありません。仮に三体目の人形――たとえば効果音を担当する人形――が加わったとしても、その人形にcan_contributecontributeさえ実装させれば済み、指し矢自身のこのリセット処理には、指一本触れずに済みます。書く場所が三つから一つに減った、というだけの話ではありません。場面の完了を判定する責任と、個々の人形が自分の出番を判断する責任とが、構造として初めて分かれた、ということでした。

言葉で辿るだけでは、まだ像がぼんやりしています。今度は、進行表を挟んで三者がどう繋がっているのかを、同じように紙の上へ描き起こしてみました。

中央の共有進行表CueBoardにlights(灯りの持ち場)とset_moved(大道具の持ち場)の二つの欄があるAfterの疎結合構造図。ランプ人形LightPuppetと書割人形StagehandPuppetはcan_contributeで進行表を読んで出番を判断し、contributeでそれぞれ自分の持ち場の欄だけを書き換える。指し矢run_cue_sequenceは進行表を読むだけで、人形へは出番を指すだけ。誰も相手の内部には糸を伸ばしていない

描き上げてみると、指図役が両手を人形の内側へ突っ込んでいた、あの絵とはまるで違う姿になっていました。指し矢も、二体の人形も、互いの内側には一本の糸も伸ばしていません。誰もが見える進行表の上にだけ、みんなの糸が集まっている――それだけの違いでした。

改めて数え上げれば、灯りを落とす・戻す、大道具を動かす、という各人形の役目そのものも、場面転換を三回繰り返すという回数も、Before/Afterで一字も変えていません。system_promptの文言さえ、そのままです。変わったのは、状態をどこに置き、誰がそれを読み書きできるかという規律だけでした。

三度の場面転換を、進行表越しに追い直してみます。Beforeの足取りは、一度きりの周回を終えると、出口のない箱に迷い込みます。Afterの足取りは、同じ形の輪をそのまま繰り返せる――この違いが、模擬公演でも、そのまま数字に表れました。

1
2
3
4
5
6
light, stagehand = LightPuppet(), StagehandPuppet()
board = CueBoard()
run_cue_sequence(board, [light, stagehand], scenes=3)

# 誰がいつ書き換えたか、因果順序を記録するスパイを挟んで確認する
self.assertEqual(log, [light.name, stagehand.name, light.name] * 3)

三場面ぶん、「灯り→大道具→灯り」の順が、一度も崩れずに三回繰り返されることを確かめました。誰がいつ進行表を書き換えたか――この並び順が一度でもずれていれば、出番判定の分かれ道のどこかが壊れている証拠になります。もう一つ、意地悪な確認もしました。三回を完走した進行表に、同じ顔ぶれ・同じscenes=3で、もう一度run_cue_sequenceを差し向けたのです。

1
2
3
4
5
6
board = run_cue_sequence(CueBoard(), [LightPuppet(), StagehandPuppet()], scenes=3)
board_before_second_call = board.model_copy()

board = run_cue_sequence(board, [LightPuppet(), StagehandPuppet()], scenes=3)

self.assertEqual(board, board_before_second_call)

これは、正直に種明かしをしておかなければなりません。completedrun_cue_sequenceが呼ばれるたびにゼロから数え直す、その場限りの変数です。だから二度目の呼び出しは、「もう終わっているから何もしない」と即座に打ち切っているわけではなく、内部では三場面ぶんをもう一度フルに周回しています。ただ、進行表が完了直後と同じ値から出発するので、同じ道筋をたどって、同じ着地点に静かに戻ってくる――暴走もせず、値の食い違いも起きません。即座に止まる仕組みではなく、もう一周させても同じ収束点に戻ってくる、という違う種類の安心でした。

各人形が自分の持ち場しか書き換えていないことも、確かめておきます。

1
2
3
4
board = CueBoard(lights="up", set_moved=False)
LightPuppet().contribute(board)

self.assertEqual(board.set_moved, False)  # ランプ人形はset_movedに触れない

進行表に、そもそも不正な値を渡そうとすればどうなるか――CueBoard(lights="dim")のように、Literalにない文字列を渡すと、PydanticのValidationErrorが、きちんと撥ねてくれることも確認しました。分類の土台となる型判定が、正しく働いている証です。

似た仕組みを、以前――一つの入力を、どのエージェントに振り分けるか決めた回でも見た気がする、と思われるかもしれません。あのときの案内役は、一つの依頼を受け取って、ふさわしい行き先へ一度だけ渡せば、それで役目を終えました。今回はそれとは違います。ランプ人形と書割人形は、一度渡して終わりの相手ではありません。同じ進行表を、場面が完成するまで何度も読み書きしながら、時間をかけて舞台を仕上げていく――単発の割り振りではなく、継続する協調でした。

回数の上限――max_rounds――にも、念のため触れておきます。ずっと前、迷路から出られなくなった探鉱人形に、考える回数の上限を設けた回がありました。あのときは、回数の上限そのものが答えでした。今回のmax_roundsは、あくまで保険です。指し矢の本来の終わり方は、場面の完了数――completed――が、目当てのscenesに届いた時点で、輪の先頭で静かに気づいて抜けるだけです。「誰も出番を指せなくなったら終わる」という分かれ道も用意してはありますが、正直に言えば、今回のランプ人形と書割人形の組み合わせでは、この道を通ることは一度もありません。どちらか一方には、必ず出番が回ってくるように、二人のcan_contributeを噛み合わせて作ったからです。この分かれ道は、たとえば人形が一体、うっかり顔ぶれから抜け落ちていたときのような、本来あってはならない事態のための保険であり、もしここへ迷い込めば、completedは目当てに届かないまま、進行表はそのときの姿で静かに返ります。例外は上がりません。

正直に、留保も置いておきます。持ち場のフィールドだけを書くという規律は、Pythonの型が強制してくれるものではありません。もし将来、別の人形が他人の持ち場を直接書き換えるようなことがあれば、同じ問題が形を変えて戻ってくるでしょう。指し矢の選び方――今回は候補の先頭を選ぶだけの、単純な仕組みでした――も、それで常に十分とは限りません。候補が三体、四体と増えれば、もっと丁寧な選び方が要る場面も出てくるはずです。何場面まで無事に進んだのかを、呼び出す側がどう確かめるのかも、今回はまだ、進行表の中身を見比べる以上の仕組みを用意していません。

第4幕: 開演と調和 ── 指し矢が、迷わず振れる

本番当日。フェアファクス女史の一座と、この劇場の人形たちが、初めて肩を並べました。

三度の場面転換――灯りが落ち、大道具が入れ替わり、灯りが戻る――が、一度も詰まることなく、滑らかに続きます。ランプ人形と書割人形の「お先にどうぞ」「いや、そちらから」は、進行表の指し矢がひとりでに次の持ち場を指し示すたび、迷いなく短く交わされては、すぐに次の一手へと繋がっていきました。客席からは、暗転のたびに小さなどよめきが起き、灯りが戻るたびに、まったく違う景色が広がっていることに、旅の一座の団員たちも目を丸くしていました。舞台袖から見守る私は、指し矢が振れるたびに、小さく頷いていました。

幕が下りたあと、フェアファクス女史が袖から出てきました。三度の場面転換を食い入るように見ていたのでしょう、興奮の残る様子で、満足げに笑います。

「見事だったわ。どこの継ぎ目でうちの一座が入っても、迷わず息が合う」

続けて、こう言い残しました。

「また大きい仕事を持ち込むかもしれないから、覚悟しておいてちょうだい」

謝礼の品は、何も残しませんでした。ただ、次の仕事の気配だけを置いて、颯爽と去っていきます。

「あの二人、息が合うと思ったら、今度は張り合いがなくてつまらないくらいね」

通りすがりにアンが、いつもの調子で軽口を叩きました。

マエストロは、指揮棒をそっと片付けながら、こちらを見ずに言いました。

「……譜面は、破れなかったな」

それだけ言って、いつもの作業へ戻っていきました。

思えば、この糸の調整期に入ってからというもの、私が向き合ってきたのは、いつも一体の人形の中身でした。増え続ける記憶をどう刈り込むか。壊れた相棒に、どう向き合うか。今回、初めて相手にしたのは、一体の中身ではなく、二体の間にある何もない空間――そこに何を置くかという問題でした。二人の間に割り込んで、両方の内側を握りしめる必要はなかったのです。見える場所に、一枚の記録を置くだけで、二人はちゃんと、自分の出番を見つけられました。

その夜、革表紙の「操り人形師の覚書」を開きます。九ページ目です。今夜のことを書き終えたところで、私は初めて棚からこれまでの束を取り出し、今夜のページと並べて見比べてみました。書く道具も、書き方も、少しずつ変わってきたことに気づいて、小さく笑って、束を仕舞います。

窓の外では、じきに夜が明けます。二日後に迫っていたはずの本番は、もう昨日のことになっていました。


🎭 操り人形師の覚書(Colette’s Puppeteer Log)

  • 調整したからくり(モジュール/関数名): StageDirector(Before: 両人形の内部フラグis_down/has_movedを直接読み書きし、場面完了時のリセットを1箇所書き忘れる)→ CueBoard + CuePlayercan_contribute/contribute)+ run_cue_sequence(After: 進行表の公開フィールドのみを介した疎結合協調、リセットは進行表側の一箇所に集約)
  • 思考のゼンマイ(思考素子/LLMの特性):
    • 各人形は「今どの状態か」を自分だけの内部フラグとして持ちうるが、それを外部が自由に読み書きできる設計は、1回きりの実行では偶然動いて見えても、繰り返しに耐えない
  • 操り糸の張り具合(運糸の法/プロンプトと制御):
    • PydanticのCueBoardを共有状態とし、typing.Protocolで定義したCuePlayercan_contribute/contribute)を各人形が実装、run_cue_sequenceが進行表の状態だけを見て次の担当を選ぶ構造
  • 舞台裏の絡まり(背伸びの過ち/避けるべき罠):
    • コレットが仲裁役として二体の人形の内部フラグに直接手を伸ばし、状態の一貫した記録をどこにも持たない設計にしてしまったこと。行き詰まりを力技(両方同時に動かす)で解こうとして、衝突寸前の事故を招いたことも含む
  • 模擬公演の記録(検証のポイント):
    • Beforeが1回目は成功し2回目で恒久停止すること、Afterが3回の場面転換をすべて完走すること、各人形が自分の持ち場のフィールドしか書き換えないこと、3回完走後に同じ条件でもう一度呼んでも暴走せず同じ着地点へ静かに戻ること、進行表への不正な値がpydanticに撥ねられること
  • マエストロの言葉:
    • 「指揮者は、奏者の指を掴んでは動かさぬ。譜面を示すだけだ」
comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。