分离逻辑的后向证明
前一节将目标统一写为 [Gamma ?- p][Gamma ?- p],策略通过目标树将一个普通布尔命题分解为子目标。但 C* 的验证条件通常是分离逻辑蕴含:证明器不仅要处理命题,还必须拆分、选择和重组线性堆资源。本节在前一节通用目标树的基础上,介绍专门处理这类资源的目标表示与策略族,并结合前文介绍的 sllsll 谓词展开讨论。
我们围绕链表逆置例子中的一个引理展开讨论。在前向证明一节中,我们将 sll_unfoldsll_unfold 作为现成的定理复用;现在来证明这个引理,本节将其称为 unfold_sll_not_nullunfold_sll_not_null。
先回顾单向链表谓词 sll pt lsll pt l,它描述一个从地址 ptpt 开始的链表,其逻辑内容是整数列表 ll:
// struct list { int head; struct list *tail; },Tlist、Fhead、Ftail// 按“C* 中的分离逻辑”一节的方式注册PROOF thm sll_def = cst_new_rec_definition( "sll", get_theorem_by_name("list_RECURSION"), `(sll pt [] -|- fact(pt == 0i)) && (sll pt (h :: t) -|- fact(~(pt == 0i)) ** exists q. data_at (field_addr pt Tlist Fhead) Tint h ** data_at (field_addr pt Tlist Ftail) Tptr q ** sll q t)`, `:addr->(int)list->hprop`);
// struct list { int head; struct list *tail; },Tlist、Fhead、Ftail// 按“C* 中的分离逻辑”一节的方式注册PROOF thm sll_def = cst_new_rec_definition( "sll", get_theorem_by_name("list_RECURSION"), `(sll pt [] -|- fact(pt == 0i)) && (sll pt (h :: t) -|- fact(~(pt == 0i)) ** exists q. data_at (field_addr pt Tlist Fhead) Tint h ** data_at (field_addr pt Tlist Ftail) Tptr q ** sll q t)`, `:addr->(int)list->hprop`);
空链表用空指针表示;非空链表的头节点拥有 headhead 和 tailtail 字段,尾指针 qq 指向表示剩余列表 tt 的链表。 cst_new_rec_definitioncst_new_rec_definition 是示例用户库(userlib/qcp/veriftime.huserlib/qcp/veriftime.h)中的辅助函数:一次调用就能完成递归定义,并将新常量导出给引擎;PROOFPROOF 是标准库为 [[cst::proof]][[cst::proof]] 提供的简写。
unfold_sll_not_nullunfold_sll_not_null)。 如果 ptpt 非空且 sll pt lsll pt l 成立,那么 ll 必定具有 h :: th :: t 的形式,展开头节点后得到两个字段资源和一个后继链表:
forall l pt. ~(pt == 0i) ==> (sll pt l |-- exists h t q. fact(l == h :: t) ** data_at (field_addr pt Tlist Fhead) Tint h ** data_at (field_addr pt Tlist Ftail) Tptr q ** sll q t)forall l pt. ~(pt == 0i) ==> (sll pt l |-- exists h t q. fact(l == h :: t) ** data_at (field_addr pt Tlist Fhead) Tint h ** data_at (field_addr pt Tlist Ftail) Tptr q ** sll q t)这个引理看起来只需“展开一次定义”,实际证明却包含几个不同的步骤:先引入全称量词和纯前件 ~(pt == 0i)~(pt == 0i),将 sll pt l |-- ...sll pt l |-- ... 转为适合操作资源的目标形式;再对 ll 分类讨论,用矛盾解决空列表分支;在非空分支中展开 sllsll,解构存在量词和分离合取,为堆结论提供 hh、tt、qq 三个例证;最后简化纯事实,通过框架消除消去两侧共有的空间资源。
本节将依次完成这些步骤。首先要考虑的是:普通目标是否足以表达这种资源结构?
普通目标将蕴含作为一个整体处理。前一节中的普通目标是 [Gamma ?- p][Gamma ?- p]。引入 unfold_sll_not_nullunfold_sll_not_null 的外层全称量词和蕴含后,得到:
(本页的目标框仅作示意。在策略的实际打印输出中,普通目标分为 Assumptions:Assumptions: 和 Conclusion:Conclusion: 两部分,分离逻辑目标分为 Assumptions:Assumptions:、Antecedents:Antecedents: 和 Consequent:Consequent: 三部分。自动引入的假设也标为 HH;这里写成 P_ptP_pt,以便与下文的堆前件 HH 区分。)这是一个普通目标:整条蕴含 sll pt l |-- ...sll pt l |-- ... 被当作一个 boolbool 结论处理。目标树只知道这是一个待证命题,并不了解蕴含的内部结构。证明器无法像操作普通假设那样按标签操作堆资源;对目标的任何变换都可能破坏蕴含的形态。
分离逻辑目标显式表示资源结构。下文使用如下记号:
p, q : boolH, K, P, Q : hpropLambda : 有序且带标签的普通逻辑前件Delta : 有序且带标签的堆前件ant(Delta) = Delta 中堆断言之分离合取
p, q : boolH, K, P, Q : hpropLambda : 有序且带标签的普通逻辑前件Delta : 有序且带标签的堆前件ant(Delta) = Delta 中堆断言之分离合取
[Lambda; Delta ?- K][Lambda; Delta ?- K],表示普通逻辑判断 |Lambda| ?- (ant(Delta) |-- K)|Lambda| ?- (ant(Delta) |-- K)。其中,LambdaLambda 保存普通逻辑前件,这些前件可以任意复制或丢弃;DeltaDelta 保存堆资源,每一项都在使用后消耗;KK 是保留为一个整体的堆结论。打印时,两类前件分别显示在两个区域中:
回到链表引理,进入分离逻辑目标模式后得到:
现在,P_ptP_pt 是普通逻辑前件,可以用于重写和推导矛盾;HH 是堆资源,只能通过分离逻辑规则使用;堆结论则保留为一个整体。这一划分直接反映了分离逻辑证明的结构。
从蕴含进入:SL_MODESL_MODE。
最稳妥的做法是先将整个前件命名为 "H""H":
gnode_list gns = SL_MODE(gn, "H");
gnode_list gns = SL_MODE(gn, "H");
如果已知前件的结构,也可以在进入目标模式时一并解构。模式语言仅有五种构造:
| 模式 | 含义 |
|---|---|
HH |
将整个堆前件命名为 HH |
H_1 * H_2H_1 * H_2 |
拆分外层的分离合取 |
H_1 | H_2H_1 | H_2 |
拆分外层的析取,产生两条分支 |
x ? Hx ? H |
消去外层的存在量词,以新变量 xx 开启 |
[L][L] |
把该位置上的一条 fact(p)fact(p) 从堆中移入普通假设,记为 L: pL: p |
解析器从括号中读取结构,不设运算符优先级,因此复杂模式必须显式加括号:
SL_MODE(g, "H_1 * ((x ? H_2) | H_3)");
SL_MODE(g, "H_1 * ((x ? H_2) | H_3)");
从堆断言等式进入:EQ_SLTACEQ_SLTAC。另一种常见目标是两个堆断言相等。此时 SL_MODESL_MODE 不适用,应改用反对称性将等式拆分为两个有向蕴含。
两条分支各自有独立的标签命名空间,因此 L_1L_1 和 L_2L_2 可以使用同一名称。
进入分离逻辑目标模式后,证明器首先要处理 DeltaDelta 中已有的资源。堆资源与普通逻辑前件的根本区别在于线性:每个资源恰好使用一次,既不能随意复制,也不能随意丢弃。
解构外层结构。对单个带标签的堆前件,最常用的原子策略如下:
| 前件形态 | 策略 | 效果 |
|---|---|---|
L:(P ** Q)L:(P ** Q) |
HANT_SEP_SLTACHANT_SEP_SLTAC |
拆分为两个带标签的资源 |
L:(P || Q)L:(P || Q) |
HANT_DISJ_SLTACHANT_DISJ_SLTAC |
每个析取支产生一个子目标 |
L:(exists x. H)L:(exists x. H) |
HANT_EXISTS_SLTACHANT_EXISTS_SLTAC |
以新变量开启存在量词 |
L:HL:H |
HANT_RENAME_SLTACHANT_RENAME_SLTAC |
仅更改标签,不更改内容 |
这些策略都保留堆结论。例如,拆分分离合取可以抽象地表示为:
[Lambda; Delta_R,L_1:P,L_2:Q ?- K]------------------------------------------------ [Lambda; Delta,L:(P ** Q) ?- K]
[Lambda; Delta_R,L_1:P,L_2:Q ?- K]------------------------------------------------ [Lambda; Delta,L:(P ** Q) ?- K]
分离合取产生一个子目标(两种资源同时存在);析取产生两个子目标(状态可能满足任一侧);开启存在量词时,必须选取一个不会捕获目标中自由变量的新名称。
递归解构整个前件:
gnode_list HANT_DESTRUCT_SLTAC( const gnode gn, const char *L, const char *pattern);gnode_list AUTO_HANT_DESTRUCT_SLTAC(const gnode gn);
gnode_list HANT_DESTRUCT_SLTAC( const gnode gn, const char *L, const char *pattern);gnode_list AUTO_HANT_DESTRUCT_SLTAC(const gnode gn);
前者按给定模式解构;后者按顺序遍历上下文,递归拆分每个可见的 ****、|||| 和 existsexists,并在每个新产生的析取分支上继续,直到不再有这类外层结构。
将纯事实移到普通逻辑区域。要让 fact(p)fact(p) 参与普通逻辑重写、算术推理或矛盾推导,需要将它从堆区域移入 LambdaLambda:
gnode INTRO_FACT_SLTAC( const gnode gn, const const_cstr_list lbs);gnode AUTO_INTRO_FACT_SLTAC(const gnode gn);
gnode INTRO_FACT_SLTAC( const gnode gn, const const_cstr_list lbs);gnode AUTO_INTRO_FACT_SLTAC(const gnode gn);
INTRO_FACT_SLTACINTRO_FACT_SLTAC 按指定标签选取纯事实;自动版则选取当前堆前件中所有顶层的 fact(p)fact(p)。移入后,这些命题就可以像前一节的普通假设一样使用。
重写、魔术棒与矛盾分支的处理。另外还有三种常见操作:
HANT_CONV_SLTACHANT_CONV_SLTAC:以变换重写指定堆前件;WAND_MP_SLTACWAND_MP_SLTAC:从(P -* Q)(P -* Q)和资源PP产生资源QQ;CONTR_SLTACCONTR_SLTAC:当某个堆前件规范化为fact(false)fact(false)时解决该分支。
例如,在链表例子的空列表分支中,展开 sll pt []sll pt [] 得到 fact(pt == 0i)fact(pt == 0i)。用普通逻辑前件 ~(pt == 0i)~(pt == 0i) 重写后,资源变为矛盾,CONTR_SLTACCONTR_SLTAC 随即解决该分支。
分析堆前件回答的是“已有些什么”;操作堆结论回答的是“还需要构造什么”。结论 KK 保留为一个整体,使用什么策略取决于它的最外层结构。
分离合取划分资源。如果目标是 K_1 ** K_2K_1 ** K_2,两个子目标不能像普通逻辑合取那样各自继承全部资源。证明者必须指明哪些前件资源分配给左侧子目标,其余的则分配给右侧:
析取、存在量词与纯事实。其余用于堆结论的策略也可以按结论的结构分类:
| 结论形态 | 策略 | 证明者的选择 |
|---|---|---|
K_1 || K_2K_1 || K_2 |
DISJ1_SLTACDISJ1_SLTAC / DISJ2_SLTACDISJ2_SLTAC |
选择左或右析取支 |
exists x. Hexists x. H |
EXISTS_SLTACEXISTS_SLTAC |
提供例证 |
多个外层 existsexists |
LIST_EXISTS_SLTACLIST_EXISTS_SLTAC |
按顺序提供例证列表 |
P ** (exists x. Q)P ** (exists x. Q) |
EXISTS_PULL_SLTACEXISTS_PULL_SLTAC |
先将存在量词提至最外层 |
若干 fact(p)fact(p) |
PURE_SLTACPURE_SLTAC |
将纯事实分离为一个普通目标,保留其余部分 |
KK |
HCON_CONV_SLTACHCON_CONV_SLTAC |
以变换重写整个堆结论 |
例如:
[Lambda; Delta ?- H[w/x]]-------------------------------- EXISTS_SLTAC w [Lambda; Delta ?- exists x. H]
[Lambda; Delta ?- H[w/x]]-------------------------------- EXISTS_SLTAC w [Lambda; Delta ?- exists x. H]
其中例证的类型必须与存在量词变量的类型一致。
许多分离逻辑目标在语义上已经成立,只是两侧的语法形式有所不同:可能只有一侧展开了定义,可能还有纯事实尚未简化,也可能 **** 的结合方式或排列顺序不同。这类目标需要先规范化,再消去共同资源。
变换与清理。
gnode CONV_SLTAC(const gnode gn, const conv cv);gnode CONV_WITH_ASMP_SLTAC( const gnode gn, conv (*cvf)(thm_list), const thm_list ths);gnode CLEAN_SLTAC(const gnode gn);void EMP_SLTAC(const gnode gn);
gnode CONV_SLTAC(const gnode gn, const conv cv);gnode CONV_WITH_ASMP_SLTAC( const gnode gn, conv (*cvf)(thm_list), const thm_list ths);gnode CLEAN_SLTAC(const gnode gn);void EMP_SLTAC(const gnode gn);
CONV_SLTACCONV_SLTAC 依次重写堆结论和每个堆前件。CONV_WITH_ASMP_SLTACCONV_WITH_ASMP_SLTAC 额外将 LambdaLambda 中的普通逻辑前件作为假设定理传递给变换函数,适合“按定义展开再根据当前分支事实简化”的场景。CLEAN_SLTACCLEAN_SLTAC 删除 empemp、fact(true)fact(true) 等单位元并规范化其余部分。如果规范化后堆结论为 empemp 且每个堆前件都是 empemp 或平凡为真的事实,EMP_SLTACEMP_SLTAC 解决该目标。
框架消除。除 EMP_SLTACEMP_SLTAC 外,最常见的收尾方式是消去两侧的共同资源:
gnode FRAME_SLTAC( const gnode gn, const const_cstr_list labels);gnode AUTO_FRAME_SLTAC(const gnode gn);
gnode FRAME_SLTAC( const gnode gn, const const_cstr_list labels);gnode AUTO_FRAME_SLTAC(const gnode gn);
FRAME_SLTACFRAME_SLTAC 按标签选择堆前件,在堆结论的顶层多重集中找到与之相等的资源,再将两者同时消去(不解决目标);AUTO_FRAME_SLTACAUTO_FRAME_SLTAC 按前件顺序贪心地消去所有可匹配的资源,然后清理单位元,如果目标已变得平凡,就将其解决。
框架匹配按出现次数进行。如果前件中某个资源有两个副本而结论中只有一个,同一结论资源不能被匹配两次。堆资源按多重集处理,普通逻辑假设按集合处理,两者的关键区别就在这里。
进入分离逻辑目标模式、解构堆前件、清理单位元、引入纯事实、尝试框架消除,这些都是机械性步骤,也常常以相同顺序出现在证明的开头。因此,库将它们封装为一个组合策略。
这组步骤首先引入普通逻辑量词和前件,然后进入分离逻辑目标模式;接着解构可见的堆前件、清理平凡资源、将顶层的纯事实移到普通逻辑区域,最后尝试消去可直接匹配的框架资源。由于拆分析取可能产生分支,它返回当前所有尚未求解的叶节点,而非单个 gnodegnode。
现在,证明所需的原子策略都已介绍完毕,我们也知道了 AUTO_INIT_SLTACAUTO_INIT_SLTAC 在开头组合了哪些步骤。回到开篇的链表引理,可以将这些工具串联起来:
PROOF thm unfold_sll_not_null_proof(const term goal_tm) { gnode root = gnode_new_with_ccl(goal_tm); gnode gn = AUTO_INIT_SLTAC(root)[0]; gnode_list gns = CASES_TAC(gn, `l:(int)list`, "P_l"); gn = gns[0]; gn = CONV_WITH_ASMP_SLTAC( gn, rewrite_conv, THM_LIST(sll_def)); CONTR_SLTAC(gn, "H"); gn = gns[1]; gn = CONV_WITH_ASMP_SLTAC( gn, rewrite_conv, THM_LIST(sll_def)); gn = AUTO_HANT_DESTRUCT_SLTAC(gn)[0]; gn = LIST_EXISTS_SLTAC( gn, TERM_LIST(`a0:int`, `a1:(int)list`, `q:addr`)); gn = CONV_SLTAC(gn, simp_conv(thm_list_n(0))); gn = AUTO_FRAME_SLTAC(gn); return gnode_prove(root);}
PROOF thm unfold_sll_not_null_proof(const term goal_tm) { gnode root = gnode_new_with_ccl(goal_tm); gnode gn = AUTO_INIT_SLTAC(root)[0]; gnode_list gns = CASES_TAC(gn, `l:(int)list`, "P_l"); gn = gns[0]; gn = CONV_WITH_ASMP_SLTAC( gn, rewrite_conv, THM_LIST(sll_def)); CONTR_SLTAC(gn, "H"); gn = gns[1]; gn = CONV_WITH_ASMP_SLTAC( gn, rewrite_conv, THM_LIST(sll_def)); gn = AUTO_HANT_DESTRUCT_SLTAC(gn)[0]; gn = LIST_EXISTS_SLTAC( gn, TERM_LIST(`a0:int`, `a1:(int)list`, `q:addr`)); gn = CONV_SLTAC(gn, simp_conv(thm_list_n(0))); gn = AUTO_FRAME_SLTAC(gn); return gnode_prove(root);}
初始化:让目标显式表示资源。 AUTO_INIT_SLTACAUTO_INIT_SLTAC 引入外层量词和前件,并将目标转为显式表示资源的形式;运行后只剩一个尚未求解的叶节点,因此示例取返回列表的第一个元素。
对逻辑列表进行分类讨论。 CASES_TAC(gn, CASES_TAC(gn, l:(int)list, "P_l"), "P_l") 只改变普通逻辑前件,因此两个子目标均停留在分离逻辑目标模式中。空列表分支获得 l = []l = [];非空分支获得 l = a0 :: a1l = a0 :: a1。
空列表分支:展开后得到矛盾。用 sll_defsll_def 展开 sll pt []sll pt [],再用当前分支的逻辑前件化简。此时资源 HH 变为矛盾,CONTR_SLTACCONTR_SLTAC 便可解决该分支。
非空分支:展开、提供例证、框架消除。展开定义后,可以看到指针非空的事实、头字段和尾字段,以及后继链表的 sllsll 资源。AUTO_HANT_DESTRUCT_SLTACAUTO_HANT_DESTRUCT_SLTAC 将外层的 **** 和 existsexists 转化为各自带标签的独立资源。分类讨论和展开已经确定了三个例证,LIST_EXISTS_SLTACLIST_EXISTS_SLTAC 将它们一并提供;CONV_SLTACCONV_SLTAC 简化纯事实 l == a0 :: a1l == a0 :: a1;两侧剩余的空间资源逐项匹配,AUTO_FRAME_SLTACAUTO_FRAME_SLTAC 将它们逐一消去,解决目标。
最后调用 gnode_prove(root)gnode_prove(root)。此时树中的每个叶节点都已求解,因此可以自底向上重建证明,返回原始引理对应的定理。
AUTO_INIT_SLTACAUTO_INIT_SLTAC 展示了库如何封装一套稳定的机械性流程。用户也可以用同样的方式组合已有策略,为自己的证明构建作用范围更小、更有针对性的自动步骤。
如果每一步都产生单个子目标,可以直接串联:
gnode MY_CLEAN_FRAME_SLTAC(const gnode gn) { gnode next_gn = CLEAN_SLTAC(gn); next_gn = AUTO_INTRO_FACT_SLTAC(next_gn); next_gn = AUTO_FRAME_SLTAC(next_gn); return next_gn;}
gnode MY_CLEAN_FRAME_SLTAC(const gnode gn) { gnode next_gn = CLEAN_SLTAC(gn); next_gn = AUTO_INTRO_FACT_SLTAC(next_gn); next_gn = AUTO_FRAME_SLTAC(next_gn); return next_gn;}
但处理析取或分离合取结论的策略可能产生分支。此时,必须对当前所有叶节点应用下一步策略:
gnode_list gns = AUTO_HANT_DESTRUCT_SLTAC(gn);APPLY_ALL_TAC(CLEAN_SLTAC, gns);gns = gnode_leaves(gn);
gnode_list gns = AUTO_HANT_DESTRUCT_SLTAC(gn);APPLY_ALL_TAC(CLEAN_SLTAC, gns);gns = gnode_leaves(gn);
这里需要重新调用 gnode_leavesgnode_leaves 获取叶节点:上一步可能已经解决了某些目标,或产生了新分支,因此旧列表未必还对应子树中尚未完成的证明工作。
前一节已经介绍了通用的健全性机制:当策略展开一个目标时,它必须记录一个验证函数;子目标得到证明后,验证函数重建上层定理,gnode_provegnode_prove 将整棵树归约到根定理。分离逻辑策略复用同一机制。
分离逻辑目标 [Lambda; Delta ?- K][Lambda; Delta ?- K] 只是将命题 |Lambda| ?- (ant(Delta) |-- K)|Lambda| ?- (ant(Delta) |-- K) 的结构显式表示出来。当策略更改标签、拆分前件或选定结论时,其验证函数调用 proof_slproof_sl 中已经证明的前向规则来恢复原始蕴含。例如,框架消除在子目标中临时移除了共同资源,验证函数则用框架规则重建上层目标。
需要额外保证的是资源线性:普通逻辑前件按集合检查,堆前件则按多重集检查,计入每个资源的出现次数。同一资源不能被消耗两次。当标签、资源计数、变量新鲜性或类型不满足策略要求时,策略直接失败;它不会将尚未满足的条件当作事实。
- 普通目标将
H |-- KH |-- K整体当作一个命题;分离逻辑目标则将普通逻辑前件、带标签的堆前件和堆结论分开保存,直接反映资源结构。 SL_MODESL_MODE处理分离逻辑蕴含;EQ_SLTACEQ_SLTAC通过两条有向蕴含处理堆断言等式。HANT_*_SLTACHANT_*_SLTAC分析已有资源;INTRO_FACT_SLTACINTRO_FACT_SLTAC将纯事实移入普通逻辑区域;SEP_SLTACSEP_SLTAC及析取/存在量词策略构造堆结论。- 变换与清理是统一的;
FRAME_SLTACFRAME_SLTAC和AUTO_FRAME_SLTACAUTO_FRAME_SLTAC按出现次数消去共同资源。 AUTO_INIT_SLTACAUTO_INIT_SLTAC这样的组合策略适合处理机械性步骤;分支、例证和资源划分通常需要证明者显式输入。- 分离逻辑策略仍通过目标树验证函数重建定理,最终由 HOL 内核检查。
链表例子展示了一套典型的证明步骤:初始化目标,对纯数据分类讨论,展开空间谓词,提供例证,规范化两侧,再通过框架消除消去共同资源。后续对更复杂的数据结构进行证明时,也会沿用这条主线。