- 开发工具
- 数据可视化
【免费下载链接】penrose
Create beautiful diagrams just by typing notation in plain text.
本文以仓库中 synthesis 模块文档 为核心骨架,结合
Synthesizer.ts、Mutation.ts、Search.ts及SubstanceAnalysis.ts的源码实现,系统讲解 Penrose 中用于自动生成 Substance 程序变体的核心模块:Synthesizer类如何维护合成上下文、如何按SynthesizerSetting配置驱动「增 / 删 / 改」三类 AST 变异操作、如何通过纯函数分析层保证生成程序的类型合法,以及基于 AST 差分与观测等价性搜索的进阶能力。读完本文,你将能够理解并复现@penrose/edgeworth中"一键生成多张变体图"的核心调用链,并能独立编写一份完整的SynthesizerSetting配置。
一、模块定位:synthesis是什么
synthesis是 Penrose 仓库中负责Substance 程序自动合成的模块,位于packages/edgeworth/src/synthesis/。它对外导出两个核心构件:
Synthesizer类:包含 Penrose 合成器的核心功能。上层包(如@penrose/synthesizer)以及 Edgeworth 的 UI 层都依赖它来批量生成变异后的 Substance 程序。SynthesizerSettings接口(源码中实际命名为SynthesizerSetting,见 Synthesizer.ts):初始化合成器所必需的配置对象,控制变异数量、参数来源、各类语句的权重与开关。
模块内共有四个源码文件与两个测试文件,职责划分非常清晰:
| 文件 | 职责 |
|---|---|
Synthesizer.ts | 合成器主类、SynthesisContext上下文、SynthesizerSetting配置类型、语句生成器 |
Mutation.ts | 变异(mutation)的类型定义、守卫函数(check*)、执行函数(execute*)与枚举器(enum*) |
Search.ts | 两个 Substance 程序之间的 AST 差分(diff)、变异路径搜索、带观测等价性的枚举式搜索 |
SubstanceAnalysis.ts(在../analysis/) | 一组纯函数:AST 节点的创建、转换、比较、删除、签名匹配等 |
二、Synthesizer的工作原理:类与上下文的职责分离
README 中用一个两层的对象模型概括了合成器的核心机制:
2.1Synthesizer:变异操作的生产者
Synthesizer是合成过程的外层对象,它负责产生"要做什么变异"的数据。所谓变异数据,就是一条"增 / 删 / 改"语句(statement)的操作描述。它内部维护的字段(见 Synthesizer.ts):
export class Synthesizer { domEnv: DomainEnv; // 编译后的 Domain 环境(类型/谓词/函数/构造子声明) subEnv: SubstanceEnv; // 编译后的 Substance 环境 template: SubProg<A>; // 模板 Substance 程序(若无模板则为空程序) setting: SynthesizerSetting; currentProg: SubProg<A>; // 当前正在生成的 Substance AST currentMutations: Mutation[]; // 当前程序的变异操作记录 rng: seedrandom.prng; // 可复现的随机数生成器 private choice: <T>(array: Array<T>) => T; // 均匀随机选择 private weightedChoice: <T>(array: Array<T>) => T; // 带权随机选择(用于 opWeights) private random: RandomFunction; // [min,max] 区间随机整数 }构造函数接收(domEnv, subEnv, setting, subRes?, seed = "synthesizerSeed")。其中subRes?: [SubstanceEnv, DomainEnv]用于传入模板 Substance 程序:如果提供了模板,template会被初始化为该程序的深拷贝;否则模板是一个空的SubProg。seed通过seedrandom驱动choice、weightedChoice、random三个随机函数,因此同一 seed 下生成的变体序列是完全可复现的。
2.2SynthesisContext:AST 变更的执行者与记账员
SynthesisContext(Synthesizer.ts)是内层对象,它在合成单个Substance 程序期间维护全部中间状态:
export interface SynthesisContext { names: im.Map<string, number>; // 名称 -> 出现次数 declaredIDs: im.Map<string, Identifier<A>[]>; // 类型名 -> 已声明的 ID 列表 generatedNames: im.Map<string, number>; // 自动生成 ID 的前缀 -> 最新下标 argOption: ArgOption; // "existing" | "generated" | "mixed" argReuse: ArgReuse; // "distinct" | "repeated" subEnv: SubstanceEnv; domEnv: DomainEnv; choice: <T>(array: Array<T>) => T; // 从数组中随机选一个 }上下文是合成过程中的"活账本":declaredIDs按类型登记所有已声明的 ID,generatedNames记录自动生成 ID(如a0、b1)的下标进度。每当Synthesizer决定添加/删除一个Decl语句时,Mutation.ts中的appendStmtCtx/removeStmtCtx会同步调用addID/removeID(Synthesizer.ts)来更新declaredIDs,保证"程序语句"与"上下文登记"始终一致——这正是 README 所说"SynthesisContext执行实际操作并做必要记账(如记录变异操作)"的含义。
initContext(Synthesizer.ts)会以 seed 创建新的seedrandom.prng并建立上下文,同时把subEnv.objIds中已有的对象按类型预登记进declaredIDs。
2.3 关键不变量:每个程序生成后必须 reset
README 特别强调:Synthesizer每次生成一个新的 Substance 程序时,都会显式reset上下文。reset(Synthesizer.ts)把currentProg恢复为模板(经过desugarAutoLabel预处理)的深拷贝,并清空currentMutations。这样保证一次generateSubstance()只对应一个独立的合成过程,多个程序之间互不污染。
三、SynthesizerSetting:一份完整的配置说明书
初始化合成器必须提供SynthesizerSetting。该接口(Synthesizer.ts)由以下几个维度组成:
type All = "*"; type ArgOption = "existing" | "generated" | "mixed"; type ArgReuse = "distinct" | "repeated"; export type MatchSetting = string[] | All; // "*" 表示不限制 export type DeclTypes = { [t in SubStmtKind]: MatchSetting }; // SubStmtKind = "type" | "predicate" | "constructor" | "function" export interface SynthesizerSetting { mutationCount: [number, number]; // 每个程序执行的变异次数区间 [min, max] argOption: ArgOption; // 生成语句参数时:复用已有 ID / 新生成 ID / 混合 argReuse: ArgReuse; // 复用 ID 时:同一 ID 是否可重复出现在多个参数位 weights: { type: number; predicate: number; constructor: number }; opWeights: { [t in MutationType]: number }; // add / delete / edit 三种操作的权重 add: DeclTypes; // 允许"新增"的语句种类(按 kind 过滤,值为名称列表或 "*") delete: DeclTypes; // 允许"删除"的语句种类 edit: DeclTypes; // 允许"编辑"的语句种类 }3.1 各配置项语义与底层影响
mutationCount:generateSubstance()用this.random(...this.setting.mutationCount)从区间内随机取一个整数作为本程序的变异步数(Synthesizer.ts)。区间两端都闭。argOption与argReuse:在generateArg(Synthesizer.ts)中直接决定参数如何产生:existing:从declaredIDs中按参数类型(含subTypesOf子类型)查找已声明的 ID;distinct模式下会排除usedIDs中已用过的 ID,repeated则允许重复使用。若找不到可用 ID,会回退到generated模式自动生成一个新 ID。generated:根据domEnv.typeDecls生成一个新的Decl语句并返回其名字。mixed:对每个参数随机选择existing或generated。
weights:控制新增语句时在「类型 / 谓词 / 构造子」之间如何选择。注意该结构中并没有单独的 function 权重——函数声明在enumerateAdd中同样被枚举,但带权选择仅覆盖上述三类。opWeights:mutateProgram中通过weightedChoice在add/delete/edit三种操作类型里做一次带权抽样(Synthesizer.ts),再进入对应的枚举分支。add/delete/edit:DeclTypes按SubStmtKind给出每类语句的匹配设置。值为"*"表示不限制;值为字符串数组时只允许数组内列出的名称。这些配置经filterContext(Synthesizer.ts)作用:过滤domEnv中的types、typeDecls、functionDecls、predicateDecls、constructorDecls,从而缩小每一步变异时可选的候选集。delete与edit还会以模板程序(template)为参照做二次过滤,避免破坏模板中定义的关键结构。
3.2 仓库中的真实配置示例
Edgeworth 的预设配置 给出了可直接复制的完整样例(Lewis 分子结构预设):
const lewisParams: SynthesizerSetting = { mutationCount: [1, 4], argOption: "existing", argReuse: "distinct", weights: { type: 0.15, predicate: 0.5, constructor: 0.35 }, opWeights: { add: 0, delete: 0, edit: 1 }, // 只做"编辑",不做增删 add: { type: "*", function: "*", constructor: "*", predicate: "*" }, delete: { type: "*", function: "*", constructor: "*", predicate: "*" }, edit: { type: "*", function: "*", constructor: "*", predicate: "*" }, };而 Synthesizer.test.ts 中的defaultSetting展示了另一种风格:opWeights均分0.3333,且add/delete/edit各字段初始为空数组(表示默认不开启)。两者对照可以看出:权重决定"改不改",DeclTypes 决定"改什么",二者共同塑造变体的多样性方向。
四、生成流水线:从配置到一批 Substance 程序
4.1 顶层入口generateSubstances(numProgs)
批量生成是 UI 层最常用的入口(Synthesizer.ts):
- 用
_.times(numProgs, oneSubstance)逐个生成; - 每个程序生成完毕后立刻
reset(); - 用
dedupSynthesizedSubstances(按prettySubstance字符串比较)去重; - 若去重后数量不足
numProgs,继续循环补足,直到凑齐指定数量的互不相同程序。
每个SynthesizedSubstance返回三件套(Synthesizer.ts):
export interface SynthesizedSubstance { prog: SubProg<A>; // 合成后的 Substance AST ops: Mutation[]; // 本程序经历的变异操作记录 src: string; // pretty-printed 的 Substance 源码文本 }ops与src让上层 UI 既能展示"程序长什么样",也能展示"经历了哪些操作"。
4.2 单程序合成generateSubstance()
(Synthesizer.ts)核心流程:
generateSubstance = (): SynthesizedSubstance => { const numStmts = this.random(...this.setting.mutationCount); // 随机变异步数 _.range(numStmts).reduce((ctx, n) => { const newCtx = this.mutateProgram(ctx); // 每步做一次变异 return newCtx; }, initContext(this.domEnv, this.subEnv, ...)); const prog = sortStmts(dedupStmts(this.currentProg)); // 排序 + 去重,防编译错误 return { prog, ops: this.currentMutations, src: prettySubstance(prog) }; };其中sortStmts与dedupStmts都来自SubstanceAnalysis.ts:前者按「Decl → ApplyPredicate → Bind → AutoLabel → LabelDecl → NoLabel」的类别顺序加源码字典序重排(SubstanceAnalysis.ts),后者按 pretty 字符串去重——两者共同确保随机变异不会产出重复或顺序敏感的编译错误。
4.3 单步变异mutateProgram(ctx)
每步变异遵循「先枚举、后带权抽样、再执行」三段式(Synthesizer.ts):
- 枚举三组候选:
enumerateAdd(addCtx)、enumerateDelete(deleteCtx)、enumerateUpdate(editCtx),各上下文分别由filterContext按add/delete/edit配置过滤得到; - 带权选操作类型:
weightedChoice依据opWeights抽出add | delete | edit; - 执行:
executeMutations(mutationGroup, this.currentProg, ctx)依次应用组内所有变异,把结果写回currentProg并追加到currentMutations; - 无候选时递归重试:若当前类型没有可用变异,则递归调用自身重新抽样(保证每步都能产生变化)。
三个枚举器的工作方式:
enumerateAdd(Synthesizer.ts):从nonEmptyDecls(ctx)(当前上下文中仍有候选声明的语句种类)里随机选一种,然后生成对应的新语句组:TypeDecl→ 生成一个Decl(generateDecl);PredicateDecl/FunctionDecl/ConstructorDecl→ 调用对应的generatePredicate/generateFunction/generateConstructor,返回「新参数声明 + 新语句」的组合,打包成一组Add变异。
enumerateDelete(Synthesizer.ts):随机选一个语句种类和名称,findStmt定位语句;对Bind/Decl这类"返回值语句"调用cascadingDelete找出所有引用其返回值的语句一并删除,其余语句直接单删。enumerateUpdate(Synthesizer.ts):随机挑一条语句,调用findMutations枚举该语句上所有可用的编辑变异。
五、变异类型全表:Add / Delete / Update
Mutation.ts定义了完整的变异类型系统(Mutation.ts):
export type MutationGroup = Mutation[]; export type Mutation = Add | Delete | Update; export type MutationType = "add" | "delete" | "edit"; export interface MutationBase { tag: Mutation["tag"]; additionalMutations?: Mutation[]; // 伴随变异(如改类型时的连带增删) mutate: (op, prog, ctx) => WithContext<SubProg<A>>; // 实际执行函数 } export type Update = | SwapExprArgs | SwapStmtArgs | SwapInStmtArgs | SwapInExprArgs | ReplaceStmtName | ReplaceExprName | ChangeStmtType | ChangeExprType;每种Update变异的具体语义(可由showMutation的输出印证,Mutation.ts):
| 变异标签 | 语义 | 适用语句 |
|---|---|---|
SwapStmtArgs | 交换谓词语句两个参数的位置 | ApplyPredicate |
SwapExprArgs | 交换 Bind 表达式中两个参数的位置 | Bind的ApplyConstructor/ApplyFunction/Func |
SwapInStmtArgs | 把谓词语句的某个参数替换为同类型的另一个 ID | ApplyPredicate |
SwapInExprArgs | 把 Bind 表达式中的某个参数替换为同类型 ID | Bind |
ReplaceStmtName | 将谓词语句名替换为签名相同的另一声明 | ApplyPredicate |
ReplaceExprName | 将 Bind 中的表达式名替换为签名相同的另一声明 | Bind |
ChangeStmtType | 整体替换谓词语句为签名兼容的新语句(连带删除旧语句、补上新声明) | ApplyPredicate |
ChangeExprType | 替换 Bind 表达式;若新语句返回值类型变化则cascadingDelete旧绑定 | Bind |
5.1 守卫函数(check*)与枚举器(enum*)
- 守卫函数:
checkAddStmt(s)、checkSwapStmtArgs、checkReplaceStmtName、checkSwapInStmtArgs、checkChangeStmtType等(Mutation.ts)负责判断某条语句在当前上下文下能否执行某类变异,能则构造对应的Mutation对象并返回,不能则返回undefined。守卫函数会把"候选选择"以回调形式交给Synthesizer(由choice完成随机挑选),例如交换参数时只在类型相同的索引对中选择(用getSignature比较args[i] === args[j])。 - 枚举器:
enumSwapStmtArgs、enumReplaceStmtName、enumChangeExprType等(Mutation.ts)则是穷举某条语句的全部可行变异。mutationEnumerators数组把它们统一注册,enumerateStmtMutations与enumerateProgMutations分别提供"单语句枚举"和"全程序枚举"。
5.2 执行函数与上下文同步
executeMutation/executeMutations(Mutation.ts)逐个调用MutationBase.mutate。关键在于增删Decl时要同步维护上下文:
appendStmtCtx:若新增的是Decl,先addID(ctx, stmt.type.name.value, stmt.name)再追加语句;removeStmtCtx:若删除的是Decl,先removeID再移除语句(Mutation.ts)。
这正是 README 所述"上下文负责实际操作与记账"在代码层面的直接体现。
六、纯函数分析层:SubstanceAnalysis
README 指出:Synthesizer与SynthesisContext在与 Substance AST 交互时,统一使用analysis/SubstanceAnalysis中的纯函数来创建或转换 AST 节点。这一约定保证了所有 AST 操作可预测、无副作用、便于测试。核心纯函数包括(SubstanceAnalysis.ts):
- 结构操作:
appendStmt、removeStmt、replaceStmt、getStmt、subProg——均为"返回新 AST"的不可变风格实现; - 节点工厂:
applyConstructor/applyFunction/applyPredicate/applyBind/applyTypeDecl/nullaryTypeCons/labelStmt——把 Domain 声明转换成 Substance AST 节点,统一打上nodeType: "SyntheticSubstance"标记,与用户手写节点区分; - 签名匹配:
getSignature、signatureEquals、signatureArgsEqual、matchDecls、matchSignatures、argMatches、findDecl、identicalTypeDecls——支撑ReplaceName、SwapIn、ChangeType等变异候选的合法性判定; - 删除分析:
cascadingDelete——给定一个将被删除的Bind/Decl,用栈式搜索迭代找出所有引用其返回值/变量的语句并一起返回(SubstanceAnalysis.ts),避免生成悬空引用; - 清洗与比较:
cleanNode(剔除metaProps元属性)、nodesEqual、progsEqual、intersection、sortStmts、dedupStmts、dedupSynthesizedSubstances; - 类型发现:
findTypes/mergeKindMaps/typeOf——从程序中提取用到的 type/predicate/function/constructor 名称集合,用于filterContext的二次过滤。
其中cascadingDelete是保证删除安全的关键:删除Set A时,若存在Set C := Subset(B, A)、Set E := Intersection(D, C)这类依赖链,它会递归地把C、E及其绑定语句全部纳入删除集。这一行为被 Synthesizer.test.ts 中的 "cascading delete" 用例直接验证:删除Set A后,程序被正确化简为Set B/Set D。
七、进阶能力:Search.ts的差分与路径搜索
Search.ts(Search.ts)提供了 README 未展开但同属合成模块核心的能力:在任意两个 Substance 程序之间计算差异,并搜索"如何从一个变异到另一个"的路径。这用于把用户编辑后的程序反向映射回变异操作序列。
7.1 AST 差分(Diff)
diffSubProgs:用recursive-diff的getDiff计算两棵 AST 的精确差异,并过滤掉路径中涉及metaProps(位置、nodeType 等元信息)的噪音差异;diffSubStmts:先对两边程序sortStmts归一化顺序,再做精确差分,输出带语句索引的StmtDiff[];subProgDiffs:语句级差分,产出DiffSet { add, delete, update }三分类结果——先用intersection找公共语句,再对剩余语句用similarNodes(节点相等或拥有共同后代)建立相似映射,最终把"相似对"判为update、把"多出/缺失"判为add/delete。
Search.test.ts 展示了典型结果:两个集合程序之间的差异会被正确识别为Delete: Equal(E, E)与两条Update(含具体到(variable,value) -> E这样的细粒度路径描述)。
7.2 变异路径搜索
findMutationPaths(src, dest, ...):对DiffSet中的每条 update,在源语句上用enumerateStmtMutations枚举候选变异,用executeMutation+prettySubstance字符串比对筛选出能精确还原目标语句的变异,再与 add/delete 变异做笛卡尔积组合成完整路径组;enumerateAllPaths:不做"匹配筛选",直接对所有候选变异做笛卡尔积,产出全部可行路径;enumerateMutationPaths(src, dest, initCxt, maxDepth):基于 BFS 的枚举式搜索,从src出发逐层生长候选程序,每层先enumerateProgMutations枚举、再executeMutation执行,并用prettySubstance字符串去重(观测等价,observational equivalence)剪枝,直到深度maxDepth或命中目标。测试用例(Search.test.ts)验证了它能找到包含SwapStmtArgs+ChangeExprType+ReplaceStmtName的最短三步路径。
八、上层调用方:Edgeworth 如何驱动合成器
synthesis模块不是孤立的——Edgeworth 应用通过两条路径消费它:
- 批量变体生成(problems/util.tsx):
generateProgs中先用compileDomain编译 Domain,可选地用compileSubstance编译模板 Substance,然后new Synthesizer(domEnv, subEnv, setting, subRes, seed)并调用synth.generateSubstances(numPrograms)。模板程序(getTemplate())会被放在结果列表首位,作为变体的"原图"对照。 - 设置面板(components/Settings.tsx):UI 提供"Pick a Domain"下拉与"Input Scenario"编辑框,点击 "Generate Variations" 时把当前
SynthesizerSetting、seed、程序数量、domain/style/substance 一起交给回调;"More Variations" 按钮会通过generateVariation()(颜色+动物名+随机数字拼出的随机 seed)生成全新种子后再触发一次生成。
九、如何运行与验证
synthesis模块的测试基于 Vitest(@vitest-environment jsdom),可在仓库根目录按序执行:
yarn install yarn vitest run packages/edgeworth/src/synthesis现有测试覆盖三类能力:
- Synthesizer.test.ts:级联删除的正确性;
- Mutation.test.ts:单语句枚举(例如对三元谓词
Equal3(A, B, C)恰好枚举出 3 条SwapStmtArgs); - Search.test.ts:AST 差分、变异识别(swap/change type/replace name)、含噪音的多变异路径搜索。
若要在浏览器中体验合成器 UI,可进入packages/edgeworth后执行yarn start(开发模式)或yarn build(产物输出到dist),在设置面板中选择 Domain、调整配置并点击 "Generate Variations" 批量产出变体图。
十、小结
synthesis模块通过「Synthesizer决策 +SynthesisContext记账 +SubstanceAnalysis纯函数执行」的三层协作,把"生成一个合法的 Substance 程序变体"拆解为可配置、可复现、可审计的变异流水线:SynthesizerSetting控制变异预算与候选范围,Mutation类型系统统一描述增删改操作,Search.ts则提供反向的差分与路径搜索能力。无论是为@penrose/synthesizer提供批量训练数据,还是像 Edgeworth 那样为教学场景生成选择题选项,这套架构都构成了 Penrose 自动生成内容(diagram synthesis)的基础设施。
- 开发工具
- 数据可视化
【免费下载链接】penrose
Create beautiful diagrams just by typing notation in plain text.
相关推荐
escrcpy安卓投屏完整指南:零门槛把手机屏幕镜像到电脑
escrcpy安卓投屏完整指南:零门槛把手机屏幕镜像到电脑 用 escrcpy 做安卓投屏:把手机画面实时镜像到电脑,鼠标键盘都能直接操控手机。它完全免费、无需
开发工具数据可视化Tikv内存引擎在线配置变更失效问题分析
Tikv内存引擎在线配置变更失效问题分析 在Tikv项目的内存引擎 Range Cache Memory Engine 实现中,发现了一个关于配置动态更新的设计
数据库KV存储分布式数据库云原生深入解析 k6 内置 JavaScript 引擎 sobek 的 parser 包:从源码到 AST 的完整指南
深入解析 k6 内置 JavaScript 引擎 sobek 的 parser 包:从源码到 AST 的完整指南 k6 是一个使用 Go 编写、以 JavaSc
测试开发工具CI/CD
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考