Fixedpoint ― 再帰的なルールで到達可能性を解く#
この章で学ぶこと#
Z3 の Fixedpoint は、再帰的なルールを扱うエンジンです。「A から B に行けて、B から C に行けるなら、A から C に行ける」といった推論を、そのままルールとして書けます。
ルール(
rule)と事実(fact)で関係を書くquery()で「成り立つか」を聞く到達できるとき、実際の経路をどう取り出すか
デフォルトのエンジンはボトムアップ型の Datalog です。Datalog は関係データベースの問い合わせ(SQL の再帰クエリ)に似ていて、与えた事実からルールを繰り返し適用し、新しい事実を増やしていきます。
from z3 import *
例1:グラフの到達可能性#
次の無向グラフで、頂点1から頂点6へ行けるかどうかを調べます。
flowchart LR
1 --- 2
1 --- 5
5 --- 2
4 --- 5
2 --- 3
4 --- 3
4 --- 6
使う述語は3つです。
edge(a, b)…aとbの間に辺がある(事実として与える)connect(a, b)…aからbへ直接移動できる(辺があれば双方向に成り立つ)path(a, b)…aからbへ到達できる(再帰的に決まる)
fp = Fixedpoint()
fp.set(engine='datalog') # Datalog エンジンを使う
s = BitVecSort(8) # 頂点の番号は8ビットで表す
connect = Function('connect', s, s, BoolSort())
edge = Function('edge', s, s, BoolSort())
path = Function('path', s, s, BoolSort())
a, b, c = Consts('a b c', s)
fp.register_relation(path, edge, connect)
fp.declare_var(a, b, c)
# ルール:辺があれば直接移動できる(無向なので両方向)
fp.rule(connect(a, b), edge(a, b))
fp.rule(connect(a, b), edge(b, a))
# ルール:直接移動できるなら到達できる
fp.rule(path(a, b), connect(a, b))
# ルール:a→b 移動できて、b→c 到達できるなら、a→c 到達できる
fp.rule(path(a, c), [connect(a, b), path(b, c)])
# 事実:グラフの辺
edges = [(1, 2), (1, 5), (5, 2), (4, 5), (2, 3), (4, 3), (4, 6)]
for p, q in edges:
fp.fact(edge(p, q))
コードの説明#
1. エンジン
fp = Fixedpoint()
fp.set(engine='datalog')
engine='datalog' で Datalog エンジンを選びます。
2. 頂点の型
s = BitVecSort(8)
頂点の番号は8ビットのビットベクトルで表します。Datalog は有限の型を必要とするので、BitVecSort や IntSort ではなく有限の型を使います。
3. 述語(関係)の定義
connect = Function('connect', s, s, BoolSort())
Function(名前, 引数の型..., 戻り値の型) で述語を作ります。「2つの頂点を受け取って真偽を返す関係」なので、戻り値は BoolSort() です。
4. 変数の宣言と述語の登録
fp.register_relation(path, edge, connect)
fp.declare_var(a, b, c)
register_relation() で述語をエンジンに登録し、declare_var() でルール内で使う変数を宣言します。
5. ルールと事実
fp.rule(path(a, c), [connect(a, b), path(b, c)])
rule(頭, 本体) は「本体がすべて成り立つなら、頭も成り立つ」というルールです。このルールは「a から b に移動でき、b から c に到達できるなら、a から c に到達できる」と読みます。自分自身(path)を本体に入れているのが再帰です。
fp.fact(edge(1, 2)) は、辺があることを事実として登録します。
問い合わせる#
print(fp.query(path(1, 6))) # 到達できるか
print(fp.query(path(1, 7))) # 存在しない頂点
sat
unsat
頂点1から6へは到達でき(sat)、頂点7へは到達できません(unsat)。この「成り立つかどうか」を答えるのが Fixedpoint の仕事です。
経路を再構成する#
「到達できる」は分かりましたが、どう行けばよいかはまだ分かりません。
Z3 の Datalog には、なぜその事実が成り立つかの**説明(explanations)**を出す機能があります(datalog.generate_explanations)。ところが、この機能は最近の Z3(この環境は 5.1.0)では不安定で、explanations are not supported with undefined predicates というエラーになったり、答えが unsat に化けたり、Z3 ごと落ちたりします。
そこで、この章では経路は自分で作ります。到達できるかどうかは Fixedpoint に聞き、具体的な経路は辺のリストを BFS(幅優先探索)すれば求まります。
from collections import deque
def bfs_route(edges, start, goal, undirected=True):
"""(from, to) のリストから、最短の経路を BFS で作る"""
neighbors = {}
for p, q in edges:
neighbors.setdefault(p, []).append(q)
if undirected:
neighbors.setdefault(q, []).append(p)
prev = {start: None}
queue = deque([start])
while queue:
cur = queue.popleft()
if cur == goal:
break
for nxt in neighbors.get(cur, []):
if nxt not in prev:
prev[nxt] = cur
queue.append(nxt)
if goal not in prev:
return None
route = [goal]
while prev[route[-1]] is not None:
route.append(prev[route[-1]])
return route[::-1]
print(bfs_route(edges, 1, 6))
[1, 5, 4, 6]
経路は 1 → 5 → 4 → 6 です。edge(4, 5) は無向なので 5→4 に使えます。
例2:3つのバケツ問題#
8リットル、5リットル、3リットルのバケツがあります。最初は8リットルのバケツだけが一杯で、あとは空です。2つのバケツに4リットルずつ入れるには、どう注げばよいでしょうか。
これは「状態を頂点、注ぐ操作を辺とするグラフの到達可能性」と見なせます。まず、ありうる状態と、状態間の移行を Python で全部作ります。
水の量の合計はいつも8リットル
移行は「あるバケツから別のバケツへ注ぐ」だけ
注ぐと、注ぎ先がいっぱいになるか、注ぎ元が空になるかのどちらか
from itertools import product
sizes = (8, 5, 3)
# ありうる状態(合計が8リットル)
states = [(a, b, c)
for a, b, c in product(*[range(size + 1) for size in sizes])
if a + b + c == 8]
# 1回の注ぎで移れる状態の組
transitions = []
for s1, s2 in product(states, states):
changed = [(size, old, new) for size, old, new in zip(sizes, s1, s2) if old != new]
if len(changed) != 2:
continue # 変わるバケツはちょうど2つ(注ぎ元と注ぎ先)
if any(new == size or new == 0 for size, old, new in changed):
transitions.append((s1, s2)) # 注ぎ先が満杯 or 注ぎ元が空
print(len(states), "通りの状態、", len(transitions), "通りの注ぎ方")
24 通りの状態、 106 通りの注ぎ方
次に、この遷移を pour 述語の事実として登録し、plan 述語で「初期状態から目標状態への手順があるか」を推論します。
pour(a, b, c, a2, b2, c2)… 状態(a, b, c)から(a2, b2, c2)へ1回で移れるplan(a, b, c, a2, b2, c2)… 状態(a, b, c)から(a2, b2, c2)へ何回かで移れる
fp = Fixedpoint()
fp.set(engine='datalog')
s = BitVecSort(5)
pour = Function('pour', s, s, s, s, s, s, BoolSort())
plan = Function('plan', s, s, s, s, s, s, BoolSort())
a, b, c, a1, b1, c1, a2, b2, c2 = Consts(['a', 'b', 'c', 'a1', 'b1', 'c1', 'a2', 'b2', 'c2'], s)
fp.register_relation(pour, plan)
fp.declare_var(a, b, c, a1, b1, c1, a2, b2, c2)
# 1回で移れるなら、plan も成り立つ
fp.rule(plan(a, b, c, a2, b2, c2), pour(a, b, c, a2, b2, c2))
# 1回移って、そこから plan があるなら、plan が成り立つ(再帰)
fp.rule(plan(a, b, c, a2, b2, c2), [pour(a, b, c, a1, b1, c1), plan(a1, b1, c1, a2, b2, c2)])
for s1, s2 in transitions:
fp.fact(pour(*s1, *s2))
start, goal = (8, 0, 0), (4, 4, 0)
%time print(fp.query(plan(*start, *goal)))
sat
CPU times: total: 0 ns
Wall time: 1.49 ms
sat なので、8,0,0 から 4,4,0 へ移る手順があります。
手順は、transitions(辺のリスト)を BFS すれば求まります。例1 の bfs_route() がそのまま使えます(こちらは有向なので undirected=False)。
route = bfs_route(transitions, start, goal, undirected=False)
for s1, s2 in zip(route, route[1:]):
print(s1, "->", s2)
(8, 0, 0) -> (3, 5, 0)
(3, 5, 0) -> (3, 2, 3)
(3, 2, 3) -> (6, 2, 0)
(6, 2, 0) -> (6, 0, 2)
(6, 0, 2) -> (1, 5, 2)
(1, 5, 2) -> (1, 4, 3)
(1, 4, 3) -> (4, 4, 0)
7回の注ぎで 4,4,0 に到達しました。人間が解くと試行錯誤が必要な問題も、状態と遷移さえ書き出せば、あとは機械的に解けます。
登録したルールと事実も確認できます。
for rule in fp.get_rules()[:5]:
print(rule)
print("...")
ForAll([A, B, C, D, E, F],
Implies(pour(A, B, C, D, E, F),
plan(A, B, C, D, E, F)))
ForAll([A, B, C, D, E, F, G, H, I],
Implies(And(plan(D, E, F, G, H, I),
pour(A, B, C, D, E, F)),
plan(A, B, C, G, H, I)))
pour(0, 5, 3, 3, 5, 0)
pour(0, 5, 3, 5, 0, 3)
pour(1, 4, 3, 0, 5, 3)
...
例3:pour をルールにする#
例2 では、Python ですべての遷移を計算して pour を事実として登録しました。こんどは、pour をルールで書きます。ありうる状態 state(a, b, c) を事実として与え、注ぎ方の条件をルールにします。
条件は「変わったバケツが2つ」で、どちらかが満杯か空になることです。
fp = Fixedpoint()
fp.set(engine='datalog')
s = BitVecSort(5)
state = Function('state', s, s, s, BoolSort())
pour = Function('pour', s, s, s, s, s, s, BoolSort())
plan = Function('plan', s, s, s, s, s, s, BoolSort())
a, b, c, a1, b1, c1, a2, b2, c2 = Consts(['a', 'b', 'c', 'a1', 'b1', 'c1', 'a2', 'b2', 'c2'], s)
fp.register_relation(state, pour, plan)
fp.declare_var(a, b, c, a1, b1, c1, a2, b2, c2)
fp.rule(plan(a, b, c, a2, b2, c2), pour(a, b, c, a2, b2, c2))
fp.rule(plan(a, b, c, a2, b2, c2), [pour(a, b, c, a1, b1, c1), plan(a1, b1, c1, a2, b2, c2)])
# 注ぎ方のルール:変わるバケツは2つ、どちらかが満杯か空になる
fp.rule(
pour(a, b, c, a1, b1, c1),
[
state(a, b, c),
state(a1, b1, c1),
Or(
And(a == a1, b != b1, Or(b1 == 0, b1 == 5, c1 == 0, c1 == 3)),
And(b == b1, a != a1, Or(a1 == 0, a1 == 8, c1 == 0, c1 == 3)),
And(c == c1, a != a1, Or(a1 == 0, a1 == 8, b1 == 0, b1 == 5)),
),
],
)
# ありうる状態(合計8リットル)を事実として登録
for p in states:
fp.fact(state(*p))
%time print(fp.query(plan(*start, *goal)))
sat
CPU times: total: 46.9 ms
Wall time: 38.3 ms
例2 と同じく sat です。pour をルールで書いても、状態を事実として与えれば同じ結論になります。この環境では 0.04 秒ほどでした(例2 は 1 ミリ秒ほど)。
pour のルールを読むときのポイントは、state(a, b, c) と state(a1, b1, c1) の両方を本体に入れていることです。注いだ後の状態も、ありうる状態でなければいけません。
例4:state もルールにする#
最後に、state もルールにします。バケツごとの取りうる量を bucket_a, bucket_b, bucket_c の事実として与え、state はその組み合わせ(合計8リットル)として導きます。
fp = Fixedpoint()
fp.set(engine='datalog')
s = BitVecSort(5)
bucket_a = Function('bucket_a', s, BoolSort())
bucket_b = Function('bucket_b', s, BoolSort())
bucket_c = Function('bucket_c', s, BoolSort())
state = Function('state', s, s, s, BoolSort())
pour = Function('pour', s, s, s, s, s, s, BoolSort())
plan = Function('plan', s, s, s, s, s, s, BoolSort())
a, b, c, a1, b1, c1, a2, b2, c2 = Consts(['a', 'b', 'c', 'a1', 'b1', 'c1', 'a2', 'b2', 'c2'], s)
fp.register_relation(state, pour, plan, bucket_a, bucket_b, bucket_c)
fp.declare_var(a, b, c, a1, b1, c1, a2, b2, c2)
fp.rule(plan(a, b, c, a2, b2, c2), pour(a, b, c, a2, b2, c2))
fp.rule(plan(a, b, c, a2, b2, c2), [pour(a, b, c, a1, b1, c1), plan(a1, b1, c1, a2, b2, c2)])
fp.rule(
pour(a, b, c, a1, b1, c1),
[
state(a, b, c),
state(a1, b1, c1),
Or(
And(a == a1, b != b1, Or(b1 == 0, b1 == 5, c1 == 0, c1 == 3)),
And(b == b1, a != a1, Or(a1 == 0, a1 == 8, c1 == 0, c1 == 3)),
And(c == c1, a != a1, Or(a1 == 0, a1 == 8, b1 == 0, b1 == 5)),
),
],
)
# state は「各バケツの量の組み合わせ」で、合計が8リットルになるもの
fp.rule(state(a, b, c), [bucket_a(a), bucket_b(b), bucket_c(c), a + b + c == 8])
for i in range(sizes[0] + 1):
fp.fact(bucket_a(i), str(i))
for i in range(sizes[1] + 1):
fp.fact(bucket_b(i), str(i))
for i in range(sizes[2] + 1):
fp.fact(bucket_c(i), str(i))
%time print(fp.query(plan(*start, *goal)))
sat
CPU times: total: 625 ms
Wall time: 614 ms
こちらも sat ですが、ずっと時間がかかりました。この環境では 0.6 秒で、例3 の 0.04 秒と比べて15倍ほどです。
state をルールにすると、pour のルールを適用するたびに state の組み合わせを考え直すことになります。その分だけ探索が増えるわけです。「ルールで書ける」ことと「速い」ことは別で、分かっている関係は事実として与えたほうが速いという例です。
まとめ#
使うもの |
役割 |
|---|---|
|
エンジンを作る |
|
Datalog エンジンを選ぶ |
|
述語を作る |
|
述語をエンジンに登録する |
|
ルールで使う変数を宣言する |
|
「本体なら頭」というルールを追加する |
|
事実を追加する |
|
成り立つか問い合わせる( |
|
登録したルールと事実を確認する |
到達可能性は「1回で移れる」+「再帰」の2つのルールで書ける
Fixedpoint が答えるのは「到達できるか」。具体的な経路は、辺のリストを BFS すれば作れる
説明(
generate_explanations)で経路を取り出す機能は、最近の Z3 では不安定なので、あてにしないほうがよい状態と遷移が分かっているなら、事実として与えるほうが速い。すべてルールで書くと柔軟だが遅くなる