Xで話題のこちらのポスト
確かに...。
「二度漬け禁止(にどづけきんし)」といえば、一度ソースに漬けた串カツをかじりもう一度ソースに漬けると共有のソースに唾液が入り衛生上よくないため禁止されている有名な串カツのルールです。
しかし、もし「二度漬け禁止」の厳密な定義を知らない機械は「齧る → 漬ける → 齧る」という行動に関してはルールに違反していないと判断してしまうかもしれません。
ちなみに、画像の機械は「キラーマシーン」という名前の「ドラゴンクエスト」というゲームに出てくるモンスターです。1ターンに2回行動するのが特徴です。
曖昧な言葉が生む「抜け穴」
ところで、この「二度漬け禁止」というルール、文章で表現するとどうなるでしょう?
例えば、架空の串カツ店Aではこのような説明をしていたとします。
ソースに漬けた後にかじった串カツを、再度ソースに漬けてはならない。
一見それっぽく聞こえますが、実はこの表現、抜け穴を生むかもしれません。どんな抜け穴かお気づきでしょうか...?
文章通り読むと「ディップ(漬け)後にバイト(かじる)した場合」のみ二度目のディップをしてはいけないようにも受け取れるかもしれません。つまり、先にかじってからソースに漬ける行為はこの文面だと直接は禁止されていないようにも読めてしまうかもしれません。
もちろん現実の世界で「串カツを先に一口かじってから残りをソースに漬ける」なんて暴挙をする人はいないと信じたいですが、ルールの書き方によってはそんなトリッキー(?)な二度漬けも「文章上はNGとは言われていない」と見えてしまうわけです。要はルールの曖昧さが招くバグですね。
大阪の串カツ文化を持ち出して何を言ってるんだと思うかもしれませんが、これはソフトウェア開発で言うところの「仕様の穴」のようなものです。ルール(仕様)の書き方が曖昧だと、人(やプログラム)は抜け道を見つけてしまうという問題があります...
バグだらけの二度漬け禁止ポリシーを実装
ここでは、串カツへの Dip (漬け) と Bite (一口かじる) の動作をそれぞれ "D" と "B" という文字列で表現し、これらのシーケンス(順序列)を検証します。
まずはバグっぽいルールを実現する buggy_policy 関数を作ります。この関数は一連の行動(例えば "DBD")を受け取り、その行動がルール上OKなら True、NGなら False を返すことにしましょう。先ほどの曖昧ルールに従えば、「ディップした後にバイトしていたら、その後のディップは禁止」というロジックになります。そのままコードにすると以下のようなイメージです。
例えば明らかにアウトなシーケンス "DBD"(漬けて→かじって→また漬ける)は、ちゃんとNG判定になるでしょうか?また、先ほど話題に出した抜け穴シーケンス "BDB"(先にかじって→漬けて→かじる)はどう判定されるでしょうか?
実行して確認します。
def buggy_policy(seq: str) -> bool:
"""
二度漬け禁止ルール(バグあり)
- 直前の Dip 後に Bite が起きたら、その串はもう Dip してはいけない
- 文字列は 'D' と 'B' のみで構成されていると想定
"""
bitten_after_last_dip = False
last = None
for ch in seq:
if ch == "D":
if bitten_after_last_dip:
return False # かじった直後の再 Dip は違反
last = "D"
elif ch == "B":
if last == "D":
bitten_after_last_dip = True
last = "B"
return True
from double_dip import buggy_policy
tests = [
"", # 何もしていない
"DDDBBB", # 正しく一口かじって終わり
"BD", # 先にかじってから漬ける(本来 NG)
"BDB", # かじり→漬け→かじり(本来 NG)
]
for seq in tests:
print(f"{seq!r:8} → {buggy_policy(seq)}")
上のコードを実行すると:
'' → True # まだ何もしていないので OK
'DDDBBB' → True # Dip だけ→Bite だけの順で終了、OK
'BD' → True # ❌ 抜け穴:先かじり後漬けでも「問題なし」と判定
'BDB' → True # ❌ 抜け穴:かじって漬けて再びかじっても通過
-
DDDBBBに対して True(OK)となり、一応それっぽく機能しました。しかし… -
BDやBDBに対しては True、つまり「問題なし(違反なし)」という結果が返ってきてしまいました...
コード上は確かに穴を突かれています。
もしキラーマシーンが串カツ屋に来たと想定すると...
キラーマシーンは1ターンに2回行動します。ゲーム内では同じ行動を2回しないこともありますが、仮に同じ行動を2回するとします。
同じく、BBDDBBの場合、違反なしとなってしまいました...
'DDBBDD' → False
'BBDDBB' → True
この結果は先述の通り、buggy_policy のロジックが「Dipした後にBiteしたらアウト」という状態をチェックしているためです。最初にBiteしてしまった場合、その時点では「直前にDipしていない」ためフラグが立たず、その後にDipしても「直前のDipの後にBite」が発生していないと認識されてOKが出てしまうわけですね。
ソースに漬けた後にかじった串カツを、再度ソースに漬けてはならない。
を逐語的に解釈した実装といえます。
これでは、キラーマシーンは2度漬けという違反行為を理解できないかもしれません。
二度漬け禁止ルールを定義してみる
二度漬け禁止ルールについては、噛んでから漬けるのは明らかにNGなのですが、
- 噛んでいなくとも2回以上漬ける行為も二度漬けに含むか?
については諸説あります。
そのため、以下の2つの世界線を用意しました
噛んでいなくとも2回以上漬ける行為も二度漬けに含む世界線
- ソースに漬ける行為を
D(dip)、串カツをかじる行為をB(bite) と表す -
S0(未かじり状態)では 最初の1回目のDをするとS0d(1回目のDip後)へ遷移 - 2回目の
Dを行った時点で違反(Reject)とする -
S0かS0dでBが出た瞬間にS1(かじり後の状態)へ遷移。以降はBのみ許可 -
S0dかS1でDが出るとReject(違反) - 文字列(行動列)の読み取りが途中でRejectに行かず、
S0、S0d、S1で正常終了すれば受理(=二度漬け禁止を守ったとみなす)
このように定義すると、状態遷移図は以下のようになります。
状態遷移が一意に定まるため、このオートマトンは決定性有限オートマトンになります。
二度漬け禁止ポリシーの実装
本来あるべきルールを実装してみましょう。
「じゃあ、0.5度はどうなんだ?」という屁理屈は存在しないものとします。
先ほどの buggy_policy のバグを修正したバージョンとして、strict_policy 関数を作ります。実装してみます。
def strict_policy(seq: str) -> bool:
"""
正しい二度漬け禁止ルール
- S0(まだかじっていない)では Dip は1回まで(2回目を行った時点で違反)
- Biteが出た瞬間に S1(かじり済)へ遷移
- S1では Dip が出たら即違反(False)
- S0 または S1 で終了すれば OK
"""
state = "S0" # 初期状態:まだかじっていない
for ch in seq:
if state == "S0":
if ch == "D":
state = "S0d" # 1回漬けたら「漬け済み状態」へ
elif ch == "B":
state = "S1" # かじったら状態遷移
else:
return False # 不正な文字
elif state == "S0d":
if ch == "D":
return False # 既に1回漬けているのに再度 Dip は違反
elif ch == "B":
state = "S1" # 漬けた串をかじったので S1 へ
else:
return False # 不正な文字
elif state == "S1":
if ch == "D":
return False # かじった後に Dip -> 違反
elif ch != "B":
return False # 不正な文字(想定外の記号)
# ch が "B" の場合は状態 S1 のまま継続
return True
この strict_policy では、一度でも D を行った後に再び D をしようとすると即 False(違反)を返すようにしています。また、一度かじった (B が出現した) 後はそれ以降 D を許しません。これで先ほどの抜け穴も塞げそうですね。
念のため、先ほどと同じシーケンスで新旧ポリシーの判定結果を比較してみましょう。
from double_dip import strict_policy
tests = [
"",
"DBBBBB",
"DDDBBB",
"BD",
"BDB",
]
for seq in tests:
print(f"{seq!r:8} → {strict_policy(seq)}")
'' → True
'DBBBBB' → True
'DDDBBB' → False
'DBD' → False
'BDB' → False
実行結果は予想通り:
-
buggy_policyでは True(抜け穴ルールだと許されてしまう)だったケースも、 -
strict_policyでは False(当然NG判定)になっています。
無事に差異が確認できました。例えば「先にかじってから漬ける」という意味不明な二度漬け攻撃もしっかり検出できますし、DDDBBB のように噛んでいなくても複数回漬ける行為もきちんとNGになります。
キラーマシーンが来店しても...
'DDBBDD' → False
'BBDDBB' → False
しっかりとお断りできてますね!
正規表現でルールを表現すると以下のように書けます。こちらの方がより簡潔に定義できます。
import re
# 二度漬け禁止ルール(正規表現版)
PATTERN = re.compile(r'^D?B*$')
def strict_policy_re(seq: str) -> bool:
return bool(PATTERN.fullmatch(seq))
from double_dip import strict_policy_re
tests = [
"",
"DBBBBB",
"DDDBBB",
"DBD",
"BDB",
]
for seq in tests:
print(f"{seq!r:8} → {strict_policy_re(seq)}")
'' → True
'DBBBBB' → True
'DDDBBB' → False
'DBD' → False
'BDB' → False
pytestでテストを実行
色々なパターンの不届きものを弾けるかテストしてみます。
import pytest
from double_dip import strict_policy, strict_policy_re
@pytest.mark.parametrize(
"seq, expected",
[
# ── 基本形 ──
("", True), # 空列
("D", True), # 1文字(Dipのみ)
("B", True), # 1文字(Biteのみ)
("DDDBBB", False), # Dipだけ→Biteだけの順だがDipが複数回あるのでNG
("DB", True), # Dipしてからかじる(OK)
# ── 1文字ずつ交互 ──
("DB"*2, False), # D B D B (途中でかじってから再DipしているのでNG)
("DB"*5 + "D", False), # 奇数偶数に関係なく途中で二度漬けが発生すれば常にNG
# ── 先かじりスタイル ──
("BD", False), # かじってから漬ける(NG)
("BDB", False), # かじり→漬け→かじり(NG)
("B"*10 + "D", False), # 一度でも口をつけた串を後からDipしたら即失格
# ── Dip だけ / Bite だけ長大列 ──
("D"*1000, False), # Dipを複数回しているのでNG
("B"*1000, True), # Biteだけなら何回でもOK
# ── Biteしてから大量Dip ──
("B" + "D"*999, False), # 口をつけた後にDipを大量にしても当然NG
# ── ランダムだが合法な列 ──
("D" + "B"*73, True), # Dip1回+その後Bite複数(OK)
# ── ランダムだが不正な列(途中で Bite→Dip が混じる)──
("D"*10 + "B"*5 + "D", False), # 途中でかじってまた漬けている
("D" + "B"*3 + "D"*4 + "B"*2 + "D", False), # 複雑だがどこかで二度漬けが発生
# ── 交互境界 ──
("BD" + "B"*100, False), # 噛んでから漬けている箇所があるのでNG
],
)
def test_strict_cases(seq, expected):
assert strict_policy(seq) == expected
assert strict_policy_re(seq) == expected
pytest -q test_double_dip.py
................. [100%]
17 passed in 0.01s
良い感じに不届き者を弾けていそうです。
頑固親父を超える、例外は一切許さない最強のルールが爆誕したかもしれません。
これからは、このルールを串カツ屋に掲げましょう!
Rule as Code!
めでたしめでたし。
噛む前に2回以上漬ける行為は二度漬けに含まない世界線
- ソースに漬ける行為を
D(dip)。串カツをかじる行為をB(bite) -
S0(未かじり) ではDは何度繰り返しても OK -
S0でBが出た瞬間にS1(かじり後) へ遷移。以降はBのみ許可 -
S1でDが出ると即座にReject(違反) - 文字列(行動列)の読み取りが
S0またはS1で終了すれば受理(2度漬け禁止を守った)
とすると、このような状態遷移図となります。
これを二度漬け禁止ルールとします。
状態遷移が一意となるため、決定性有限オートマトンとなります。
二度漬け禁止ポリシーの実装
本来あるべきルールを実装してみましょう。
「じゃあ、0.5度はどうなんだ?」という屁理屈は存在しないとします。
先ほどの buggy_policy に対して、こちらはバグ修正版ということで strict_policy と名付けましょう。実装してみます。
def strict_policy(seq: str) -> bool:
"""
正しい二度漬け禁止ルール
- S0(まだかじってない)では何度Dipしてもよい
- Biteが出た瞬間にS1(かじり済)へ遷移
- S1ではDipが出たら即違反(False)
- S0またはS1で終了すればOK
"""
state = "S0" # 初期状態:まだかじっていない
for ch in seq:
if state == "S0":
if ch == "B":
state = "S1" # かじったら状態遷移
elif ch != "D":
return False # 不正な文字
elif state == "S1":
if ch == "D":
return False # かじった後にDip → 違反
elif ch != "B":
return False # 不正な文字
return True
この strict_policy では、一度でも B が出現した後に D が来たら即座に False(違反)を返すようにしています。これなら先ほどの抜け穴も塞げそうですね。
念のため、先ほどと同じシーケンスで新旧ポリシーを比較してみましょう。
from double_dip import strict_policy
tests = [
"",
"DDDBBB",
"BD",
"BDB",
]
for seq in tests:
print(f"{seq!r:8} → {strict_policy(seq)}")
'' → True
'DDDBBB' → True
'BD' → False
'BDB' → False
実行結果は予想通り:
- buggy_policy: True(抜け穴ルールだと許されてしまう)
- strict_policy: False(当然NG判定)
となり、無事に差異が確認できます。「先にかじってから漬ける」という意味不明な二度漬け攻撃も検出できてそうです
キラーマシーンが来店しても...
'DDBBDD' → False
'BBDDBB' → False
しっかりとお断りできてますね!
正規表現で書くと、以下のようになります。こちらの方がより簡潔に書けます。
import re
# 二度漬け禁止ルール
PATTERN = re.compile(r'^D*B*$')
def strict_policy_re(seq: str) -> bool:
return bool(PATTERN.fullmatch(seq))
from double_dip import strict_policy_re
tests = [
"",
"DDDBBB",
"BD",
"BDB",
]
for seq in tests:
print(f"{seq!r:8} → {strict_policy_re(seq)}")
'' → True
'DDDBBB' → True
'BD' → False
'BDB' → False
pytestでテストを実行
色々なパターンの不届きものを弾けるかテストしてみます
import pytest
from double_dip import strict_policy, strict_policy_re
@pytest.mark.parametrize(
"seq, expected",
[
# ── 基本形 ──
("", True), # 空列
("D", True), # 1 文字
("B", True),
("DDDBBB", True), # 典型 OK
("DB", True),
# ── 1 文字ずつ交互 ──
("DB"*2, False), # D B D B
("DB"*5+"D", False), # サイズが奇数偶数でも常に NG
# ── 先かじりスタイル ──
("BD", False),
("BDB", False),
("B"*10 + "D", False), # 口付け後に 1 回でも Dip で失格
# ── Dip だけ / Bite だけ長大列 ──
("D"*1000, True),
("B"*1000, True),
# ── Bite してから大量 Dip ──
("B" + "D"*999, False),
# ── ランダムだが合法な列 ──
("D"*42 + "B"*73, True),
# ── ランダムだが不正な列(途中で Bite→Dip が混じる)──
("D"*10 + "B"*5 + "D", False),
("D" + "B"*3 + "D"*4 + "B"*2 + "D", False),
# ── 交互境界 ──
("BD" + "B"*100, False),
],
)
def test_strict_cases(seq, expected):
assert strict_policy(seq) == expected
assert strict_policy_re(seq) == expected
pytest -q test_double_dip.py
................. [100%]
17 passed in 0.01s
良い感じに弾けてそうです
頑固親父を超える例外は一切許さない最強のルールが爆誕したかもしれません
これからは、このルールを串カツ屋に掲げましょう!
Rule as Code!
めでたしめでたし
まとめ
今回は、大阪名物の串カツ文化「二度漬け禁止」を題材に、ルールの曖昧さによるバグをPythonでシミュレーションしてみました。結果はご覧の通り、いい加減な仕様だと想定外の動きを許してしまうという、ソフトウェア開発ではおなじみ(?)の教訓が得られましたね。
たかが食事のマナー・ルールと侮るなかれ、仕様は明確に、バグのないように定義しないといけないというのは何事にも通じるようです...。
気をつけていきましょう!
では👋
余談
「二度漬け禁止」の例はシンプルですが、現実世界のルールは関係者・状況・例外が多すぎるが故に、しばしば自然言語でしか定義できません。しかし、自然言語には曖昧さが残りやすいため、国際契約では英語 + 他言語の二重記載で相互に補完し合う――といったテクニックが取られることもあります。
こうした曖昧さを減らすため、法律そのものを「プログラム」として厳密記述しようとする試みは世界各地で続いています。たとえば、1980年代には英国国籍法を Prolog でモデル化した研究が登場しました1。
また、法律を扱うために開発されたOCamlベースのCatalaというプログラミング言語やNumpyによるベクトル演算で社会制度を記述するOpenFiscaというOSSも存在するようです。
複雑な規制を扱う領域ほど、形式手法や専用DSLの価値は高まります。とはいえ、最終的な運用現場ではより人間の判断が絡むため、「コード化されたルール」と「人間の解釈・裁量」をどうバランスさせるかが今後の大きなテーマと言えそうです。

