从一到无穷大 #70 从 LR 图 PEC 到InfluxQL兼容性差分测试方法论与工程实践

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

本作品 (李兆龙 博文, 由 李兆龙 创作),由 李兆龙 确认,转载请注明版权。

文章目录

    • 一、背景与核心痛点
    • [二、知识背景:LR 图 PEC 方法论](#二、知识背景:LR 图 PEC 方法论)
      • [2.1 定义一个简化的 InfluxQL CFG](#2.1 定义一个简化的 InfluxQL CFG)
      • [2.2 构建 LR(0) 自动机------解析器执行逻辑的可视化](#2.2 构建 LR(0) 自动机——解析器执行逻辑的可视化)
      • [2.3 彻底搞懂"归约"------解析器的核心动作](#2.3 彻底搞懂"归约"——解析器的核心动作)
      • [2.4 构建 InfluxQL 的 LR 图](#2.4 构建 InfluxQL 的 LR 图)
      • [2.5 在 LR 图上"走"一遍目标查询](#2.5 在 LR 图上"走"一遍目标查询)
    • 三、论文方法的工程局限
      • [3.1 硬伤 1:LR 图在递归文法上天然无限](#3.1 硬伤 1:LR 图在递归文法上天然无限)
      • [3.2 硬伤 2:文法合法 ≠ 引擎可执行](#3.2 硬伤 2:文法合法 ≠ 引擎可执行)
      • [3.3 硬伤 3:抽象终结符具体化是"摊平的随机采样"](#3.3 硬伤 3:抽象终结符具体化是"摊平的随机采样")
      • [3.4 硬伤 4:负测试的 witness 机制不完备](#3.4 硬伤 4:负测试的 witness 机制不完备)
    • 四、完整解决方案
      • [4.1 LR 图的防爆炸机制](#4.1 LR 图的防爆炸机制)
        • [防线 1:PEC 按"每条产生式一棵树"生成(`pec.py::pec_generate`)](#防线 1:PEC 按"每条产生式一棵树"生成(pec.py::pec_generate))
        • [防线 2:最短派生 Knuth 不动点(`pec.py::shortest_derivations`)](#防线 2:最短派生 Knuth 不动点(pec.py::shortest_derivations))
        • [防线 3:context 搜索深度预算 + 记忆化(`pec.py::expand_with_hole`)](#防线 3:context 搜索深度预算 + 记忆化(pec.py::expand_with_hole))
        • [防线 4:深度有界随机采样(`pec.py::depth_bounded_enumeration`)](#防线 4:深度有界随机采样(pec.py::depth_bounded_enumeration))
        • [防线 5:文法层收紧(`grammar.py`)](#防线 5:文法层收紧(grammar.py))
        • 五重防线协同效果
      • [4.2 派生树 → 可执行 SQL(核心章节)](#4.2 派生树 → 可执行 SQL(核心章节))
        • [4.2.1 整体流程](#4.2.1 整体流程)
        • [4.2.2 Step 1:递归 emit 压扁](#4.2.2 Step 1:递归 emit 压扁)
        • [4.2.3 Step 2:抽象终结符 → 具体 token](#4.2.3 Step 2:抽象终结符 → 具体 token)
        • [4.2.4 Step 3:空白粘合(`_join` + `_GLUE_BEFORE_LPAREN`)](#4.2.4 Step 3:空白粘合(_join + _GLUE_BEFORE_LPAREN))
        • [4.2.5 Seed 机制:决定论、可复现、扇出控制](#4.2.5 Seed 机制:决定论、可复现、扇出控制)
          • [Seed 的三个注入点](#Seed 的三个注入点)
          • [Seed 在 Phase A 与 Phase B 中的不同用法](#Seed 在 Phase A 与 Phase B 中的不同用法)
          • [为什么要"每棵树重置 rng"而不是"全局递增"](#为什么要"每棵树重置 rng"而不是"全局递增")
          • [Seed 值的可复现保证](#Seed 值的可复现保证)
          • 一个具体例子
        • [4.2.6 两种扇出模式在同一个 `generate.py` 里合流](#4.2.6 两种扇出模式在同一个 generate.py 里合流)
          • [Phase A:plain 模式(多 seed 扇出)](#Phase A:plain 模式(多 seed 扇出))
          • [Phase B:metamorphic 模式(4-kind 变体组)](#Phase B:metamorphic 模式(4-kind 变体组))
          • [Phase C:interleave + 写 sidecar](#Phase C:interleave + 写 sidecar)
          • 两种扇出对比
          • [为什么把 metamorphic 合并进 PEC 实例化阶段是架构升级](#为什么把 metamorphic 合并进 PEC 实例化阶段是架构升级)
        • [4.2.7 血统可追溯](#4.2.7 血统可追溯)
      • [4.3 本方法与论文的差异](#4.3 本方法与论文的差异)
    • 五、解空间量化分析
      • [5.1 第一层:LR 图理论解空间(递归不设限)](#5.1 第一层:LR 图理论解空间(递归不设限))
      • [5.2 第二层:五重防线剪枝后的派生树空间](#5.2 第二层:五重防线剪枝后的派生树空间)
      • [5.3 第三层:PEC 树实例化(同一个 `generate.py` 调用同时产出两路)](#5.3 第三层:PEC 树实例化(同一个 generate.py 调用同时产出两路))
        • [Phase A:plain 通道(多 seed 扇出)](#Phase A:plain 通道(多 seed 扇出))
          • [PEC 派生树 → SQL 的量化放大链路](#PEC 派生树 → SQL 的量化放大链路)
        • [Phase B:metamorphic 通道(`_build_metamorphic_groups`)](#Phase B:metamorphic 通道(_build_metamorphic_groups))
          • 解空间压缩链路
          • [为什么要限制 metamorphic 的组合数](#为什么要限制 metamorphic 的组合数)
      • [5.4 第四层:语义约束后的可执行解空间](#5.4 第四层:语义约束后的可执行解空间)
        • [Plain 通道:36 条 validator 过滤 + DELETE 降采样](#Plain 通道:36 条 validator 过滤 + DELETE 降采样)
        • [Metamorphic 通道:TAG 金丝雀 + `--max-metamorphic-groups` 封顶](#Metamorphic 通道:TAG 金丝雀 + --max-metamorphic-groups 封顶)
        • 综合语料规模
      • [5.5 三层空间对比总表](#5.5 三层空间对比总表)
    • 六、方法论总结
      • [6.1 三条核心原则](#6.1 三条核心原则)
      • [6.2 一句话结论](#6.2 一句话结论)

一、背景与核心痛点

解析器(parser)是任何 SQL 方言执行的"入口",它把人类可读的查询语句转化为计算机可执行的语法树。一旦解析器出现 bug,可能导致查询报错、结果失真,甚至数据库崩溃------例如 InfluxDB 历史上曾出现 SELECT time FROM sensor 的错误解析 bug,本质就是解析器在特定归约上下文下,错误地把关键字 time 归约为普通字段而非时间戳字段。

时序数据库兼容性测试 场景里,问题更复杂一层:xstor TSDB 自己实现了一套 InfluxQL parser + planner + executor,需要与开源 InfluxDB 做行为等价性验证。常见的测试方法有三类:

方法 优势 局限
手工枚举 语义精准、场景明确 维度组合穷不尽,长尾路径漏测
文法驱动(Rossouw-Fischer PEC) 覆盖每条归约规则、规模可控 只覆盖 parser 层,对语义约束、类型、执行正确性无保证
随机 fuzz / SQLsmith 规模大、意外发现多 信噪比低,两端都拒绝的垃圾样本爆炸

所有这些方法都存在三大致命痛点:

  1. 路径爆炸:从 LR 自动机起点盲目遍历,路径数量会随文法复杂度指数级增长------哪怕是简化版 InfluxQL,朴素 BFS 也会在稍大的表达式文法上跑出 620 条 prefix 仍未收敛。
  2. 覆盖不全面 :仅覆盖文法规则,忽略归约上下文 ------同样的语法单元(如 ID),在不同场景下(SELECT 后、FROM 后)归约逻辑不同。同样的,Tag 和 Field 在语法层都是 <VAR>,但它们在时序数据库里的运行时语义天差地别(Tag 带倒排索引用于过滤,Field 无索引用于聚合)------传统测试无法精准覆盖这种语义等价类。
  3. 负测试依赖预言机:生成的非法语句需要人工或外部参考解析器验证,代价高、易出错;即便用参考引擎作 oracle,如果双端都有 bug,结果也不可信。

针对这些痛点,论文提出了"LR 图建模 + PEC 正测试生成 + 路径变异负测试"的方案------用 LR 图显式化解析器执行逻辑,用 PEC 算法高效覆盖所有归约场景,用路径变异生成无需预言机的负测试用例。

但是论文方法天生假设 SUT = 纯 parser,在真实时序数据库上直接套用会出现 96.6 % 两端都拒绝的 BOTH_ERROR------把 LR 图从数学模型变成工程可用的差分测试方法,还需要补上若干关键机制。

下面先梳理 LR 图 PEC 方法的理论基础,再逐一解析论文方案的工程局限,以及我们在 influxql_verify/ 仓库里给出的完整解决方案。


二、知识背景:LR 图 PEC 方法论

2.1 定义一个简化的 InfluxQL CFG

InfluxQL 的完整语法相当复杂,为演示方便我们取一个核心子集:

sql 复制代码
SELECT <field> FROM <measurement> WHERE time <operator> <time_expr>

对应的上下文无关文法 G = ( N , T , P , S ) G = (N, T, P, S) G=(N,T,P,S):

元素 内容 说明
非终结符 N {Query, SelectClause, FromClause, WhereClause, Field, Measurement, TimePred, Op, TimeExpr} 语法结构的抽象单元
终结符 T {SELECT, FROM, WHERE, ID, TIME, >, NOW, $} 解析器能直接识别的词法单元
开始符号 S Query 一条完整查询

产生式规则 P:

bnf 复制代码
1.  Query        → SelectClause FromClause WhereClause
2.  SelectClause → SELECT Field
3.  FromClause   → FROM Measurement
4.  WhereClause  → WHERE TimePred
5.  Field        → ID
6.  Measurement  → ID
7.  TimePred     → TIME Op TimeExpr
8.  Op           → >
9.  TimeExpr     → NOW

后续所有步骤将围绕目标查询展开:

sql 复制代码
SELECT temp FROM sensor WHERE time > now()

对应终结符序列:SELECT ID FROM ID WHERE TIME > NOW $

2.2 构建 LR(0) 自动机------解析器执行逻辑的可视化

LR 自动机是解析器的底层模型,本质是"状态 + 转移"的集合。论文把 LR 图建立在 LR(0) 自动机上。

LR(0) 项目 :在产生式右部加一个 dot ,表示"当前解析器识别到的位置":

  • Query → • SelectClause FromClause WhereClause:初始状态;
  • Query → SelectClause • FromClause WhereClause:已识别完 SelectClause,等待 FromClause。

从拓广开始符号 Query' → • Query 出发,通过闭包运算GO 函数,生成 InfluxQL 子集的核心状态集:

状态 ID 核心项目集 说明
I0 Query' → • QueryQuery → • SelectClause FromClause WhereClauseSelectClause → • SELECT Field 初始状态
I1 Query' → Query • 接受状态
I2 Query → SelectClause • FromClause WhereClauseFromClause → • FROM Measurement 已识别 SELECT 子句
I3 SelectClause → SELECT • FieldField → • ID 看到 SELECT,等待 ID
I4 SelectClause → SELECT Field • SELECT 子句归约完毕
I5 Field → ID • 一个 ID 被当作 Field
I6 Query → SelectClause FromClause • WhereClauseWhereClause → • WHERE TimePred 等待 WHERE
I7 FromClause → FROM • MeasurementMeasurement → • ID 看到 FROM,等待 ID
I8 FromClause → FROM Measurement • FROM 子句归约完毕
I9 Measurement → ID • 一个 ID 被当作 Measurement
I10 Query → SelectClause FromClause WhereClause • Query 即将归约
I11 WhereClause → WHERE • TimePredTimePred → • TIME Op TimeExpr 看到 WHERE,等待时间谓词
I12 WhereClause → WHERE TimePred • WHERE 子句归约完毕
I13 TimePred → TIME • Op TimeExprOp → • > 看到 TIME,等待运算符
I14 TimePred → TIME Op • TimeExprTimeExpr → • NOW 看到 >,等待时间表达式
I15 Op → > • > 归约为 Op
I16 TimePred → TIME Op TimeExpr • 完整的时间谓词
I17 TimeExpr → NOW • NOW 归约为 TimeExpr

2.3 彻底搞懂"归约"------解析器的核心动作

归约的本质是"自底向上的语法合并" ,与之配合的是移进(Shift):把终结符读进解析器栈里。

类比拼乐高:

  • Shift :从盒子里拿出一块积木(SELECTtemp),放到工作台(栈)上;
  • Reduce :工作台上的几块积木刚好能拼成一个大部件(temp → FieldSELECT + Field → SelectClause),把它们换成这个大部件。

SELECT temp FROM sensor WHERE time > now() 为例,终结符序列 [SELECT, ID, FROM, ID, WHERE, TIME, >, NOW],完整演示解析器的移进与归约过程:

步骤 解析器栈 剩余输入 动作 对应规则
1 [] SELECT, ID, FROM, ... Shift ---
2 [SELECT] ID, FROM, ... Shift ---
3 [SELECT, ID] FROM, ... Reduce Field → ID
4 [SELECT, Field] FROM, ... Reduce SelectClause → SELECT Field
5 [SelectClause] FROM, ... Shift ---
6 [SelectClause, FROM] ID, WHERE, ... Shift ---
7 [SelectClause, FROM, ID] WHERE, ... Reduce Measurement → ID
8 [SelectClause, FROM, Measurement] WHERE, ... Reduce FromClause → FROM Measurement
9 [SelectClause, FromClause] WHERE, ... Shift ---
10 [..., WHERE] TIME, >, NOW Shift ---
11 [..., WHERE, TIME] >, NOW Shift ---
12 [..., TIME, >] NOW Reduce Op → >
13 [..., TIME, Op] NOW Shift ---
14 [..., TIME, Op, NOW] --- Reduce TimeExpr → NOW
15 [..., TIME, Op, TimeExpr] --- Reduce TimePred → TIME Op TimeExpr
16 [..., WHERE, TimePred] --- Reduce WhereClause → WHERE TimePred
17 [SelectClause, FromClause, WhereClause] --- Reduce Query → SelectClause FromClause WhereClause
18 [Query] --- Accept ---

最关键的一点:归约上下文决定归约结果

看上面的步骤 3 和步骤 7:

  • 步骤 3:栈里是 [SELECT]ID(temp)归约为 Field------归约上下文是"SELECT 后面";
  • 步骤 7:栈里是 [SelectClause, FROM]ID(sensor)归约为 Measurement------归约上下文是"FROM 后面"。

如果解析器错误地忽略归约上下文(比如把 FROM 后面的 ID 归约为 Field),就会导致查询解析错误。传统基于文法的测试只覆盖了"ID 可以被归约为 FieldMeasurement"这种规则级事实,但并不系统地区分这两个不同的归约上下文------这正是 LR 图要解决的核心问题。

2.4 构建 InfluxQL 的 LR 图

LR 图是论文最核心的建模对象:它在 LR 自动机的基础上,把转移关系拆成两类显式的边,让归约上下文变得"可走可覆盖"。

给定 LR 自动机 L R G = ( Q , δ , ρ , q i n i t , q a c c ) LR_G = (Q, \delta, \rho, q_{init}, q_{acc}) LRG=(Q,δ,ρ,qinit,qacc),论文定义的 LR 图 L R G → = ( Q , E , q i n i t , q a c c ) LR_G^{\to} = (Q, E, q_{init}, q_{acc}) LRG→=(Q,E,qinit,qacc) 包含两类边:

边类型 对应解析器动作 记号 含义
Push 边 Shift 或 Goto q → X q ′ q \to_X q' q→Xq′ 从 q 看到符号 X,到达 q'
Pop 边 Reduce q ⇢ A / ∣ α ∣ q ′ q \dashrightarrow_{A/|\alpha|} q' q⇢A/∣α∣q′ 在状态 q 应用规则 A → α A \to \alpha A→α,栈顶弹 ∣ α ∣ |\alpha| ∣α∣ 个状态后回到 q'

为什么要拆? 传统 PDA 里,一次归约是"弹若干栈帧 + 推入非终结符"的原子动作,栈顶之下的状态被隐藏起来;LR 图把归约拆成一条 Pop 边,于是每种栈顶上下文对应一条独立的 Pop 边------这恰好把归约上下文显式化了。

InfluxQL 子集的核心 Pop 边(每个归约对应一条):

规则 Pop 边 归约上下文
Field → ID I 5 ⇢ F i e l d / 1 I 3 I_5 \dashrightarrow_{Field/1} I_3 I5⇢Field/1I3 SELECT 之后的 ID
Measurement → ID I 9 ⇢ M e a s u r e m e n t / 1 I 7 I_9 \dashrightarrow_{Measurement/1} I_7 I9⇢Measurement/1I7 FROM 之后的 ID
Op → > I 15 ⇢ O p / 1 I 13 I_{15} \dashrightarrow_{Op/1} I_{13} I15⇢Op/1I13 TIME 之后的 >
TimeExpr → NOW I 17 ⇢ T i m e E x p r / 1 I 14 I_{17} \dashrightarrow_{TimeExpr/1} I_{14} I17⇢TimeExpr/1I14 > 之后的 NOW
SelectClause → SELECT Field I 4 ⇢ S e l e c t C l a u s e / 2 I 0 I_4 \dashrightarrow_{SelectClause/2} I_0 I4⇢SelectClause/2I0 SELECT 子句合并
FromClause → FROM Measurement I 8 ⇢ F r o m C l a u s e / 2 I 2 I_8 \dashrightarrow_{FromClause/2} I_2 I8⇢FromClause/2I2 FROM 子句合并
TimePred → TIME Op TimeExpr I 16 ⇢ T i m e P r e d / 3 I 11 I_{16} \dashrightarrow_{TimePred/3} I_{11} I16⇢TimePred/3I11 时间谓词合并
WhereClause → WHERE TimePred I 12 ⇢ W h e r e C l a u s e / 1 I 6 I_{12} \dashrightarrow_{WhereClause/1} I_6 I12⇢WhereClause/1I6 WHERE 子句合并
Query → SelectClause FromClause WhereClause I 10 ⇢ Q u e r y / 3 I 0 I_{10} \dashrightarrow_{Query/3} I_0 I10⇢Query/3I0 顶层查询合并

Pop 边 I 5 ⇢ F i e l d / 1 I 3 I_5 \dashrightarrow_{Field/1} I_3 I5⇢Field/1I3 和 I 9 ⇢ M e a s u r e m e n t / 1 I 7 I_9 \dashrightarrow_{Measurement/1} I_7 I9⇢Measurement/1I7 共享文法规则中相同的 ID,但是两条不同的边 ------这就是 LR 图把"归约上下文"显式化的方式。覆盖所有 Pop 边 ≡ 覆盖所有归约上下文

2.5 在 LR 图上"走"一遍目标查询

目标:SELECT temp FROM sensor WHERE time > now()。在 LR 图上,一次完整的解析 = 一条从 q i n i t = I 0 q_{init}=I_0 qinit=I0 到 q a c c = I 1 q_{acc}=I_1 qacc=I1 的交替路径,终结符 Push 边由输入驱动,每条 Pop 边必须紧跟一条相同非终结符的 Push 边(对应 Goto):

动作 下一状态
1 读入 SELECT Push I 0 → S E L E C T I 3 I_0 \to_{SELECT} I_3 I0→SELECTI3 I3
2 读入 ID Push I 3 → I D I 5 I_3 \to_{ID} I_5 I3→IDI5 I5
3 归约 Field→ID Pop I 5 ⇢ F i e l d / 1 I 3 I_5 \dashrightarrow_{Field/1} I_3 I5⇢Field/1I3 I3
4 Goto Field Push I 3 → F i e l d I 4 I_3 \to_{Field} I_4 I3→FieldI4 I4
5 归约 SelectClause→SELECT Field Pop I 4 ⇢ S e l e c t C l a u s e / 2 I 0 I_4 \dashrightarrow_{SelectClause/2} I_0 I4⇢SelectClause/2I0 I0
6 Goto SelectClause Push I 0 → S e l e c t C l a u s e I 2 I_0 \to_{SelectClause} I_2 I0→SelectClauseI2 I2
7 读入 FROM Push I 2 → F R O M I 7 I_2 \to_{FROM} I_7 I2→FROMI7 I7
8 读入 ID Push I 7 → I D I 9 I_7 \to_{ID} I_9 I7→IDI9 I9
9 归约 Measurement→ID Pop I 9 ⇢ M e a s u r e m e n t / 1 I 7 I_9 \dashrightarrow_{Measurement/1} I_7 I9⇢Measurement/1I7 I7
10 Goto Measurement Push I 7 → M e a s u r e m e n t I 8 I_7 \to_{Measurement} I_8 I7→MeasurementI8 I8
... (略,以此类推) ... ...
27 Accept --- ---

把路径上所有终结符 Push 边 的标签按序收集起来,就得到正测试用例:SELECT ID FROM ID WHERE TIME > NOW。替换成具体标识符即为目标查询。

PEC 算法的核心洞察 :每一条 Pop 边在 LR 图中都对应唯一一条规范的归约路径(Push 前缀 + Pop 边本身 + Goto 回推):

r = ι ( p ∈ E → ∣ α ∣ ∣ v ( p ) = q ′ ⋯ q ) ⏟ push 前缀 ∘ ( q ⇢ A / ∣ α ∣ q ′ ) ⏟ Pop 边 ∘ ι ( q ′ → A q ′ ′ ) ⏟ Goto r = \underbrace{\iota(p \in E_{\to}^{|\alpha|} \mid v(p)=q'\cdots q)}{\text{push 前缀}} \circ \underbrace{(q \dashrightarrow{A/|\alpha|} q')}{\text{Pop 边}} \circ \underbrace{\iota(q' \to_A q'')}{\text{Goto}} r=push 前缀 ι(p∈E→∣α∣∣v(p)=q′⋯q)∘Pop 边 (q⇢A/∣α∣q′)∘Goto ι(q′→Aq′′)

覆盖一条 Pop 边 ≡ 走完它唯一的归约路径,不用搜索,直接构造。时间复杂度 O(|E⇢|),线性于 Pop 边数。


三、论文方法的工程局限

论文方法在 "SUT 就是 parser" 的场景(SQLite 单元测试、GCC 前端测试)里是成立的:只要能派生出的 SQL 就是"合法正例"。但把它直接套到 xstor / InfluxDB 的兼容性测试上,会遇到三个硬伤:

3.1 硬伤 1:LR 图在递归文法上天然无限

LR 图在数学上 是有限的(InfluxQL 子集约 179 状态 + 360 push 边 + 97 pop 边),但它生成的语言 L(G) 无限------任何路径都可以绕回起点重新走一遍。InfluxQL 里尤其棘手的是两类递归产生式:

bnf 复制代码
# 子查询递归
measurement → <MEASUREMENT>
            | <REGEX_LIT>
            | <LPAREN> select_stmt <RPAREN>     ← 可无限嵌套

# 表达式递归
expr_bool  → expr_rel
           | expr_rel <OP_LOGIC> expr_bool      ← 右递归
expr_arith → expr_atom
           | expr_atom <OP_ARITH> expr_arith    ← 右递归
expr_atom  → <LPAREN> expr <RPAREN>             ← 括号嵌入 expr
           | function_call                      ← 函数内嵌 expr

论文原版的 BFS(Algorithm 1/2/3)在稍大一点的表达式文法上就跑出 620 条 prefix 仍未收敛,对 InfluxQL 这种带子查询的文法更是指数爆炸。

3.2 硬伤 2:文法合法 ≠ 引擎可执行

论文假设"语法 = 语义"。但 InfluxQL 在 parser 之上还有多层隐式约束

  1. 词法层time(fill(tz( 之间必须无空格,SELECT time ( 1h ) 会被拒绝;
  2. 语义层 :SELECT 子句不允许 AND/OR/=/<>ORDER BY 只支持 timeGROUP BY time() 要求 SELECT 含聚合;WHERE 子句必须为布尔;
  3. 类型层min/max 不接受 string;time + 'abc' 非法;tag 只能用 = / != / =~,不能 < / >=
  4. 时序特有语义层:Tag vs Field 的运行时语义差异。

论文原版 PEC 跑出的 1 658 条"文法合法" SQL,直接打到 xstor + InfluxDB 双端,96.6 % 被两端同时拒绝------这些样本在差分测试里是零信号的纯噪音。

3.3 硬伤 3:抽象终结符具体化是"摊平的随机采样"

论文的 terminalizer 只是"把抽象终结符换成某个具体值"的简单映射。但时序数据库里有一个关键区分:Tag 与 Field 在语法上都是 <VAR>,但运行时语义完全不同

维度 Tag Field
存储 倒排索引 无索引的列
WHERE 运算符 =, !=, =~(禁 <, >= =, !=, <, <=, >, >=
GROUP BY 自然可分组 一般不允许
聚合 count(tag) 合法,sum(tag) 非法 全聚合(类型允许下)

如果 terminalizer 只是从 ALL_FIELDS 里随机抽一个名字塞进 <FIELD_KEY>,类型维度就被"摊平"了------PEC 派生树里的 min(<FIELD_KEY>) 有 50 % 概率被 terminalize 成 min(status)(非法),要么被过滤规则 drop,要么污染差分信号。论文方法根本没有"同一结构下变换列类型"的概念,于是 Tag/Field 语义错位这类 TSDB 核心 bug 直接漏测。

3.4 硬伤 4:负测试的 witness 机制不完备

论文的路径变异依赖 follow/precede/witness 集合来保证变异后"一定非法"。在 TSDB 差分测试里:

  • "一定非法"的定义本身有歧义------到底是 parser 拒绝,还是 analyzer 拒绝,还是类型拒绝?
  • 两端引擎行为可能不同------xstor 接受 InfluxDB 拒绝恰恰是高价值 bug,绝不能被 witness 机制过滤掉。

因此负测试这条路在差分测试场景里信噪比天然偏低:真正有信号的反而是正例里隐藏的宽严分歧、静默失败、跨类型错位。


四、完整解决方案

针对以上四个硬伤,influxql_verify/ 仓库给出的方案是把论文方法当作一条生成管线的骨架,在它之上叠加三层工程机制,并把 metamorphic 完全融入 PEC 实例化阶段

复制代码
Grammar(148 产生式)
  │
  │ ① PEC + 防爆炸机制 → 派生树
  ▼
派生树池 (PEC + 深度采样 + 结构签名去重)
  │
  │ ② terminalizer 在同一个 generate.py 里一次产出两路 SQL
  │    ├─ Phase A (plain):         kind_override=None,多 seed 扇出
  │    └─ Phase B (metamorphic):   每棵"带列槽"且 TAG 变体语义合法的树
  │                                 用同一个 tree_seed 派生 4 条
  │                                 (TAG / NUMERIC / BOOL / STRING) 变体
  ▼
<out>.sql     +  sidecar <out>.sql.meta.jsonl   (逐行元信息)
  plain 在前、     {"meta_group_id": null}                  -- plain
  metamorphic      {"meta_group_id": "mg_00000",
  4 条一组连续        "meta_kind": "TAG"}                   -- 变体 1/4
  在后             ...                                      -- 2/4, 3/4, 4/4
  │
  │ ③ 36 条 validator 方言语义过滤
  ▼
可执行 SQL (plain ≈ 1.6 万条; metamorphic 组 ≤ 2000 组 × 4)
  │
  │ ④ run_diff_all.py 差分执行
  │    ├─ plain:         单条 SQL 双端对比
  │    └─ metamorphic:   按 meta_group_id 缓冲 4 条变体
  │                      4 条都跑完后调 metamorphic_oracle.evaluate()
  ▼
xstor vs influxdb

关键架构要点 :一次 generate.py 调用同时产出 plain 语料 + metamorphic 语料 + 侧车元数据文件。不再有独立的 run_metamorphic_pec.py------metamorphic 仅仅是生成阶段 Terminalizer 的"第二路模式"、执行阶段 run_diff_all.py 的"第二类 Oracle"。两路共享同一份派生树池、同一个 Terminalizer 实例、同一套 validator,配置开关即 --metamorphic / --no-metamorphic + --max-metamorphic-groups

4.1 LR 图的防爆炸机制

pec.py + grammar.py 里用五重防线同时压制递归:

防线 1:PEC 按"每条产生式一棵树"生成(pec.py::pec_generate

PEC 的核心对应关系是:|P| 条产生式 ↔ |P| 条 Pop 边 ↔ |P| 条测试树。产生式集合 P 在编译 grammar 的时候就已经定死(148 条),不会随递归深度膨胀。

python 复制代码
for p_idx, (A, rhs) in enumerate(g.productions):   # 148 次循环
    # 1. 建 RHS 子树:RHS 中每个非终结符用 "最短派生" 填充
    children = []
    for sym in rhs:
        if g.is_terminal(sym):
            children.append(sym)
        else:
            children.append(shortest[sym])         # 关键:递归在这里被截断
    subtree = (A, p_idx, children)

    # 2. 把 subtree 嵌入到"从 START 到 A 的最浅上下文"
    ctx = find_context(A)
    tests.append(_plug(ctx, subtree))
防线 2:最短派生 Knuth 不动点(pec.py::shortest_derivations

对每个非终结符 A 预计算"能派生出它的最小终结符数量树"。这是一个经典不动点迭代:

python 复制代码
cost: Dict[str, float] = {A: INF for A in g.nonterminals}
changed = True
while changed:
    changed = False
    for i, (A, rhs) in enumerate(g.productions):
        c = sum of 1 per terminal + cost[NT] per non-terminal
        if feasible and c < cost[A]:
            cost[A] = c; best_prod[A] = i; changed = True

这条算法对所有可终止的非终结符都返回有限大小的树 。对 select_stmtshortest[select_stmt] 只是最简的 SELECT * FROM <MEASUREMENT>------不含任何递归嵌套(因为那种路径 cost 更高)。子查询非终结符的最短派生就是"没有子查询"。

防线 3:context 搜索深度预算 + 记忆化(pec.py::expand_with_hole
python 复制代码
def expand_with_hole(A, depth_budget):
    if A == target_nt:
        return ("__HOLE__", target_nt)
    if depth_budget <= 0:
        return None                      # 硬截断
    if A in memo:
        return memo[A]
    memo[A] = None                       # 打破环

    best = None
    for p in g.by_lhs.get(A, []):
        sub = expand_with_hole(sym, depth_budget - 1)
        ...

tree = expand_with_hole(g.start, depth_budget=60)

三重保险:depth_budget=60 硬上限;memo 打破左递归环;只在"必然到达 target"的分支上递归。

防线 4:深度有界随机采样(pec.py::depth_bounded_enumeration

PEC 只给 O(|P|) = 148 棵树,结构多样性不够。第二路生成用深度有界随机采样

python 复制代码
def sample(A, depth_left):
    if depth_left <= 0:
        return shortest[A]               # 硬截断:用最短派生
    prods = g.by_lhs[A]
    if depth_left == 1:
        all_term = [p for p in prods if all terminal]
        if all_term:
            prods = all_term             # 消除尾巴递归
    p = rng.choice(prods)
    return (A, p, [sample(sym, depth_left-1) for sym in rhs])

典型参数:深度 5 / 10 / 15 三档,每档 15 000 棵树,seen_sig 结构签名去重。

防线 5:文法层收紧(grammar.py

部分递归在文法层就被砍掉

原 EBNF 收紧 目的
field = expr select_expr → select_arith(无关系/逻辑/regex) SELECT 禁递归到 expr_bool/expr_rel
`sort_field = time FIELD_KEY [ASC DESC]`
fields = * 可与 field_list 混排 `fields → * field_list`(互斥)
literalREGEX_LIT regex 只在 regex_match_op regex 递归点大幅减少
`distinct(*) distinct(field)` 剔除 distinct(*)
SHOW/DELETE 共用 expr 独立 meta_where 非终结符 SHOW/DELETE 的 WHERE 空间降到受限子集
五重防线协同效果
防线 位置 性质
1. PEC 按产生式生成 pec_generate 规模上限 = 常数 |P| = 148
2. shortest_derivations 截断 shortest_derivations 最短树有限、无递归嵌套
3. context 搜索深度预算 expand_with_hole 硬上限 60、memo 打破环
4. 深度有界采样 depth_bounded_enumeration 深度 5/10/15,触底用 shortest
5. 文法收紧 grammar.py 消除整类递归入口

LR 图作为数学对象有限,生成的语言无限;五重防线让实际采样到的派生树空间也变得可控:每棵树节点数都有明确上限,整个语料规模由"|P| + 深度档数 × 每档树数 × seed 数"线性控制。

4.2 派生树 → 可执行 SQL(核心章节)

拿到派生树池之后,terminalizer.py 负责把树上的抽象终结符<SELECT><FIELD_KEY><INT_LIT> 等)替换成具体 token,拼成可直接发给 xstor 的 SQL。这一步是整个管线最关键、最容易被低估的环节------同一棵派生树可以产出从"单条代表性 SQL"到"上万条候选 SQL"到"4 条精确等价类变体 SQL"的完全不同的语料,全看 terminalizer 怎么配置。

在最新的工程实现里,整个 metamorphic 生成逻辑已经被完全吸收进 generate.py 的 Phase B (函数 _build_metamorphic_groups)。不再有独立的 run_metamorphic_pec.py 脚本:一次 CLI 调用同时产出 plain SQL + 4-kind 变体组,并把"哪一行属于哪个 meta_group_id 的哪个 meta_kind"写进 sidecar <out>.sql.meta.jsonl。下游 run_diff_all.py 读取 sidecar 即可识别变体组并触发 metamorphic Oracle。

4.2.1 整体流程
复制代码
派生树池 (pec.py)
    │
    ▼
[step 1] _emit() 递归 DFS 压扁   ───► token 列表(仍含抽象终结符)
    │
    ▼
[step 2] _emit_terminal() 抽象 → 具体
    │        ├─ 关键字 1:1 映射
    │        ├─ 字面量池随机采样 (INT_LIT / STRING_LIT / DURATION_LIT ...)
    │        ├─ 函数族随机采样   (FN_AGG_NUM / FN_TRANSFORM ...)
    │        └─ 列槽 (FIELD_KEY / NUMERIC_FIELD / TAG_KEY / TAG_VALUE)
    │              ├─ kind_override=None → 随机采样 (Phase A / plain)
    │              └─ kind_override=TAG/NUM/BOOL/STR → 强制替换 (Phase B / metamorphic)
    │                 其中 saw_column_slot 会被置位,作为本树是否值得
    │                 派生 4 变体的判据
    │
    ▼
[step 3] _join() 空白粘合 + _GLUE_BEFORE_LPAREN
    │
    ▼
[step 4] generate.py 的三段扇出(Phase A / B / C)
    ├─ Phase A  plain:       trees × 多 seeds ───► 约 1.6 万条 unique SQL
    ├─ Phase B  metamorphic: 每棵树单 tree_seed × 4 kinds
    │                        (TAG 变体先过 validator 作语义金丝雀,
    │                         过得了才真正产出 4 条;
    │                         4 条全相同的退化组会被直接丢弃)
    │                         ───► ≤ 2 000 组 × 4 = ≤ 8 000 条
    └─ Phase C  interleave + emit
                plain 在前 + metamorphic 4 条一组连续在后,
                同时写 <out>.sql 与 <out>.sql.meta.jsonl 两份文件
4.2.2 Step 1:递归 emit 压扁
python 复制代码
def _emit(self, node):
    if isinstance(node, str):                # 终结符
        return self._emit_terminal(node)
    if isinstance(node, tuple) and node[0] == "__HOLE__":
        return ["/*HOLE*/"]                  # 未插好的占位
    lhs, _prod, children = node
    out = []
    for c in children:
        out.extend(self._emit(c))            # DFS
    return out

输入派生树 ("select_stmt", 0, ["<SELECT>", ..., ("from_clause", ..., ["<FROM>", "<MEASUREMENT>"])]),输出 ["SELECT", "*", "FROM", "\"gd_select\""]

4.2.3 Step 2:抽象终结符 → 具体 token

这是整个 terminalizer 的核心。抽象终结符分四类,每类的具体化方式不同:

(A) 关键字:1:1 映射

python 复制代码
_KEYWORD_MAP = {
    "<SELECT>": "SELECT", "<FROM>": "FROM", "<WHERE>": "WHERE",
    "<GROUP_BY>": "GROUP BY", "<ORDER_BY>": "ORDER BY", ...
}

总计 53 个关键字,纯字典查询,无随机性。

(B) 字面量池:从 schema.py 的值池随机采样

python 复制代码
if t == "<INT_LIT>":     return [str(r.choice(schema.INT_LITS))]
if t == "<FLOAT_LIT>":   return [str(r.choice(schema.FLOAT_LITS))]
if t == "<STRING_LIT>":  return ["'" + r.choice(schema.STRING_LITS) + "'"]
if t == "<DURATION_LIT>":return [r.choice(schema.DURATION_LITS)]
if t == "<REGEX_LIT>":   return [r.choice(schema.REGEX_LITS)]

schema.py 定义了每个字面量类型的值池(摘录):

抽象终结符 值池 池大小 示例
<INT_LIT> INT_LITS 7 0, 1, 3, 10, 50, 100, 1000
<FLOAT_LIT> FLOAT_LITS 5 0.0, 0.5, 1.5, 50.0, 99.9
<STRING_LIT> STRING_LITS 5 'alpha', 'beta', 'gamma', 'delta', 'unknown'
<DURATION_LIT> DURATION_LITS 9 1u, 1ms, 1s, 30s, 1m, 5m, 1h, 1d, 1w
<REGEX_LIT> REGEX_LITS 3 /./、/^multi_./、/host[0-3]/
<TZ_STRING> TZ_ARGS 4 UTC, Asia/Shanghai, America/New_York, Europe/Berlin

© 函数族:从函数目录随机采样

python 复制代码
if t == "<FN_AGG_NUM>":  return [r.choice(schema.AGG_NUMERIC)]
if t == "<FN_AGG_ANY>":  return [r.choice(schema.AGG_ANY)]
if t == "<FN_SELECTOR>": return [r.choice(schema.SELECTORS)]
if t == "<FN_TRANSFORM>":return [r.choice(schema.TRANSFORMS)]
if t == "<OP_ARITH>":    return [r.choice(schema.ARITH_OPS)]
if t == "<OP_REL>":      return [r.choice(schema.REL_OPS)]
if t == "<OP_LOGIC>":    return [r.choice(schema.LOGIC_OPS)]
抽象终结符 目录 大小 内容
<FN_AGG_NUM> AGG_NUMERIC 7 sum, mean, median, mode, spread, stddev, integral
<FN_AGG_ANY> AGG_ANY 6 count, first, last, min, max, distinct
<FN_SELECTOR> SELECTORS 4 first, last, min, max
<FN_TOPBOTTOM> TOP_BOTTOM 2 top, bottom
<FN_TRANSFORM> TRANSFORMS 7 derivative, non_negative_derivative, difference, non_negative_difference, cumulative_sum, moving_average, elapsed
<OP_ARITH> ARITH_OPS 5 +, -, *, /, %
<OP_REL> REL_OPS 7 =, !=, <>, <, <=, >, >=
<OP_LOGIC> LOGIC_OPS 2 AND, OR

(D) 列槽:v7 新增分类,也是 metamorphic 融合的核心

python 复制代码
_COLUMN_SLOT_TERMS = {"<FIELD_KEY>", "<NUMERIC_FIELD>", "<TAG_KEY>"}
KIND_COLUMN  = {"TAG":"host", "NUMERIC":"cpu", "BOOL":"status", "STRING":"info"}
KIND_LITERAL = {"TAG":"'host0'","NUMERIC":"50","BOOL":"true","STRING":"'alpha'"}

if t in _COLUMN_SLOT_TERMS:
    self.saw_column_slot = True
    if self.kind_override is not None:
        return [f'"{KIND_COLUMN[self.kind_override]}"']     # 强制替换
    # 无 override 时走随机采样
    if t == "<FIELD_KEY>":    return [f'"{r.choice(schema.ALL_FIELDS)}"']
    if t == "<NUMERIC_FIELD>":return [f'"{r.choice(schema.NUMERIC_FIELDS)}"']
    if t == "<TAG_KEY>":      return [f'"{r.choice(schema.TAG_KEYS)}"']
if t == "<TAG_VALUE>":
    if self.kind_override is not None:
        return [KIND_LITERAL[self.kind_override]]           # 跟随 kind
    k = r.choice(schema.TAG_KEYS)
    return ["'" + r.choice(schema.TAG_VALUES[k]) + "'"]

列槽和其他终结符的关键区别:它们是 metamorphic 变体的"锚点"。通过三个开关支持两种模式:

  1. _COLUMN_SLOT_TERMS:定义哪些抽象终结符算"列槽"------只有这些槽的值会因 kind_override 而变化;
  2. saw_column_slot:派生树一旦触碰列槽就置位,标志这棵树是否有 metamorphic 变异点;
  3. KIND_COLUMN + KIND_LITERAL:两张替换表保证"强制 kind"时列名与 <TAG_VALUE> 字面量搭配一致 (例如 kind=BOOL 时列名 "status" 配字面量 true,而不是 'alpha')。
4.2.4 Step 3:空白粘合(_join + _GLUE_BEFORE_LPAREN

InfluxQL lexer 对 time(fill(tz( 以及所有函数名 ( 的组合要求无空格

python 复制代码
_GLUE_BEFORE_LPAREN = {
    "time", "fill", "tz",                      # 3 个保留字
    "count", "sum", "mean", "median", "mode",
    "spread", "stddev", "integral",             # 7 个数值聚合
    "first", "last", "min", "max", "distinct",  # 5 个选择器
    "top", "bottom", "percentile", "sample",    # 4 个 top/bottom
    "derivative", "non_negative_derivative",
    "difference", "non_negative_difference",
    "cumulative_sum", "moving_average", "elapsed",   # 7 个 transform
    "holt_winters", "holt_winters_nonneg",      # 2 个 predictor
}

def _join(self, tokens):
    buf = []
    for tok in tokens:
        last = buf[-1].lstrip() if buf else None
        if tok == "(" and last in _GLUE_BEFORE_LPAREN:
            buf.append(tok); continue
        if tok in {")", ",", "."} or last in {"(", "."}:
            buf.append(tok)
        else:
            buf.append(" " + tok)
    return "".join(buf)

若不做这一步,time (1h) 会直接被两端拒绝------这是论文 terminalizer 完全没提、但在 InfluxQL 方言上不可缺的环节。

4.2.5 Seed 机制:决定论、可复现、扇出控制

上面的 Step 2 反复出现 r.choice(...)r.randint(...)------这个 rTerminalizer.rng,一个 random.Random(seed) 实例。整个 terminalizer 的"随机性"全部由这一个 PRNG 驱动,seed 值决定了:

  1. 同一棵树在同一 seed 下的输出完全相同(可复现、可回归);
  2. 同一棵树在不同 seed 下几乎必然输出不同 SQL(扇出放大的源头);
  3. 同一个 seed 内的 4 次 kind_override 调用,非列槽终结符抽到完全相同的值(metamorphic 等价类的成立基础)。
Seed 的三个注入点
python 复制代码
# terminalizer.py ------ 类内部与公开入口
class Terminalizer:
    def __init__(self, measurement, seed=42, kind_override=None):
        self.rng = random.Random(seed)           # (1) 构造期 seed:基线 rng

    def tree_to_sql(self, tree, seed=None, kind_override=...):
        if seed is not None:
            self.rng = random.Random(seed)       # (2) 每棵树 seed:本次调用独占 rng
        self._cache = {}
        self.saw_column_slot = False
        ...
        tokens = self._emit(tree)                # 递归 _emit → _emit_terminal → r.choice
        return self._join(tokens)
python 复制代码
# terminalizer.py ------ 批量入口
def trees_to_sqls(trees, measurement=..., base_seed=42):
    term = Terminalizer(measurement=measurement, seed=base_seed)
    out = []
    for i, t in enumerate(trees):
        out.append(term.tree_to_sql(t, seed=base_seed + i))   # (3) 每棵树独立递增
    return out

三个 seed 注入点分工如下:

注入点 作用 典型调用方
(1) Terminalizer(seed=...) 构造期设置一个"兜底 rng",若后续 tree_to_sql 不传 seed 就沿用之 共享实例、序列化多棵树
(2) tree_to_sql(tree, seed=S) 每棵树独立重置------这是工程中最常用的用法 Phase A 多 seed 扇出、Phase B 4 变体组
(3) trees_to_sqls(trees, base_seed=B) 内部对第 i 棵树用 seed=B+i,保证"同一批树的每棵树 seed 全局唯一" Phase A 的默认 batch 入口
Seed 在 Phase A 与 Phase B 中的不同用法

Phase A(plain 多 seed 扇出)走 trees_to_sqls(base_seed=s)

复制代码
base_seed=42,  trees=[t0, t1, t2, ...]
  → t0 用 seed=42 + 0 = 42
  → t1 用 seed=42 + 1 = 43
  → t2 用 seed=42 + 2 = 44
  ...

base_seed=100, trees=[t0, t1, t2, ...]
  → t0 用 seed=100 + 0 = 100
  → t1 用 seed=100 + 1 = 101
  ...

同一棵树 t0 在两个 base_seed 下拿到 seed=42 和 seed=100 ,两次 r.choice 序列完全不同,因此字面量、函数名、列名的抽取结果也几乎必然不同。seen_plain 用 SQL 字符串去重,最终得到多个不同版本的 t0。

Phase B(metamorphic 4 变体组)走自定义 tree_seed

python 复制代码
# generate.py 片段
for base_seed in seeds:
    for tree_idx, tree in enumerate(trees):
        tree_seed = base_seed * 1_000_003 + tree_idx   # 每组唯一 seed
        tag_sql     = term.tree_to_sql(tree, seed=tree_seed, kind_override="TAG")
        numeric_sql = term.tree_to_sql(tree, seed=tree_seed, kind_override="NUMERIC")
        bool_sql    = term.tree_to_sql(tree, seed=tree_seed, kind_override="BOOL")
        string_sql  = term.tree_to_sql(tree, seed=tree_seed, kind_override="STRING")

关键细节 :4 次 tree_to_sql 传入同一个 tree_seed 。因为 tree_to_sql 开头会 self.rng = random.Random(seed),每次调用 rng 状态完全一致------于是:

  • <INT_LIT><OP_REL><FN_AGG_NUM><ALIAS_ID> 等非列槽终结符在 4 次遍历中抽到完全相同的值
  • 只有列槽和 <TAG_VALUE>kind_override 分支而产出不同字面量(绕开 rng,直接查 KIND_COLUMN / KIND_LITERAL 表)。

乘子 1_000_003 是一个大质数,作用是base_seedtree_idx 两个维度"拉开距离",避免两个组意外拿到相近的 seed 值导致 rng 状态趋同。

为什么要"每棵树重置 rng"而不是"全局递增"

一个似乎更自然的实现是:在 Terminalizer 里全局保留一个 rng,按遍历顺序往下抽。但这样做有两个问题:

  1. 不可复现单棵树:要重现第 500 棵树的 SQL,必须从头遍历 499 棵树耗尽 rng,否则状态对不上;
  2. Phase B 无法做到"非列槽终结符一致" :4 次 kind_override 遍历本是对同一棵树做 4 次,但如果 rng 全局延续,第二次遍历时 rng 状态已经被第一次耗光,非列槽终结符的抽取结果就会漂移,metamorphic 等价类彻底失效。

因此工程实现严格遵守"一次 tree_to_sql 调用 = 一次 rng 重置 "的契约。副作用是浅层树在相邻 seed 间有一定重复(seen_plain 去重能兜底);收益是 metamorphic 等价类的每个非列槽终结符都按位对齐

Seed 值的可复现保证

整个生成管线的 seed 来源链如下:

复制代码
用户 CLI:
  --seed 42                           (args.seed:      depth-bounded 采样结构)
  --seeds 42,100,200                  (args.seeds:     terminalizer base_seed 列表)
      │                                │
      ▼                                ▼
pec.depth_bounded_enumeration(     trees_to_sqls(base_seed=s)
    seed = args.seed + d * 131)    → term.tree_to_sql(tree, seed = s + i)
      │                                │                       │
      ▼                                ▼                       ▼
  结构采样 rng                   batch rng                   每棵树 rng
  • 结构采样 (派生树的产生式选择)由 args.seed + depth * 131 驱动;
  • 终结符实例化base_seed + tree_index(Phase A)或 base_seed * 1_000_003 + tree_idx(Phase B)驱动;
  • 两层完全独立,一层 seed 不变时另一层也 100 % 复现。

这意味着:给定相同的 --seed / --seeds / --depths / --max-trees / --max-metamorphic-groups,两次 generate.py 调用一定输出字节级相同的 <out>.sql + <out>.sql.meta.jsonl 。这是差分测试回归对比的根基------否则同一个 bug 在两次运行中出现在不同行号,.sql 的 diff 就是噪声的海洋。

一个具体例子
复制代码
派生树:
  SELECT <FN_AGG_NUM>(<NUMERIC_FIELD>) FROM m
    WHERE <TAG_KEY> <OP_REL> <TAG_VALUE>

# Phase A,base_seed=42,tree_idx=7 → seed=49
  rng = Random(49)
  <FN_AGG_NUM>  → r.choice(AGG_NUMERIC)  = "mean"
  <NUMERIC_FIELD> → r.choice(NUMERIC_FIELDS) = "cpu"
  <TAG_KEY>      → r.choice(TAG_KEYS)        = "host"
  <OP_REL>       → r.choice(REL_OPS)         = "!="
  <TAG_VALUE>    → (k = r.choice(TAG_KEYS) = "host";
                    r.choice(TAG_VALUES["host"]) = "host2")
  → SELECT mean("cpu") FROM "m" WHERE "host" != 'host2'

# Phase B,base_seed=42,tree_idx=7 → tree_seed = 42 * 1000003 + 7 = 42000133
  rng = Random(42000133)                  # 与 Phase A 不同
  for kind in TAG, NUMERIC, BOOL, STRING:
      rng 重置为 Random(42000133)         # 4 次调用全部重置到同一状态

  # 因此 <FN_AGG_NUM>/<OP_REL> 四次都抽到同一个值,例如:
  <FN_AGG_NUM>  → "count"                 # 全组 4 条都是 count
  <OP_REL>      → "="                     # 全组 4 条都是 =
  # 列槽与 <TAG_VALUE> 走 kind_override 分支:
  TAG     变体: count("host")   = 'host0'
  NUMERIC 变体: count("cpu")    = 50
  BOOL    变体: count("status") = true
  STRING  变体: count("info")   = 'alpha'

Phase A 的"mean / cpu / host / != / host2"与 Phase B 的"count / = / 4 kind"之间没有任何复用------两次调用从完全不同的 seed 起跳,彼此独立。但一次 Phase B 调用内部的 4 条变体完全对齐 ,这正是 metamorphic oracle 对比 XSTOR_OVER_ACCEPTS / CROSS_KIND_XSTOR_INCONSISTENT 等 finding 时最看重的等价性。

4.2.6 两种扇出模式在同一个 generate.py 里合流

同一份 Terminalizer 实例在工程侧衍生出两种互补 的 SQL 扇出方式,并且被合并到同一个 generate.py 调用的 Phase A / Phase B / Phase C 三段流程里:

Phase A:plain 模式(多 seed 扇出)
python 复制代码
# generate.py 片段(简化)
plain_sqls = []
seen_plain = set()
for s in seeds:                                  # 例如 [42]
    for sql in trees_to_sqls(all_trees,
                             measurement=args.measurement,
                             base_seed=s):        # kind_override=None
        if sql not in seen_plain:
            seen_plain.add(sql)
            plain_sqls.append(sql)

trees_to_sqls 内部对每棵树调用一次 tree_to_sql(tree, seed=...);每次 seed 不同,rng 重置后所有抽象终结符(字面量、函数、列)都重新独立采样 。seeds 数量通过 --seeds 42,100,... 控制。

以一个具体的树为例:

复制代码
派生树:
  SELECT <FN_AGG_NUM>(<NUMERIC_FIELD>) FROM m WHERE <TAG_KEY> <OP_REL> <INT_LIT>

└── seed=42  →  SELECT sum("cpu") FROM "gd_select" WHERE "host"   > 100
└── seed=100 →  SELECT mean("mem") FROM "gd_select" WHERE "region" = 0
└── seed=200 →  SELECT stddev("cpu") FROM "gd_select" WHERE "host" != 50

放大机制是"一棵树 = 一个 SQL 模板 ":同一结构下,每个抽象终结符叶子独立走一次 rng.choice,组合空间是笛卡儿积。对一棵深度 10 含 ~20 个抽象终结符叶子、平均池大小 5 的树,理论展开空间 ≈ 5 20 ≈ 10 14 \approx 5^{20} \approx 10^{14} ≈520≈1014 条 SQL------实际按 seed 采样 + seen 去重,典型产出 10 万量级唯一 SQL

Phase B:metamorphic 模式(4-kind 变体组)

Phase B 的实现集中在 _build_metamorphic_groups() 函数里:

python 复制代码
# generate.py 片段(简化)
def _build_metamorphic_groups(trees, measurement, seeds,
                              max_groups, start_group_idx=0):
    term = Terminalizer(measurement=measurement, seed=seeds[0])
    out, seen_group_sigs = [], set()
    group_idx = start_group_idx
    for base_seed in seeds:
        for tree_idx, tree in enumerate(trees):
            if len(out) >= max_groups:
                return out, group_idx
            tree_seed = base_seed * 1_000_003 + tree_idx  # 每组唯一 seed

            # (1) TAG 变体:作为语义金丝雀
            tag_sql = term.tree_to_sql(tree, seed=tree_seed,
                                       kind_override="TAG")
            if not term.saw_column_slot:       continue   # 无列槽的树跳过
            ok, _ = is_semantically_valid(tag_sql)
            if not ok:                          continue   # TAG 非法的树跳过

            # (2) 同 seed 派生其余 3 个 kind
            variants = [("TAG", tag_sql)]; sqls_set = {tag_sql}
            for k in ("NUMERIC", "BOOL", "STRING"):
                sql = term.tree_to_sql(tree, seed=tree_seed,
                                       kind_override=k)
                variants.append((k, sql)); sqls_set.add(sql)

            # (3) 退化组/重复组拒收
            if len(sqls_set) == 1:              continue
            sig = tuple(s for _, s in variants)
            if sig in seen_group_sigs:          continue
            seen_group_sigs.add(sig)

            gid = f"mg_{group_idx:05d}"
            group_idx += 1
            out.append((gid, variants))
    return out, group_idx

关键:4 次调用用同一个 tree_seed------这意味着:

  • 所有非列槽 终结符(<INT_LIT><OP_REL><ALIAS_ID><DURATION_LIT>、函数名 ...)在 4 次 rng.choice 里抽到完全相同的值
  • 只有列槽 (及跟随的 <TAG_VALUE>)因 kind_override 而变化。

得到的是"除列类型外全部等价"的 4 条 SQL------metamorphic 测试所必需的语义等价类:

复制代码
派生树:
  SELECT count(<FIELD_KEY>) FROM m WHERE <TAG_KEY> <OP_REL> <TAG_VALUE>
# 同一 tree_seed → OP_REL 四次都抽到 "="

TAG     : SELECT count("host")   FROM "gd_select" WHERE "host"   = 'host0'
NUMERIC : SELECT count("cpu")    FROM "gd_select" WHERE "cpu"    = 50
BOOL    : SELECT count("status") FROM "gd_select" WHERE "status" = true
STRING  : SELECT count("info")   FROM "gd_select" WHERE "info"   = 'alpha'

Phase B 通过三道"丢弃门"控制信噪比:saw_column_slot == False 的树丢弃(没有锚点)、TAG 变体 validator 不通过的树丢弃(整棵树本身就非法)、4 条变体塌陷成同一 SQL 的组丢弃(退化变体)。最终由 --max-metamorphic-groups(默认 2 000)封顶。

Phase C:interleave + 写 sidecar
python 复制代码
# generate.py 片段(简化)
out_fh  = open(args.out,                  "w")
meta_fh = open(args.out + ".meta.jsonl",  "w")

# 先写 plain,每行对应 {"meta_group_id": null}
for sql in plain_sqls:
    out_fh.write(sql + "\n")
    meta_fh.write(json.dumps({"meta_group_id": None}) + "\n")

# 再写 metamorphic,每组 4 条连续,行行对应
# {"meta_group_id": "mg_00000", "meta_kind": "TAG"} ...
for gid, variants in meta_groups:
    for kind, sql in variants:
        out_fh.write(sql + "\n")
        meta_fh.write(json.dumps(
            {"meta_group_id": gid, "meta_kind": kind}) + "\n")

这份 sidecar 是整条管线的关键黏合剂 :下游 run_diff_all.py 启动时按行号对齐读取,把 plain 和 metamorphic 组分流------plain 走单条差分 Oracle,metamorphic 组按 meta_group_id 缓冲 4 条变体,全部跑完后调用 metamorphic_oracle.evaluate(stmt_type, variants_by_kind, group_id)

复制代码
执行端 (run_diff_all.py)
─────────────────────────────
for i, sql in enumerate(sqls):
    meta = sidecar[i]                     # {"meta_group_id": ..., "meta_kind": ...}
    rec  = run_diff(sql)                  # 双端并行执行

    if meta["meta_group_id"] is None:
        emit(rec)                         # plain 路径
    else:
        buffer[meta["meta_group_id"]][meta["meta_kind"]] = rec
        if len(buffer[group_id]) == 4:    # 组内 4 条都齐了
            findings = metamorphic_oracle.evaluate(
                stmt_type, buffer[group_id], group_id)
            emit_findings(findings)       # metamorphic 路径

metamorphic_oracle.py 独立成模块,产出 6 类结构化 bug 签名:XSTOR_OVER_ACCEPTS / XSTOR_REJECTS_VALID / XSTOR_DELETE_SILENT_FAIL / XSTOR_DELETE_OVER_DELETES / XSTOR_FIELD_DELETE_AS_TAG / CROSS_KIND_XSTOR_INCONSISTENT

两种扇出对比
维度 Phase A (plain) Phase B (metamorphic)
起点 同一份派生树池 同一份派生树池(结构签名再去重)
非列槽终结符 每 seed 重新采样 同一 tree_seed 内冻结
列槽终结符 按 seed 随机采样 4 变体强制 TAG/NUM/BOOL/STR
扇出倍数 × seeds 数量 × 4 kinds
典型规模 约 10 万条 unique SQL ≤ 2 000 组 × 4 = ≤ 8 000 条
丢弃条件 字符串去重 无列槽 / TAG 语义非法 / 4 变体塌陷
输出行元信息 {"meta_group_id": null} {"meta_group_id": "mg_xxxxx", "meta_kind": "..."}
Oracle 粒度 单条 SQL 双端对比 组内跨 kind 结构化关系对比
Oracle 实现 run_diff_all.py 内联 metamorphic_oracle.evaluate() 独立模块
产出 bug 类型 正确性 / 宽严分歧 / crash Tag/Field 错位 / field 条件被忽略 / 静默失败
为什么把 metamorphic 合并进 PEC 实例化阶段是架构升级

历史上 metamorphic 走过两条弯路:

  1. 最早:手工 SQL 模板(13 模板 × 4 kind = 52 变体)。场景覆盖全靠人想,与文法演进脱钩。
  2. 中期 :独立的 run_metamorphic_pec.py 脚本。生成端和执行端都各自复制一份 terminalizer 逻辑,语料版本与 plain 差分跑批对不齐。

当前架构 把 metamorphic 完全融进 generate.py,好处有三:

  • 一次调用同时产出两路语料 :plain 和 metamorphic 共用同一棵派生树 + 同一个 Terminalizer 实例,版本绝对一致;
  • Sidecar 解耦生成端与执行端<out>.sql.meta.jsonlrun_diff_all.py 无需关心 metamorphic 是怎么生成的,只要"按行号查 meta"即可;
  • Oracle 模块化metamorphic_oracle.py 独立成包,输入输出契约明确(stmt_type + variants_by_kind + group_idfindings),可以被任何需要 4-kind 关系检查的脚本复用,也方便单元测试。

核心理念不变metamorphic 不是独立的测试层,而是在 PEC 终结符实例化阶段启用的"Oracle 变换" 。关掉 --no-metamorphic 就是普通差分测试;开着就是 metamorphic 测试。这种融合保留了论文 PEC 的数学覆盖保证,同时把类型/Tag/Field 专属 Oracle 免费接入。

4.2.7 血统可追溯

最终每一条可执行 SQL 的血统清晰:

  • 哪条产生式覆盖(PEC / 深度采样);
  • 哪个 seed 具体化 (Phase A: base_seed;Phase B: tree_seed = base_seed × 1 000 003 + tree_idx);
  • 哪个 kind 绑定(Phase B only:TAG / NUMERIC / BOOL / STRING);
  • 哪条 validator 通过(SELECT 24 / SHOW 6 / DELETE 6 条方言语义规则);
  • 属于哪个 meta_group_id (sidecar .meta.jsonl 中的行号对齐关系)。

所有信息写进 jsonl,便于 bug 定位、回归测试和消融实验。

4.3 本方法与论文的差异

维度 论文(Rossouw-Fischer 2023) 本方法
SUT 假设 纯 parser(CFG 为唯一规范) TSDB(parser + analyzer + planner + storage)
正例定义 CFG 可派生即正例 CFG 可派生 ∧ 方言 validator 全部通过
LR 图递归处理 BFS flooding,对递归文法会爆炸 五重防线(PEC + shortest + depth budget + 深度采样 + 文法收紧)
终结符化 通用空白规则 方言词法感知 的粘合规则 (_GLUE_BEFORE_LPAREN)
列槽处理 无此概念,所有 <VAR> 等价 列槽分类 + kind_override 钩子,支持类型感知与 metamorphic 变体
Oracle accept/reject 单一 oracle 多条 Oracle 分支:一对一差分 + 跨变体 metamorphic + sandbox+verify + CRASH 自愈
负例构造 edge/stack mutation + witness 默认不启用(差分场景信噪比低);依赖正例里的宽严分歧
失败诊断 只能看"是否被接受" 每层 drop 计数 + 跨 kind 关系 + verify 查询物理状态
领域适配 换一种文法需重写 CFG 同一 CFG + 不同 validator + 不同 Oracle 分支,换方言只需替换这两个
有效样本率 在 InfluxQL 上 ~3.4 % 在 InfluxQL plain 通道上 ~85 %(BOTH_ERROR 15.7 %);metamorphic 通道经 TAG 变体 validator 筛选后接近 100 %

一句话概括 :论文方法是一个生成器 ;本方法把它升级为一条从文法到可执行 SQL 到跨类型 Oracle 的完整差分测试流水线 ------并且 plain 与 metamorphic 在同一个 generate.py 调用里一次产出,通过 sidecar .meta.jsonl 与下游 run_diff_all.py + metamorphic_oracle.py 串成闭环。


五、解空间量化分析

从 LR 图的理论解空间出发,经过三次剪枝,最终得到实际跑批的 SQL 规模。

5.1 第一层:LR 图理论解空间(递归不设限)

深度有界派生树数 ∣ T d ( G ) ∣ |T_d(G)| ∣Td(G)∣:深度 ≤ d 的合法派生树总数。对 InfluxQL grammar(|P|=148、|N|=44、|T|=76):

T ( A , d ) = ∑ A → α ∈ P ∏ X ∈ α { 1 X terminal T ( X , d − 1 ) otherwise T(A, d) = \sum_{A \to \alpha \in P} \prod_{X \in \alpha} \begin{cases} 1 & X \text{ terminal} \\ T(X, d-1) & \text{otherwise} \end{cases} T(A,d)=A→α∈P∑X∈α∏{1T(X,d−1)X terminalotherwise

递归爆炸点

  • measurement → (select_stmt) 形成 T ( select_stmt , d ) ≈ c ⋅ T ( select_stmt , d − 1 ) T(\text{select\_stmt}, d) \approx c \cdot T(\text{select\_stmt}, d-1) T(select_stmt,d)≈c⋅T(select_stmt,d−1) 的指数增长;
  • expr_bool / expr_arith / expr_atom / meta_where 四条右递归叠加;
  • function_call::transform(inner_numeric ...) 嵌套聚合。
深度 d 结构树数 量级
≤ 5 ~5 600 扁平 SELECT
≤ 7 ~490 000 expr_arith 两层嵌套
≤ 10 ∼ 1.3 × 10 28 \sim 1.3 \times 10^{28} ∼1.3×1028 双层子查询 + 深嵌套 expr
≤ 15 ∼ 10 57 \sim 10^{57} ∼1057 无实际意义的超深结构

加上终结符实例化 。每棵深度 10 的树通常含约 20 个抽象终结符叶子,平均池大小 k ˉ ≈ 5 \bar k \approx 5 kˉ≈5,每棵树展开约 5 20 ≈ 10 14 5^{20} \approx 10^{14} 520≈1014 条 SQL。

深度 d 结构 × 实例化 量级
≤ 5 ≈ 5.1 × 10 8 \approx 5.1 \times 10^8 ≈5.1×108 5 亿
≤ 7 ≈ 9.2 × 10 13 \approx 9.2 \times 10^{13} ≈9.2×1013 92 万亿
≤ 10 ≥ 10 40 \geq 10^{40} ≥1040 宇宙级

结论 :理论解空间无限 ,深度 10 已经是 10 40 10^{40} 1040 量级,任何有限测试集除以它都趋于 0。

5.2 第二层:五重防线剪枝后的派生树空间

PEC 按产生式生成 + shortest 填充 + context 深度预算 + 深度有界采样 + 文法收紧,实际采样参数:

复制代码
PEC                : 148 棵(确定性)
depth=5 采样       : 15 000 棵
depth=10 采样      : 15 000 棵
depth=15 采样      : 15 000 棵
─────────────────────────────
独立派生树          ≈ 45 300 棵 (trees)

解空间规模压缩

  • 原始理论空间(d=10): ∼ 10 40 \sim 10^{40} ∼1040 条;
  • 递归限制后派生树:45 300 棵
  • 压缩比: 10 40 / 4.5 × 10 4 ≈ 2 × 10 35 10^{40} / 4.5 \times 10^4 \approx 2 \times 10^{35} 1040/4.5×104≈2×1035------五重防线砍掉了 35 个数量级

覆盖率保证

  • 148 / 148 产生式覆盖 = 100 %(PEC 构造定理);
  • 97 / 97 Pop 边覆盖 = 100 %
  • 360 / 360 Push 边覆盖 = 100 %

砍了 10³⁵ 个数量级,LR 图结构覆盖率仍是 100 %------这正是 PEC 的数学保证。

5.3 第三层:PEC 树实例化(同一个 generate.py 调用同时产出两路)

派生树池进入 terminalizer,按 Phase A / Phase B 两条路径在同一次生成调用中一并扇出。两路共享派生树池,但 Oracle 粒度不同。

Phase A:plain 通道(多 seed 扇出)
复制代码
45 300 棵派生树  ×  --seeds 数量
─────────────────────────────
候选 SQL 总数      依 seeds 数量线性放大

字符串签名去重
─────────────────────────────
去重后 unique SQL  典型取 10 万量级(本文基线 168 509 条)

放大机制:每棵树 = 一个 SQL 模板,每个 seed 各自对所有抽象终结符叶子独立采样。放大倍数取决于树的叶子数和池大小乘积。浅层树(叶子少)多 seed 下易重复;深层树几乎必然产出多条不同 SQL。

典型去重率约 25 %,说明浅层树有 seed 重复,大部分组合仍然新鲜。

PEC 派生树 → SQL 的量化放大链路

一棵 PEC 派生树到可执行 SQL 的转化本质是抽象终结符实例化的笛卡尔积采样。量化这条链路需要三个维度:每棵树有多少非终结符叶子、每个叶子的值池有多大、采样多少次(seed 数量)。

维度 1:每棵树的抽象终结符叶子数

关键字类终结符(<SELECT><FROM> 等 53 种)为 1:1 固定映射,不贡献组合数。真正贡献组合数的是字面量、函数、运算符、列槽四类非关键字叶子:

树深度 典型非关键字叶子数 代表结构
≤ 3 2--4 SELECT * FROM mSHOW MEASUREMENTS
5 5--8 SELECT fn(col) FROM m WHERE tag = val
10 10--15 含 GROUP BY + ORDER BY + 嵌套表达式
15 15--25 含子查询或多层函数嵌套

维度 2:各类抽象终结符的值池大小

类别 抽象终结符 池大小 k k k
数值聚合函数 <FN_AGG_NUM> 7
任意聚合函数 <FN_AGG_ANY> 6
选择器函数 <FN_SELECTOR> 4
变换函数 <FN_TRANSFORM> 7
算术运算符 <OP_ARITH> 5
关系运算符 <OP_REL> 7
逻辑运算符 <OP_LOGIC> 2
整数字面量 <INT_LIT> 7
浮点字面量 <FLOAT_LIT> 5
字符串字面量 <STRING_LIT> 5
时间字面量 <DURATION_LIT> 9
正则字面量 <REGEX_LIT> 3
列槽(字段) <FIELD_KEY> / <NUMERIC_FIELD> ~4
列槽(标签) <TAG_KEY> ~3
标签值 <TAG_VALUE> ~5(按 key 分组)

维度 3:单树展开空间与 seed 采样

一棵含 n n n 个非关键字叶子的树,理论展开空间为笛卡尔积:

∣ SQL ( t ) ∣ = ∏ i = 1 n k i |\text{SQL}(t)| = \prod_{i=1}^{n} k_i ∣SQL(t)∣=i=1∏nki

以一棵典型深度 10 的 SELECT 树为例:

复制代码
SELECT <FN_AGG_NUM>(<NUMERIC_FIELD>) FROM m
  WHERE <TAG_KEY> <OP_REL> <TAG_VALUE>
  GROUP BY time(<DURATION_LIT>)
  LIMIT <INT_LIT>

该树有 7 个非关键字叶子: 7 × 4 × 3 × 7 × 5 × 9 × 7 = 185   220 7 \times 4 \times 3 \times 7 \times 5 \times 9 \times 7 = 185\,220 7×4×3×7×5×9×7=185220 条理论 SQL。而对含 20 个叶子、平均池大小 k ˉ ≈ 5 \bar{k} \approx 5 kˉ≈5 的深层树: 5 20 ≈ 10 14 5^{20} \approx 10^{14} 520≈1014------单棵树的展开空间就已经是"不可穷举"级别。

--seeds 参数决定每棵树实际采样几次。每个 seed 对应一次完整的 rng 重置,所有抽象终结符独立重新采样:

seeds 配置 每棵树采样次数 45 300 棵树的候选上限 去重后典型产出
42(单 seed) 1 45 300 ~34 000
42,100,200(3 seeds) 3 135 900 ~102 000
42,100,200,300,400(5 seeds) 5 226 500 ~168 500(本文基线)

整条链路的量化总结

148 条产生式 ⏟ PEC 数学保证 → +采样 45   300 棵树 ⏟ 五重防线 → × 5 seeds 168   509 条候选 ⏟ 笛卡尔积采样+去重 → 36 条 validator 16   421 条可执行 ⏟ 语义过滤 \underbrace{148 \text{ 条产生式}}{\text{PEC 数学保证}} \xrightarrow{\text{+采样}} \underbrace{45\,300 \text{ 棵树}}{\text{五重防线}} \xrightarrow{\times 5 \text{ seeds}} \underbrace{168\,509 \text{ 条候选}}{\text{笛卡尔积采样+去重}} \xrightarrow{\text{36 条 validator}} \underbrace{16\,421 \text{ 条可执行}}{\text{语义过滤}} PEC 数学保证 148 条产生式+采样 五重防线 45300 棵树×5 seeds 笛卡尔积采样+去重 168509 条候选36 条 validator 语义过滤 16421 条可执行

各步放大/压缩比:

步骤 输入 输出 比率 机制
PEC + 深度采样 → 派生树 148 产生式 45 300 棵树 ×306 结构多样性扩展
派生树 × seeds → 候选 SQL 45 300 × 5 = 226 500 168 509 条 ×0.74 笛卡尔积采样 + 字符串去重
候选 SQL → 可执行 SQL 168 509 条 16 421 条 ×0.097 36 条 validator 语义过滤
端到端 148 产生式 16 421 条 SQL ×111 覆盖 100% 产生式/Pop 边/Push 边

关键洞察:从 148 条产生式到 16 421 条可执行 SQL,端到端放大 111 倍 。其中结构多样性贡献了 306 倍放大,seed 实例化在去重后贡献 ×3.7,语义过滤压缩到 9.7%。整条链路的瓶颈不在组合空间(理论 10 14 10^{14} 1014/棵树),而在 validator 的语义过滤率------这也是提升有效样本率的主要优化方向。

Phase B:metamorphic 通道(_build_metamorphic_groups

Phase B 的代码实现细节已在 4.2.6 节展开(函数 _build_metamorphic_groups 的完整代码、五道门机制、具体筛选样例)。此处仅从解空间量化角度分析该通道的规模特征。

解空间压缩链路

从派生树池到最终 metamorphic 变体组,经历五道门控的逐级过滤:

过滤条件 典型留存占比(累计) 被过滤的树类型
门 2(无列槽) saw_column_slot == True ~70 % SHOW / 纯常量 SELECT / 纯 * SELECT
门 3(TAG 非法) is_semantically_valid(tag_sql) ~45 % mean(tag) / sum(tag) / tag 用 < >= 的谓词
门 4(4 变体塌陷) len(sqls_set) > 1 ~44 % 列槽被 glue 吃掉等边缘情况
门 5(组签名重复) 组签名 tuple(sqls) 首次出现 ~35 % 浅层树跨 base_seed 的重复

45 300 × 35 % ≈ 15 800 条候选 → 经 --max-metamorphic-groups=2000 封顶后留下 2 000 组 = 8 000 条 SQL。

45   300 棵树 ⏟ 派生树池 → 5 道门 ∼ 15   800 组候选 ⏟ 35% 留存 → 封顶 2   000 组 ⏟ CLI 参数 → × 4 kinds 8   000 条 SQL ⏟ 最终产出 \underbrace{45\,300 \text{ 棵树}}{\text{派生树池}} \xrightarrow{\text{5 道门}} \underbrace{\sim 15\,800 \text{ 组候选}}{\text{35\% 留存}} \xrightarrow{\text{封顶}} \underbrace{2\,000 \text{ 组}}{\text{CLI 参数}} \xrightarrow{\times 4 \text{ kinds}} \underbrace{8\,000 \text{ 条 SQL}}{\text{最终产出}} 派生树池 45300 棵树5 道门 35% 留存 ∼15800 组候选封顶 CLI 参数 2000 组×4 kinds 最终产出 8000 条 SQL

这解释了 Phase B 规模 / Phase A 规模 ≈ 5 %------Phase B 的聚焦是 Oracle 密度而不是覆盖广度。

为什么要限制 metamorphic 的组合数

上面的留存率给了一个"大约还能剩 35 %"的粗估,而一旦把 --max-metamorphic-groups 拉到远超实际上限(让门 0 完全不生效)、同时把 --depths 5,10,15 --max-trees 15000 --seeds 42,100,200,300,400 一起开起来,真实产出会直接跳到下面这个量级:

复制代码
[metamorphic] produced 25 669 variant groups (= 102 676 SQLs)
              from 32 936 unique trees

分类明细(按 stmt type):

stmt type 独立变体组 展开后 SQL 条数(× 4)
DELETE / DROP 14 821 59 284
SHOW 7 322 29 288
SELECT 3 526 14 104
合计 25 669 102 676

上限从何而来 :32 936 棵去重派生树经 _build_metamorphic_groups 的 3 道核心过滤(saw_column_slot + is_semantically_valid(TAG) + 4 变体不塌陷/组签名不重复)后剩下 25 669 组,也就是 102 676 条 SQL。

为什么 DELETE 组数远超 SELECTgrammar.pyDELETE / DROP SERIES 的产生式虽然只有 ~5 条,但每条都必然出现在"WHERE 含列槽"的构造里(DELETE WITHOUT WHERE 会被收紧规则拦掉,太少),而且 DELETE 的 WHERE 支持 tag_or_time_pred 组合出 AND/OR 深嵌套,PEC + 深度采样下极易命中不同的结构签名。SELECT 反而因为 projection 部分结构变化多端,但带列槽 的仅占一部分(其他命中"仅函数 / 仅 *"的被门 2 过滤掉了)。

实际执行的时间代价 (按 run_diff_all.py 的典型参数估算:--sleep-ms 8;每条 SELECT/SHOW ~0.2s;每条 DELETE ~2s = sandbox-write + execute + verify + drop 四段):

stmt type SQL 数 单条平均耗时 子总耗时
SELECT 14 104 0.2 s ~47 min
SHOW 29 288 0.2 s ~98 min
DELETE 59 284 2.6 s ~43 小时
合计 102 676 --- ~45 小时

DELETE 是主要成本 ,因为每条都需要 sandbox 写入约 11 520 个数据点 + DROP 清理。这就是为什么默认值选 --max-metamorphic-groups=2000------它大致对应 1 000 组 SELECT/SHOW + 1 000 组 DELETE ≈ 2 000 groups × 4 = 8 000 变体 ,runner 里再对 DELETE 部分按 --delete-limit=2500 裁一次,整个 Phase B 跑完约 40 分钟,与 plain 通道的跑批耗时相当。

结论:--max-metamorphic-groups 是为了把 Phase B 的 DELETE sandbox 成本控制在 plain 通道的同一数量级,而不是出于信号密度的考虑。DELETE 的破坏性操作 + 物理状态 verify 的四段时延决定了它无法像 SELECT/SHOW 那样线性放大。

如果真要跑全量 25 669 组,有两种可行方案:

  1. 把 DELETE 组的 sandbox 写入从 11 520 点降到 ~100 点 (改 prepare_data.generate_line_protocolDURATION_POINTS)→ DELETE 单条降到 ~0.3 s → 全量 DELETE 约 5 小时,总耗时约 8 小时
  2. 分批跑 :先跑 2 000 组(当前默认),根据发现的问题决定是否扩展(--max-metamorphic-groups 25700 --delete-limit 60000,分 2-3 轮)。

两种方案都保留"每组 4 变体对齐"的 Oracle 契约,区别只在"一次性跑"还是"窗口式跑"------封顶参数是为工程可执行性而非数学完备性服务的。

Phase B 紧接着 Phase C 写入同一个 <out>.sql 文件(plain 在前、metamorphic 组连续在后),并逐行写 <out>.sql.meta.jsonl,让下游可以按行号对齐识别每条 SQL 所属的 meta_group_idmeta_kind

5.4 第四层:语义约束后的可执行解空间

Plain 通道:36 条 validator 过滤 + DELETE 降采样

Phase A 的 168 509 条候选 SQL 送入 36 条 validator(SELECT 24 + SHOW 6 + DELETE 6),按语句类型分发:

复制代码
candidate (168 509) ──┬──► SELECT 候选 : ~140 000
                      │                  └─► 24 条 validator → 5 944 条
                      ├──► SHOW   候选 : ~15 000
                      │                  └─► 6 条 validator  → 8 477 条
                      └──► DELETE 候选 : ~13 509
                                         └─► 6 条 validator  → 16 368 条(展开后)
                                             └── 降采样 --delete-limit=2000
                                                 → 实跑 2 000 条

合计 plain 通道实跑  = 5 944 + 8 477 + 2 000 = 16 421 条
Metamorphic 通道:TAG 金丝雀 + --max-metamorphic-groups 封顶

Phase B 的筛选在生成端就已经发生:

  • saw_column_slot == True → 树有锚点;
  • TAG 变体过 validators.is_semantically_valid() → 整棵树在这个 seed 下能落地;
  • 4 变体互不塌陷 → 组内确实能产生跨 kind 差异。

通过三道门的组 ≤ 2 000,每组 4 条,metamorphic 通道 ≤ 8 000 条

run_diff_all.py 进一步对 DELETE 类 metamorphic 组按 --delete-meta-group-limit 再做降采样(避免破坏性写太多),但 SELECT/SHOW 组 100 % 保留。

综合语料规模
复制代码
plain 通道实跑         = 16 421 条
metamorphic 通道实跑   = ≤ 8 000 条(典型 ~6 000 条)
─────────────────────────────
总 corpus 规模(文法驱动部分) ≈ 22 000 ~ 24 000 条

加上 category_based/ 手工枚举 5 753 条,仓库完整 corpus ≈ 28 000 ~ 30 000 条

5.5 三层空间对比总表

层次 规模 相对理论空间 覆盖保证
理论解空间(d=10) ∼ 10 40 \sim 10^{40} ∼1040 100 %(定义) ---
派生树池(五重防线) 45 300 棵 4.5 × 10 − 37 4.5 \times 10^{-37} 4.5×10−37 % 148/148 产生式 + 97/97 Pop 边 = 100 %
候选 SQL(Phase A 多 seed 具体化) 168 509 1.68 × 10 − 35 1.68 \times 10^{-35} 1.68×10−35 % 81/83 终结符实例 = 97.6 %
可执行 SQL(plain 通道) 16 421 1.64 × 10 − 36 1.64 \times 10^{-36} 1.64×10−36 % 整体 BOTH_ERROR 15.7 %
metamorphic 变体(Phase B) ≤ 8 000(≤ 2 000 组) 8 × 10 − 37 8 \times 10^{-37} 8×10−37 % 4-kind 笛卡儿积 = 100 %;TAG validator 金丝雀
跨变体 Oracle 断言数 约 6 × 组数 --- 结构化 data-safety 信号(6 类 finding)

三个关键比率

复制代码
理论空间 : 派生树 : 候选 SQL : 可执行 SQL : metamorphic
10^40   : 4.5×10^4: 1.68×10^5: 1.64×10^4 : ≤ 8×10^3

10 35 倍 ⏟ 递归剪枝    →    3.7 倍 ⏟ 实例化    →    10 倍 ⏟ 语义过滤    →    0.5 倍 ⏟ metamorphic 聚焦 \underbrace{10^{35}\text{ 倍}}{\text{递归剪枝}} \;\to\; \underbrace{3.7\text{ 倍}}{\text{实例化}} \;\to\; \underbrace{10\text{ 倍}}{\text{语义过滤}} \;\to\; \underbrace{0.5\text{ 倍}}{\text{metamorphic 聚焦}} 递归剪枝 1035 倍→实例化 3.7 倍→语义过滤 10 倍→metamorphic 聚焦 0.5 倍

解读

  1. 递归剪枝 靠 PEC + 五重防线------LR 图结构仍然 100 % 覆盖,不牺牲论文 PEC 的数学保证;
  2. 实例化 靠多 seed 扇出(Phase A),把一棵"SQL 模板"变成多条具体 SQL;
  3. 语义过滤 靠 36 条 validator,把"文法合法但引擎拒绝"的垃圾样本剔除;
  4. metamorphic 聚焦 靠 Phase B 的 kind_override + TAG 金丝雀,把一棵树变成 4 条等价类变体,换取跨变体 Oracle 的高信息密度。

整条流水线在一次 generate.py 调用里 把语料从"宇宙级"( 10 40 10^{40} 1040)压到"2.2 万",并额外加挂 2 000 个 metamorphic 变体组,同时保证:

  • 148 / 148 产生式至少被每条至少一次跑到;
  • Tag/Field 4 种等价类都被完整覆盖;
  • 每条 validator 的拦截率可单独统计;
  • plain 与 metamorphic 由 sidecar <out>.sql.meta.jsonl 对齐,生成端与执行端解耦;
  • 生成端和执行端的分离可做消融实验(--no-metamorphic 快速对比通道贡献)。

六、方法论总结

把本方法论按测试目标分层:

复制代码
┌─────────────────────────────────────────────────────────────┐
│ L0 写路径差分 (line protocol)        未来方向                 │
│ L1 Parser 归约覆盖 (PEC, 论文核心)   grammar_driven            │
│ L2 词法粘合 (_GLUE_BEFORE_LPAREN)    terminalizer              │
│ L3 方言语义 (36 条 validator)        validators                │
│ L4 类型 & Tag/Field (PEC-融合)       generate.py::             │
│                                        _build_metamorphic_groups│
│                                      + metamorphic_oracle.py   │
│                                      + sidecar .meta.jsonl     │
│ L5 Oracle-free (TLP / NoREC / PQS)   推荐下一步                │
│ L6 破坏性操作 (sandbox + verify)     run_diff_all              │
│ L7 稳定性 (CRASH 自愈 + perf)        CRASH 恢复已有             │
└─────────────────────────────────────────────────────────────┘

每层独立产出 jsonl,generate_report.py 聚合。业界方法定位对照:

方法 Oracle-free 核心思路 本系统角色
手工枚举(category_based/ 专家矩阵 L4 保底 + parser 之外
Rossouw-Fischer PEC 文法覆盖 L1 骨架
本方法三层约束 PEC + lexer + semantic L1-L3 可执行
PEC-融合 metamorphic kind_override + sidecar + metamorphic_oracle.evaluate() L4 Tag/Field 等价类
SQLancer-TLP 三元分区等价 L5 谓词 bug
SQLancer-NoREC 参考引擎构造等价 L5 optimizer bug
SQLancer-PQS pivot-row 包含 L5 边界值
Jepsen 故障注入 L7 分布式

6.1 三条核心原则

回头看整套方法论,有三条原则贯穿始终:

原则 1:把论文的"文法合法即正例"解耦成"文法合法 + 语义合法 + 类型合法 + 物理状态合法"

每一条合法性检查有独立位置、独立可观测性、独立可关闭:

  • 文法:在 grammar.py 的收紧产生式里;
  • 语义:在 validators.py 的 36 条规则里;
  • 类型:在 terminalizer.py 的列槽分类 + kind_override 里;
  • 物理状态:在 run_diff_all.py 的 sandbox + verify 两段比较里。

原则 2:metamorphic 不是独立的测试层,而是 PEC 实例化阶段的"Oracle 变换"开关

工程落地体现在三个地方:

  • 生成端generate.py 的 Phase B _build_metamorphic_groups 与 Phase A plain 并列,共享派生树池和 Terminalizer 实例。--no-metamorphic 可一键关闭。
  • 对齐机制 :sidecar <out>.sql.meta.jsonl 按行号标注 meta_group_id + meta_kind,让生成端和执行端彻底解耦------生成换策略不用动 runner。
  • Oracle 端metamorphic_oracle.py 独立模块,契约明确 (stmt_type, variants_by_kind, group_id) → findings,6 类结构化 bug 签名。run_diff_all.py 只负责按 meta_group_id 缓冲 4 条变体、四条到齐后调 evaluate()

同一套 PEC 生成管线上,关闭 metamorphic 就是普通差分测试(覆盖广度),开启就是 metamorphic 测试(类型等价类精确打击)。两种模式共享派生树池,物理产出 SQL 各异,Oracle 类型互补。

原则 3:剩余 BOTH_ERROR 不是必须消灭的噪音,而是"两端一致拒绝"的兼容性证据

15.7 % 的 BOTH_ERROR 中 98 % 集中在 SHOW 支路(meta_pred 的 tag 运算符),通过一轮语法收紧即可降到 < 5 %。剩下的 0.5 % 本身就是 oracle 自洽性证据------两端一致拒绝等于达成"都不支持这类语法"的兼容性保证。过度压缩 BOTH_ERROR 反而会屏蔽真实 bug:比如"只有 xstor 失败"的签名一旦写入 validator 就被遮蔽。

6.2 一句话结论

把 CFG 视作语法规范,把 validator 视作语义规范,把 kind_override + sidecar .meta.jsonl + metamorphic_oracle 视作类型等价类 Oracle,把 sandbox + verify 视作物理状态 Oracle,把 CRASH 自愈视作差分测试基础设施。把 parser 层的 PEC 正例当作这些 Oracle 的"原材料",而不是"最终测试用例"。

当前仓库从 10 40 10^{40} 1040 的理论空间出发,用五重防线砍到 45 300 棵派生树,在一次 generate.py 调用中通过 Phase A 扇出约 17 万条 unique plain SQL、通过 Phase B 另外派生 ≤ 2 000 组 metamorphic 变体(≤ 8 000 条),再用 36 条 validator 把 plain 通道压到 ~1.6 万条可执行 SQL;Phase C 把两路写入同一个 <out>.sql + sidecar .meta.jsonl;下游 run_diff_all.py 按行号分流,plain 走单条差分 Oracle、metamorphic 走 metamorphic_oracle.evaluate() 的 6 类结构化签名。LR 图的 100 % 产生式/Pop 边/Push 边覆盖 + Tag/Field 4-kind 等价类 100 % 覆盖,由同一条生成管线一次性交付------这是整套方法论的数学内核,也是当前工程架构的核心升级。


参考文献:

  • Rossouw & Fischer, Grammar-Based Test Suite Construction using Coverage-Directed Algorithms over LR-Graphs, SSRN 4423845 (2023)
  • Rigger & Su, Testing Database Engines via Pivoted Query Synthesis (PQS), OSDI '20
  • Rigger & Su, Finding Bugs in Database Systems via Query Partitioning (TLP), OOPSLA '20
  • Rigger & Su, Detecting Optimization Bugs in DBMSs via Non-optimizing Reference Engine Construction (NoREC), FSE '20
  • Kyle Kingsbury, Jepsen --- https://jepsen.io
  • InfluxData, InfluxQL Reference
相关推荐
爱学习的小囧1 小时前
ESXi 开启 Secure Boot 后驱动签名验证失败完整处置教程:合规修复与临时测试方案全解
服务器·数据库·esxi·虚拟化
weixin_568996062 小时前
如何用 IndexedDB 存储从 API 获取的超大列表并实现二级索引
jvm·数据库·python
APIshop2 小时前
小红书笔记视频详情接口深度解析:smallredbook.item_get_video_pro
数据库·笔记·音视频
空中海2 小时前
Redis 从零到精通:9大数据结构 × 11个高频工程实战场景完全手册
数据结构·数据库·redis
qiuyunoqy2 小时前
MySQL - 2
数据库·mysql
2301_775148152 小时前
如何授权AWR报告生成_GRANT SELECT ANY DICTIONARY诊断权限
jvm·数据库·python
空中海2 小时前
Redis 专家实战:生产架构设计 × 容量规划 × 安全治理 × 37道高频面试题全解
数据库·redis·安全
地球资源数据云2 小时前
1951-2025年中国逐年1千米逐月总降水量区域统计数据集_年表_县
大数据·数据结构·数据库·数据仓库·人工智能
l1t2 小时前
DeepSeek v4辅助生成的单文件SQL查询示例页面
javascript·数据库·sql