Fixedpoint:用递归的规则推导可达性#
本章的内容#
Z3 的 Fixedpoint 是处理递归规则的引擎,像从 A 能到 B、从 B 能到 C 则从 A 能到 C 这样的推理,可以直接写成规则,默认的引擎是自底向上的 Datalog。
用
rule写出规则,用fact写出事实用
query()询问是否成立到达之后取出实际路径的办法
Datalog 与关系数据库的查询,即 SQL 的递归查询,很相似,从给定的事实出发反复应用规则,不断增加新的事实。这个过程会一直持续到没有新的事实可加为止,因此总能停机。
from z3 import *
例 1:图的连通性#
下面这个无向图中,考察顶点 1 能否到达顶点 6。
flowchart LR
1 --- 2
1 --- 5
5 --- 2
4 --- 5
2 --- 3
4 --- 3
4 --- 6
用到三个谓词。
edge(a, b):a与b之间有边,作为事实给出connect(a, b):a可以直接移动到b,只要有一条边就双向成立path(a, b):a可以到达b,由递归确定
connect 由 edge 推导出来,两个方向各写一条规则,因此无向图只需要存一份边。
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 只接受有限的类型,IntSort 这类无限的型用不了,因此这里选 BitVecSort:8 位只有 256 个取值,范围有限。
3. 谓词与关系的定义
connect = Function('connect', s, s, BoolSort())
用 Function(名字, 参数的类型..., 返回值的类型) 造出谓词。这是接收两个顶点、返回真假的二元关系,因此返回值取 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 的工作。query() 只给出真或假,不给路径也不给证明,与 Solver.check() 的做法相似。
重建路径#
知道能到达之后,还不知道具体的走法。
Z3 的 Datalog 有输出说明的功能,即 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 这个方向也可以使用。bfs_route() 只按边列表找最短路,与 query() 的结果互为印证。
例 2:三个水桶的问题#
有 8 升、5 升、3 升三个水桶,最初只有 8 升的桶是满的,其余都是空的,要让两个桶各装 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]
# 一次倒水能到达的状态对
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 与 plan 都是六元的谓词。
pour(a, b, c, a2, b2, c2):从状态(a, b, c)一次就能移到(a2, b2, c2)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)
# 一步能移,则 plan 也成立
fp.rule(plan(a, b, c, a2, b2, c2), pour(a, b, c, a2, b2, c2))
# 移一步之后还有 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.78 ms
结果是 sat,因此从 8,0,0 到 4,4,0 存在一组步骤。plan 只回答是否存在这样的步骤序列,不回答有几步。
步骤可以对迁移的列表 transitions 做 BFS 求出,例 1 的 bfs_route() 可以直接使用:例 1 的图无向,两个方向都要试,而倒水有先后,倒回去的那一步不算,因此这里要传 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)
七次倒水之后到达了 4,4,0,人手工解这类问题需要反复试探,而只要把状态与迁移写出来,剩下的部分就可以机械地求解。可能的状态总数是有限的:三个桶的容量确定之后,组合不超过 9×6×4 种。
登记过的规则与事实也可以查看。get_rules() 返回的正是登记时的写法,可以用来核对规则是否有遗漏。
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) 作为事实给出,把倒水的条件写成规则。两者的区别在于谁来枚举:前者由 Python 枚举,后者由引擎在推导时枚举。
条件是变化的桶只有两个,并且其中一个是满的或空的。写成规则时,这个条件展开成三条分支,每条对应一个桶作为源桶。
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: 62.5 ms
Wall time: 47.1 ms
把 pour 写成规则、再把状态作为事实给出,结果与例 2 相同,都是 sat,在这个环境里约 0.04 秒,而例 2 约 1 毫秒。
读 pour 的规则时要注意,state(a, b, c) 与 state(a1, b1, c1) 两者都放进了本体,倒水之后的状态也必须是一个可能出现的状态。少了后一个条件,规则就会推出并不存在的状态。
例 4:把 state 也写成规则#
最后把 state 也写成规则,即把每个桶可能的水量作为 bucket_a、bucket_b、bucket_c 的事实给出,再由它们的组合,合计 8 升,推导出 state。
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: 844 ms
Wall time: 832 ms
本例也是 sat,但耗时明显增加,在这个环境里是 0.6 秒,与例 3 的 0.04 秒相比约 15 倍。
把 state 写成规则之后,每次应用 pour 的规则都要重新考虑 state 的组合,探索的量就随之增加。规则越抽象,引擎在推导时要枚举的组合就越多。能写成规则与跑得快是两件事,本例说明已知的关系作为事实给出会更快。
小结#
使用的写法 |
作用 |
|---|---|
|
建立引擎 |
|
选择 Datalog 引擎 |
|
造出谓词 |
|
把谓词注册到引擎 |
|
声明规则中使用的变量 |
|
追加本体成立则头部成立的规则 |
|
追加事实 |
|
询问是否成立,返回 |
|
查看已登记的规则与事实 |
可达性可以用一次就能移动与递归这两条规则写出
Fixedpoint 回答的只是能否到达,具体路径对边的列表做 BFS 即可得到
用说明功能
generate_explanations取路径在较新的 Z3 中并不稳定,不宜依赖状态与迁移已知时,作为事实给出更快,全部写成规则虽然灵活却更慢