表达式¶
表达式是 plaita 的"数据胶水":在节点的 output、condition、input 等字段中引用上下文数据、调用内置函数。本页给出完整语法。
速查:最常用的 5 个写法¶
| 需求 | 写法 | 示例 |
|---|---|---|
| 引用输入字段 | $INPUT.字段名 |
"$INPUT.name" |
| 引用某节点的输出 | $NODE.节点id |
"$NODE.assign" |
| 调用内置函数 | $F.函数名(参数) |
"$F.upper($INPUT.name)" |
| 字符串中嵌入变量 | {% $INPUT.字段名 %} |
"你好,{% $INPUT.name %}" |
| 引用环境变量 | $ENV.变量名 |
"$ENV.API_BASE"(须在 exposeEnv 白名单内) |
!!! warning "重要:表达式前缀是 $,不是 ${}
plaita 的变量引用写作 **`$INPUT.name`**,函数调用写作 **`$F.upper($INPUT.name)`**。
不要写成 `${INPUT.name}`——那是错误的语法,会被当作普通字符串原样返回。
变量引用¶
用 $ 前缀引用执行上下文中的命名空间:
| 表达式 | 含义 | 引用不存在的键时 |
|---|---|---|
$INPUT / $INPUT.name |
整个输入对象 / 其 name 字段 |
None(DEBUG 日志留痕) |
$NODE.assign / $NODE.assign.field |
节点 assign 的输出 / 其字段 |
None |
$NODE.raw.<节点>[.<路径>…] |
节点结果 / 字段的原文——字符串不重新求值(见下文) | None |
$GLOBAL.key |
全局上下文变量(含 flow_id) |
None |
$FLOW_ID |
当前流程的 flow_id |
— |
$FLOW |
$FLOW_ID 的别名(0.5.x 起,历史写法 $FLOW 会 KeyError 崩) |
— |
$PARENT.x |
父流程上下文(子流程中可用;子执行创建时冻结的快照,见下) | None |
$ENV.key |
环境变量(须在 exposeEnv 白名单内,见下文) |
None(未声明时另有解析期 warning) |
缺省口径统一为"缺键返回 None";例外有二:整个根不存在(如上下文尚未建立 $NODE 状态)时保留 KeyError——那通常意味着流程逻辑错误,静默反而危险;字段名以 _ 开头时按下文「属性遍历与下划线名」抛 ValueError。
支持的命名空间由 ExecutionContext 维护:INPUT / NODE / GLOBAL / PARENT / ENV。命名空间名本身可通过 express_input_name 等参数自定义;子执行(child 内联子逻辑 / parallel 分支 / map·filter·find·reduce·loop 迭代体)继承父执行的命名空间配置(plaita#106)——父级自定义名后,子流程表达式用同一套名字寻址即可。$ENV 白名单同样继承(plaita#141):子流程没有自己声明(非空)exposeEnv 时,父级的 exposeEnv 就是子执行的 allowlist;子流程自己声明了则以声明为准(替换,不合并)。父级从未声明 expose_env 的执行不受影响(子 $ENV 仍为空)。
$PARENT 是子执行创建那一刻的父状态快照(plaita#72):Parallel 未列入 joinBranches 的后台分支不阻塞父流程,父在分支提交之后写入的 $NODE 条目不会出现在该分支的 $PARENT 里(未创建时按缺省口径求值为 None)。需要子流程看到新结果时,请用 joinBranches(父会等分支)或在分支内显式传参。
$ENV 采用 allowlist 模型:不声明就读不到
0.4.0 起 $ENV 是白名单(allowlist)模型,且不存在任何敏感前缀自动过滤:
- 默认情况下流程一个环境变量都读不到——
$ENV.XXX解析不到值。 - Flow 必须显式声明
exposeEnv(JSON/YAML,Python 侧expose_env)后才可读:
- 未声明时,0.5.0 起解析期会
logger.warning一次列出被引用的 key 名与修复指引(不报错,让沉默变可见)。 - allowlist 命中即暴露,请自行确认列表里没有不该暴露的密钥。
升级迁移与 0.4.0 的行为对照见 迁移指南。
数组索引¶
支持数字索引与 [n] 语法:
属性遍历:只允许数据(安全限制)¶
字段段读的是数据:dict / 映射按键取,其它对象按属性取。属性读取
有两道硬报错(不是返回 None)的闸门:
- 下划线名(plaita#55):
_开头的段名一律拒绝; - 代码对象(plaita#133):属性遍历只允许数据。属性解析出的值是 module /
function / builtin function / method / class / frame / traceback / generator /
coroutine / async generator / code object 时拒绝——按
type(obj).__mro__真实类型判定,不读对象自己的__class__。结果值是代码对象时该字段本身 就抛错($NODE.obj.os即拒绝,不会先返回模块再等.environ)。
| 表达式 | 结果 |
|---|---|
$NODE.obj.text |
"hello"(公开数据属性) |
$NODE.obj.os(os 是 module) |
ValueError:代码对象,plaita#133 |
$NODE.obj.os.environ |
ValueError:在 .os 就拒绝,环境变量不会被读取 |
$NODE.obj.__class__ |
ValueError:'__class__' 不是允许的字段名 |
$NODE.obj._private |
ValueError |
$NODE.d._foo(d 是 dict) |
键 _foo 的值——键不受限 |
$NODE._n2(自动生成的节点 id) |
该节点结果——键不受限 |
$NODE.d.__class__(d 是 dict) |
ValueError(dunder 路径段同样拒绝) |
$F.get($NODE.d, '__class__') |
该键的数据值——显式映射辅助不受限(见下) |
规则:
- 属性读取:任何
_开头的段名一律拒绝;解析出代码对象(module / function / class / frame / generator / coroutine / code 等)也一律拒绝; - 路径段取值:单下划线键保持可读(
{"_foo": …}、builder 自动生成的_n2节点 id);__开头的路径段($NODE.d.__class__)拒绝——路径形态无法与属性遍历尝试区分, 静默None只会掩盖越权尝试; - 映射 / 索引路径保持原语义:字段段在 dict / 映射上是键,不是属性;数组索引 与映射值按原语义读取。本闸门只针对属性遍历;
- 拒绝是硬错误:抛
ValueError(消息点名路径、真实类型与plaita#133),在end节点的output等路径上表现为节点错误。不要指望"被挡住 → 得到None"。
刻意的不对称(映射辅助不受限):$NODE.d.__class__ 抛错,但
$F.get($NODE.d, '__class__') / $F.getDictValue($NODE.d, '__class__') 仍返回该键的
数据值,$F.keys($NODE.d) 也仍列出它。映射辅助只是"按键取数据"——键不是属性、
不产生反射语义;路径形态的拒绝针对的是与属性遍历无法区分、且会静默 None 的写法。
原因:$NODE.<节点>.__class__.__init__.__globals__.os.environ 这类反射链,以及
$NODE.h.os.environ 这类公有属性链,在节点返回 dataclass / pydantic / 普通实例
或 SimpleNamespace(os=os) 时都能读出执行进程的环境变量,绕开 $ENV 的
exposeEnv allowlist(见下文)。#55 关闭反射链,#133 关闭公有属性链;公开数据
属性、dict 键、{% … %} 插值与 $NODE.raw.… 都不受影响。迁移说明见
迁移指南。
运行时错误文案(逐字,与代码、迁移指南一致)。
下划线闸(#55):
expression field name '__class__' is not allowed in an expression path: names starting with '_' are reserved for Python internals (for example __class__ / __globals__ / __init__), so the attribute form reaches process state and bypasses the $ENV allowlist, which is an opt-in exposure list and not a sandbox. Public attribute traversal is additionally data-only (plaita#133): public attributes that resolve to modules, functions, classes, frames, generators, coroutines or other code objects are refused by name.
数据面闸(#133):
expression path '$NODE.h.os' traverses a non-data object of type 'module': attribute traversal is data-only (plaita#133), so modules, functions, builtin functions, methods, classes, frames, tracebacks, generators, coroutines, async generators and code objects are refused by name. Read a data field instead; mapping keys and numeric indices are unchanged.
边界:属性遍历只允许数据,映射 / 索引与 $F.* 未改
133 的收紧落在属性遍历:公有属性若解析出模块 / 函数 / 类 / frame /¶
generator / coroutine / code 对象,具名拒绝;公有数据属性仍可读。
dict / 映射的键与值、数组索引、$F.* 注册表、$ENV 的 exposeEnv
allowlist 都保持原语义。
代价:流程若把方法 / 类 / 模块当值读取(例如
$NODE.obj.some_method),现在会抛 ValueError;请改为读取该字段背后的
数据值。exposeEnv 仍是"显式暴露"清单而非沙箱;节点插件不应依赖公有属性
传递敏感模块 / 对象。详见迁移指南 的
55 / #133 条目。¶
映射遍历:容器必须是数据(安全限制)¶
133 关闭属性遍历后,映射面仍可能把 live 容器当数据读:$NODE.h.env /¶
$NODE.d.env 返回 live os._Environ,随后的 .HOME 走映射键;
$F.get($NODE.h.env, 'HOME') 也直接读它。plaita#203 把映射容器也收紧为
只允许数据:
| 表达式 | 结果 |
|---|---|
$NODE.d.k(d 是 dict) |
键 k 的值 |
$NODE.d._foo(d 是 dict) |
键 _foo 的值——键不受限 |
$NODE.ds.k(ds 是只加方法的 dict 子类) |
键 k 的真实存储值;覆写的 get/__getitem__ 不执行 |
$NODE.h.env(env 是 os.environ) |
ValueError:非数据映射容器(plaita#203) |
$NODE.d.env / $NODE.d.env.HOME |
ValueError:在 .env 就拒绝,环境变量不会被读取 |
$NODE.lst[0](lst[0] 是 os.environ) |
ValueError:索引结果同样是值位置 |
$F.get($NODE.h.env, 'HOME') / $F.getDictValue(...) |
ValueError:$F.get 旁路同样拒绝 |
$F.get($NODE.d, 'env') |
ValueError:返回的 live 容器同样拒绝 |
$F.get($NODE.d, 'k') |
键 k 的值——数据 dict 不变 |
$INPUT.x(dict / CheckpointState) |
值——引擎状态是数据 |
$ENV.HOME |
仅当 exposeEnv 显式包含 HOME 时可读(语义未改) |
规则:
- 允许:真实
dict(含子类)与引擎自有状态 mapping(CheckpointState以及$PARENT的内部惰性快照)。dict 子类走dict.get/dict.keys的 C 槽,覆写的读写方法不执行;引擎状态走自己的get。 - 拒绝:
os.environ、collections.ChainMap、collections.UserDict、types.MappingProxyType、自定义Mapping、duck-typed__getitem__+get的 host 对象——在入口(作基座)/ 结果值 / 索引结果 / 根值四个位置都抛 具名ValueError,在读取任何键 / 值之前终止。 $F.get/$F.getDictValue:第一个实参是 host mapping-like 时,在输入 测量和 callee 之前拒绝;返回值是 host mapping-like 时也拒绝。- 兼容性:
UserDict/ChainMap/MappingProxyType/ 自定义Mapping请先转成 plain dict(如dict(value)),或改用$ENV显式暴露;只加方法的dict子类保持可用。
运行时错误文案(逐字,与代码、迁移指南一致):
expression path '$NODE.h.env' read a non-data mapping container of type '_Environ': only plain dict (including subclasses, read through dict's C slots) and the engine's CheckpointState may be traversed as a mapping (plaita#203). Host mapping-like objects such as os.environ / ChainMap / UserDict / custom Mapping are refused by name before any key or value is read; convert the data to a plain dict first.
取数据原文:$NODE.raw.<节点>…¶
$NODE.<节点>.<字段> 对看起来像表达式/模板的字符串字段值会按历史语义重新求值
(引用了父容器缺失根键的数据文本除外,见 迁移指南)。
当字段其实是数据——LLM 输出、文档正文、prompt 回显——这种静默改写是不可接受的,
所以在前面加 raw 修饰符:
| 表达式 | 结果 |
|---|---|
$NODE.llm.text |
"True"(历史语义:模板被求值) |
$NODE.raw.llm.text |
"{% $F.eq(1,1) %}"(原文,不求值) |
$NODE.raw.llm |
整个节点结果原样返回(其中的字符串都不求值) |
规则:
raw贯通整条路径:每一段的字符串都不求值(中间段也不会);非字符串值 (int/float/bool/None/list/dict)与不加raw时完全一致。默认路径只在走到的 字符串段上求值,不会深度遍历容器里的字符串——所以整节点是容器时两者都原样 返回,差别出现在字符串叶子与中间字符串段上;raw保证的是"整条路径都不求值";- 只有
$NODE有raw修饰符——$INPUT/$GLOBAL/$PARENT/$ENV没有,$INPUT.raw.x就是输入对象里名为raw的键; - 命名冲突:
$NODE后第一段是修饰符,仅当$NODE里没有同名节点、且其后 紧跟一个字段段(节点 id 位)。流程里若有 id 恰为raw的节点,$NODE.raw.<路径>仍指那个节点(存量语义优先),并打出一次 WARNING 说明该冲突——此时请给节点改名 才能使用本访问器;$NODE.raw.0/$NODE.raw[0]也仍按节点raw解析。 - 运行期窗口:判定看的是当次的
$NODE,所以"声明了 id 为raw的节点、但该 节点还没产出"时,$NODE.raw.<其他节点>…会先按访问器解析(取原文、无 WARNING), 节点产出后同一表达式才切成节点语义(并多一条冲突 WARNING)。这是规则的必然结果, 且不会改变既有流程的结果(旧版本两态都得到None)。
字符串插值¶
当表达式只是字符串的一部分时,用 {% ... %} 包裹表达式做插值:
纯表达式 vs 插值
- 整个值就是表达式:
"output": "$INPUT.name"—— 直接写$INPUT.name - 表达式是字符串的一部分:
"output": "hi {% $INPUT.name %}"—— 用{% %}
函数调用¶
用 $F.funcName(args) 调用内置函数,参数本身也可以是表达式:
"output": "$F.upper($INPUT.name)"
"output": "$F.concat($INPUT.first, '-', $INPUT.last)"
"output": "$F.len($NODE.items)"
内置函数一览¶
plaita 内置 60+ 函数,按类别分布在 ExpressionRegistry 中:
add sub mul div mod pow abs ceil floor round trunc sqrt
lower upper capitalize title strip lstrip rstrip replace split join startswith endswith concat isDigit
and or not
纯函数:len length index slice append extend insert remove reverse sort getListItem addListItem insertListItem
带副作用(就地修改,非线程安全):pop delListItem setListItem
纯函数:keys values items get getDictValue getDictKeys getDictValues
带副作用:set delete clear setDictValue delDictValue clearDict
now today(接受可选 fmt 参数)
json_loads json_dumps
副作用函数非线程安全
pop / set / delete / clear 等会就地修改输入。在 Parallel 并行或共享上下文的异步场景中使用它们可能造成数据竞争——要么串行化访问,要么先拷贝再修改。
五个持久化根不可就地改写(plaita#69,r6:fresh ≡ 本次求值由引擎新建)
就地改写函数(pop delListItem setListItem set delete clear
setDictValue delDictValue clearDict)的目标位(arg0)只有在
本次逻辑求值由引擎新建该对象时才允许;否则抛 ValueError 并点名完整路径/根,
说明 provenance could not be proven fresh。原因:活的持久化对象写进去会被序列化进
checkpoint 并在恢复后保留(不是纯函数调用)。
"fresh" 的确切含义(r6):freshness 存在一个随最外层求值入口
(ExpressionParser.evaluate / ExecutionContext.evaluate)建立、
finally 里丢弃的 ContextVar 作用域里;嵌套求值(嵌套表达式串、parse_function、
容器重建)共享同一作用域。所以 A 次求值里造出的对象,在 B 次求值里一定不是 fresh
——包括"引擎物化后写进 $NODE/checkpoint,下一次求值再就地写它"这种形状(r5 的实现
是模块级、进程内的登记,曾经放行这条路径;verifier 的 C2 反例)。合法的就地改写因此
必须把物化与改写放进同一次求值,例如
$F.set($F.json_loads('{}'),'k','v') 或
$F.set($X,'k',$F.json_loads($F.json_dumps($INPUT.cfg)))。
哪些类型能进数据模型(r7):值参去别名不再用 copy.deepcopy,而是引擎自有的
结构拷贝(数据路径上唯一的拷贝入口)。接受:dict/list/tuple/set/
frozenset 与 plaita.core.types.native_types 声明的标量 —— str/bool/int/
float/Decimal/datetime/date/None(另加不可变的 bytes/complex)。
容器/标量子类会被归一化成普通基类值:OrderedDict/defaultdict/Counter →
dict,namedtuple → tuple,IntEnum → int,str/int 子类按值转换;拷贝走
基类协议,所以子类覆写的 __deepcopy__/__copy__/items/__iter__/__getitem__
一次都不会被调用。仍然拒绝(fail-closed,具名错误
ValueError: … not part of the persistable data model … 并点名类型):真正属于非数据
模型的可变对象与容器 —— 自定义 MutableMapping 实现、bytearray、不透明的宿主
类实例、dict view(d.keys())等。这样"detached 副本"永远不可能偷偷就是活对象本身。
典型地,$INPUT.amount 是 Decimal 时 $F.set($F.json_loads('{}'),'k',$F.add($INPUT.amount,1))
可用(number 类型包含 Decimal;r6 曾误拒,r7 修复)。
r10 补记(逐字段保真 + "存储之外的自述"):① 标量子类的归一化按全部可观察字段
判等,不是只看 type/==:datetime 子类连 PEP 495 的 fold 一起带过 —— DST 歧义
墙钟时间(America/New_York 2020-11-01 01:30 出现两次)的 fold=1 必须得到正确的
timestamp()/UTC 偏移(EST, 1604212200.0);r9 曾静默丢成 fold=0(EDT,
1604208600.0,==/hash() 都看不出来)。Decimal 的符号/系数/指数、date 的
year/month/day 同理逐字段保留。② 数据不在真实 dict 存储里的 dict 子类一律
具名拒绝:MRO 里 Mapping 排在 dict 之前、或用 Python 覆写 __iter__/__len__
(容器外延声明)、或实例带自己的 __dict__ 状态(数据放在 self._d 这类)—— 这些形状
r9 之前会静默变成空 dict(循环条件副本"成功"但丢数据)。只覆写
items/keys/values/__getitem__ 且没有实例状态的子类仍按基类存储归一化
(r7/r8 契约,宿主覆写零调用);带实例属性的普通 dict 子类会被这条判据过拒。
不要用 dict(...) 当逃生口(r11 更正,实测):dict(v) 读的是真实 dict
storage(即 real dict storage,基类 dict.items),对"数据在别处"的形状(ItemsLiar/SlotsItemsLiar/
HidingDict)恰好返回 {} —— 静默给出错误答案;dict(v.items()) 反而采信宿主
自述(真的调用宿主 items()),同样不可信。诚实的恢复方式是从已知良好的数据
重建,或用 ctx.evaluate / producer(register(..., fresh_producer=True))按值物化。
回归:TestIssue69R11DictEscapeHatchIsLossy::test_dict_of_a_refused_shape_is_empty
钉住 dict(<refused shape>) == {},防止这条警告腐烂。
r11 补记(clause iii 按性质判定):数据在真实 dict 存储之外的形状一律具名
拒绝,判据是四条机械可判、零宿主钩子的析取(instance_state_signal):非空实例
__dict__ ∨ MRO 里 dict 之前任一层声明非空 __slots__(含字符串简写、继承/合并
的槽)∨ 遮蔽的 __dict__(@property def __dict__)∨ 非 C 槽实现的
__getstate__/__setstate__/__reduce_ex__。r10 只测 __dict__ 这一个代理,
于是 class SlotsItemsLiar(dict): __slots__ = ("_d",) 两条拷贝路径都静默产出 {}
且 DSL 报成功 —— 而只多一个 __iter__ 的 SlotsIterLiar 却被拒(两者只差数据
放在哪)。判据只用类对象(MRO 与类命名空间经 C 描述符
type.__dict__["__mro__"] / ["__dict__"] 读),所以宿主元类覆写
__getattribute__ 也进不来(否则它能让判据漏裸异常,或"说谎"抹掉声明了
__slots__ 的基类、重新静默丢数据)。拒绝消息点名触发的信号
(instance state (__slots__) / instance state (__dict__) /
shadowed __dict__ / instance state hooks (...))。
r12 补记(第 5 条:C 布局存储 + 视图物化按真实类型):r11 的四条判据仍是代理 的枚举 —— C 级成员槽这条通道看不见:
class N_DDLiar(defaultdict):
def __init__(self, d):
super().__init__(None)
self.default_factory = dict(d) # member_descriptor 的 C 槽
def items(self): return self.default_factory.items()
defaultdict.default_factory 是 defaultdict.__dict__ 里的 member_descriptor
(CPython 3.9.6 / 3.10.21 / 3.11.16 / 3.12.14 / 3.13.15 实测都是),数据全在 C 槽里时
dict.items(v) == {}、实例 __dict__ 也是空的 ⇒ r11 判 False ⇒ 两条拷贝路径静默产出
{}、DSL 报成功。r12 的 clause (iii) 增加第 5 条:MRO 里 dict 之前、且声明层不是
cls 自己的层声明的 C member/getset 描述符 ⇒ 具名拒绝,信号
instance state (C descriptor default_factory on defaultdict)。负控照旧:精确
OrderedDict/defaultdict/Counter 与纯 dict 子类仍归一化(精确 defaultdict 的
default_factory 由它自己的 tp_members 声明,属它自己的存储口径)。过拒面(明说):
无数据在存储之外的良性 defaultdict 子类(class BenignDD(defaultdict): pass)现在
也被拒 —— 与"带实例属性/__slots__ 的 dict 子类被过拒"同级代价,取 fail-closed;
OrderedDict/Counter 的子类不受影响(它们的非 cls 层只声明被排除的
__dict__/__weakref__)。同轮还收窄视图物化:只有精确类型是引擎自产
dict_keys/dict_values/dict_items/odict_* 的视图才被 list(...) 物化,
KeysView.register(LieList) 这类 ABC 注册(宿主可控)与
class V1(KeysView): __iter__ = dict.__iter__(解析到别人的 C 描述符)一律具名拒绝 ——
后者在 r11 会漏裸 TypeError,前者能把 KeysView.register(LieBytes) 的
b'LIE' 物化成 [76, 73, 69](_engine_copy 的口径是 b'LIE',值种类被静默改掉)。
返回 ['LIE'] 的假视图不再可能被写成数据(r13 更正:这句被反证 —— $F.keys
从来不是引擎自产视图,它直接问宿主;class LieKeysPlainList(dict): def keys(self): return
["LIE"] 在 r12 下仍报成功、$VAR == {'k': ['LIE']} 且宿主 keys() 被调用。r13 把这条
通道关掉,见下方 r13 补记)。
设计之外(明说):数据放在类属性 / 闭包 / 模块级旁表 / property 或数据
描述符后面时,零钩子静态判定不把这些当"实例状态"。r13 理由更正:旧措辞把这四族的
原因统一写成"要看见就得调用宿主代码 / 可达性不可判定",对类属性族不准确 ——
_class_namespace(klass) 本来就零钩子地扫过每层真实 tp_dict,类属性看得见;真实
理由是"这个属性是内容,还是常量/缓存/类级默认值?"无法仅从类对象判定,而把任何
非 dunder 类属性都当状态会拒绝语言本身(__module__/__qualname__/__doc__、装饰过的
方法、类常量)。闭包 / 模块级旁表 / property-或-数据描述符三族才确实需要宿主代码或
不可判定的可达性判定。这几族不在本判据的承诺范围内。
r13 补记(clause iii 第 5 条改成布局判据 + 映射辅助读真实存储):
- 布局,不是名字:r12 的豁免理由是"C 描述符只可能由拥有 C 布局的类型生成,Python
类体写不出
member_descriptor" —— 假的。Python 类体的__slots__就在该类 自己的tp_dict里生成member_descriptor;r12 的 clause 2 按名字__slots__找槽、clause 5 又if klass is cls: continue,于是"隐藏声明、保留槽"就能恢复静默{}/DSL 报成功:
class HiddenSlotsLiar(dict):
__slots__ = ("_store",) # cls 自己的 member_descriptor
def __init__(self, d): self._store = dict(d)
def items(self): return self._store.items()
del HiddenSlotsLiar.__slots__ # 抹掉名字,槽还在
r13 改成按每个层命名空间里真实存在的 member_descriptor/getset_descriptor
判定(忽略 __dict__/__weakref__ 占位名);豁免只有一条:声明层是 cls 自己
且 cls 不是 Python 定义的类(判据 = Py_TPFLAGS_HEAPTYPE ∧
¬Py_TPFLAGS_IMMUTABLETYPE,即 0x200 ∧ ¬0x100)。注意不能只看 heap 位:
CPython 3.12/3.13 的 collections.defaultdict 自己就是堆类型(裸解释器实测
0x20405740,heap 位置位),只看 heap 会把精确 defaultdict 误拒;IMMUTABLETYPE
位把它(以及 dict/OrderedDict)与 Python 类区分开(3.9 上该位未启用,而 3.9 的
扩展类型都是静态类型 ⇒ 判据退化成"只看 heap"仍正确;引擎进程里该值观测为
0x20485740,多出的是无关 version-tag 位)。
于是 Python 类自己的槽无论 __slots__ 名字是否还在都被看见(信号
instance state (C descriptor _store on HiddenSlotsLiar)),子类继承到的扩展类型
层槽照旧被看见;精确 defaultdict/OrderedDict/Counter 与纯 dict 子类照常归一化。
- $F.keys/$F.values/$F.items(含 getDictKeys/getDictValues):参数是 dict
时改走基类 C 描述符(dict.keys(v) 等),宿主的 keys()/values()/items()
零调用;LieKeysPlainList 这类"自述 ['LIE']、数据在真实存储"的形状现在得到
真实存储($VAR == {'k': ['a']}),假视图到不了值参。普通 dict 的行为逐字不变。
非 dict 的映射无法在不进入宿主代码的前提下读到真实存储 ⇒ 具名拒绝
(ValueError,含 plaita#69)。
- 快照按引用共享的口径更正:快照只重建 dict/list/tuple/set/frozenset,
其它值按引用共享 —— 其中包含容器 types.MappingProxyType / collections.ChainMap
/ collections.UserDict / os.environ(旧措辞"非容器宿主对象"是错的)。它们按引用
共享是因为重建要猜构造协议;在只读快照路径上安全,且不是数据通道:DSL 路径的
_engine_copy 对这四个具名拒绝(不在可持久化数据模型里)。
破坏性收紧(r2 起):不只是目标位,把活根当值传进去同样拒绝 ——
$F.set($VAR,'k',$INPUT) 会把根对象存进 $VAR,之后 $F.set($VAR.k,'x','y')
就能经别名写穿 $INPUT。base 上这些写法静默放行且会写穿,升级后必须显式派生副本。
浅 producer 的值参会自动去别名(r3):$F.slice(...) / $F.reverse(...) /
$F.values(...) 等返回的是新容器但元素仍指向根。它们作目标位仍然可用
(mutator 只改新容器自己的条目);作值参且自身实参触达持久根时,值会先经引擎结构
拷贝再存入,因此 $F.set($VAR,'k',$F.slice($INPUT.items,0,1)) 不再让
$VAR.k[0] is $INPUT.items[0] 成立,后续就地写也碰不到 $INPUT。标量累加
($F.set($VAR,'total',$F.add($INPUT.n,1)),n 为 int/float/Decimal)等合法
用法不受影响;Decimal/datetime/date 载荷同样走这条路径(r7 起)。
callee 只在自身实参触达根时被拒(r3 + r5 运行时身份校验):
$F.get($VAR,'x') / $F.ifelse(...) 这类调用如果实参里没有持久根,作目标或值参都
放行当且仅当运行时身份命中 fresh(即结果确实来自引擎新建的容器);只有
$F.get($INPUT,'cfg') 这种实参触达根(或未声明/未分类的 callee)才 fail-closed 拒绝。
仍然允许(由构造证明 fresh):同一次求值里由引擎物化的非根键
(ctx.evaluate / producer 的结果,且物化与改写属于同一次求值)的就地改写;循环条件
里的改写(Loop/While 把"条件隔离副本的物化 + Condition.match"开成一次逻辑
求值,改写落在副本上,$LOOP-ITEM 也被引擎结构拷贝)—— 前提是条件根的整棵子树都是
数据模型值(dict/list/tuple/set/frozenset + 可持久化标量);以及对
新对象的改写——DEEP producer
($F.json_loads(...) / $F.split(...) 及纯标量函数)、SHALLOW producer
($F.slice(...) / $F.reverse(...) / $F.append(...) / $F.extend(...) /
$F.sort(...) / $F.add(...) / $F.values(...) …)以及自定义 registry 里
register(..., fresh_producer=True) 声明的 producer。
循环条件根的过拒(r8 文档化,fail-closed):条件根里只要含任何非数据模型对象
(bytearray、不透明宿主类实例、自定义 Mapping…),引擎就造不出隔离副本:该根
保留活引用且不登记 fresh,因此该根内任何就地写都会被具名拒绝 —— 包括同根的兄弟
纯数据子树(例如载荷 {"items": [{"n": 1}], "blob": bytearray(b"x")} 下
$F.set($INPUT.cfg,'k','v') 同样被拒,活对象 0 变化)。这是有意的 fail-closed:宁可
按整根拒绝,也不"部分物化"出第二条拷贝实现、把活子对象留在引擎自有容器里。要就地改就
先物化要改的部分:ctx.evaluate / producer /
$F.json_loads($F.json_dumps(<part>))。
r9:这个 JSON 往返逃生口是有损的(明说,别当无痛 round-trip)。
$F.json_dumps 只认 JSON 原生类型,因此:
Decimal/datetime/date/bytes/set(以及frozenset)直接抛TypeError:Object of type Decimal is not JSON serializable—— 逃生口在这 些类型上根本不可用;tuple静默变成list((1, 2)→[1, 2]);dict的非str键同样 被 JSON 语义改写(键被字符串化)。
按类型选正确的替代,不要用 JSON 往返硬扛:
tuple/frozenset:改用ctx.evaluate(<part>)或自定义 producer (register(..., fresh_producer=True))—— 它们按值物化,不改形状;Decimal/datetime/date:先显式转换成 JSON 可表达的类型 (str/int/float)再往返,或整体改用ctx.evaluate/ producer;bytes/set:json_dumps无解 —— 用ctx.evaluate/ producer,或先显式 转成str/list再改。
回归(防止注记腐烂):tests/unit/test_issue69_r9_host_object_loopholes.py
的 TestIssue69R9EscapeHatchIsLossy 同时钉住 tuple→list、五类 TypeError
与本节文字本身。
r6/r7 收紧:边界副本不再是 fresh。子流程 $INPUT / 集合 / Reduce.initial /
Parallel 分支输入的私有副本仍然是深物化的私有快照(活根无法按引用进入父流程
之外的执行),但它们在父求值里造出、在子执行(另一次求值)里使用,因此子执行对
副本的就地改写会具名拒绝(r5 是"改在副本上、不报错")。r7 删除了这四处作用域外
的 note_fresh_closure 死登记(r6 起就是 no-op),语义不变。父运行态与 checkpoint 的
零写穿性质不变,只是由"拒绝"而不是"改副本"来保证。注意 Parallel 分支的拒绝不是
"ContextVar 不跨线程":mode="coroutine" 在 MainThread 里同样被拒,根因是边界调用点
不在任何求值作用域内。
r5/r6 的破坏性收紧(owner 决策边界):宿主直接绑定的非持久化键不再按键名
放行——ctx.set_state("$V", {"a": 1}); parser.evaluate("$F.set($V,'k','v')", ctx.context)
会被具名拒绝(来源不可证 fresh)。合法写法是在同一次求值里把值交给引擎物化:
$F.set($F.json_loads('{}'), …) / producer 结果($F.json_loads($F.json_dumps(...)))。
注意 ctx.set_state("$V", ctx.evaluate({"a": 1})) 已不再足够——ctx.evaluate 是
一次独立求值,它的作用域在返回时就丢弃了(r5 曾放行,r6 拒绝)。同理,数据容器里挂
宿主自造副本(copy.deepcopy(...))也被拒;同一次求值里引擎物化的副本仍放行。
verifier 的 T2-1 变体(ctx.set_state("$V", ctx.get_state("$INPUT")["deep"]),活子对象
从未被当路径终点交出过)同样被拒且零写穿。
r14 / plaita#164 补记:显式 scratch 捐献 ctx.set_scratch(key, value)。上面那条
收紧让"宿主手里已有的对象"没有直白写法;set_scratch 是给它的显式声明入口,
语义 = "这个对象只归本次执行所有、可以被就地改写":
ctx.set_scratch("$V", {"a": 1}) # 显式捐献(私有绑定)
parser.evaluate("$F.set($V,'k','v')", ctx.context) # 就地写,允许
ctx.get_state("$V") # {'a': 1, 'k': 'v'}
"$V" in ctx.to_dict() # False —— 不进 checkpoint
它不引入任何"证明对象是活的"谓词:声明只把对象登记进读取它的那一次逻辑求值 的 freshness 作用域(读取点登记),所以上面的 r6 作用域语义原样保留。四条边界都要记住:
- 只登记被声明的那个对象(对象身份),不登记它的图:
$V.cfg这类嵌套目标仍被 拒。把$INPUT的子对象塞进 scratch 的图里也不能改写它。 - 一次求值有效:声明会被"读到
$V的那次求值"消费;第二次求值里同一个宿主对象 仍被拒。再用就重新set_scratch。 - 不进 checkpoint:scratch 对表达式可见(
context["$V"]),但不在__iter__/len()/keys()/__eq__/to_checkpoint_dict()的键视图里;clean()与恢复 会丢掉它。 - 不得占用 checkpoint 键(五根与其它 schema 键、以及已在 checkpoint 里的键),
声明一个"就是活持久化对象"的对象也被具名
ValueError拒绝(那会新开一条写穿通道)。
选 set_scratch 还是 ctx.evaluate({...})? 想就地改你自己手里那个对象
(保留对象身份)⇒ set_scratch;ctx.evaluate(obj) 会把它换成引擎副本,你手里那个
不会被改。只想在一个表达式里派生临时容器 ⇒ 直接用 producer
($F.json_loads(...) / $F.slice(...)),不必绑定。需要"引擎物化一份副本"且物化与
改写属于同一次求值 ⇒ with freshness_scope(): ctx.set_state("$V", ctx.evaluate({...}))
(跨求值再写它照旧被拒,见上面的 r6 口径)。
容器键名与深度不参与判决(r4 身份探测 + r5/r6 freshness):把活根挂在任意键名、
任意深度下都无效——InlineFlow(input={"$myroot": "$INPUT"}) 里的 "$INPUT"
作为字典值求值就是活根对象;嵌套串 $F.set($myroot.cfg,'k','v') 在 r4 由身份探测
具名拒绝,在 r5/r6 由 freshness 独立拒绝(r4 的 27 格容器轴在"零登记"下曾 9 格写穿,
r5/r6 在零登记/有登记两套状态下判决一致:18 拒 / 9 放行 / 0 写穿)。
完整分类矩阵(机械生成:378 目标位 + 294 值参位 + 112 浅值参去别名 + 42 callee 触达
+ 72 非根 callee + 511×2 producer × position 全表,0 格可制造别名;容器键名轴 27×2
+ 14 个多位置探针 + 90 个作用域/物化调用点清单;性能守卫覆盖 mutator 路径并用
checked-in 基线抓常量因子回归):
docs/issue69-mutation-matrix.md。
重算:PYTHONPATH=. python scripts/issue69_mutation_matrix/table.py 与
probes.py / scope_inventory.py / bench_expr.py。
未注册的函数名求值为 'undefined' 字符串
$F 是作用域注册表:调用未注册(或拼写错)的函数名不会报错,而是把整
个调用求值为字符串 'undefined'(默认 registry 未注册该名字时也一样)。例如
$F.uppr($INPUT.name)(拼错)静默得到 'undefined',下游拿到的就是这串文本。
排查时先核对函数注册名是否与内置函数一览或你的
registry.register(...) 名字完全一致。
结果规模守卫($F.*)¶
表达式引擎会拒绝「小输入 → 巨大输出」的放大:阈值随输入规模走,不是一刀切 的绝对上限。
| 旋钮 | 默认 | 环境变量 |
|---|---|---|
| floor | 100_000(字符 / 字节 / 元素) |
PLAITA_EXPR_MAX_RESULT_SIZE |
| factor | 4 |
PLAITA_EXPR_GROWTH_FACTOR |
| hard | 100_000_000 |
PLAITA_EXPR_MAX_RESULT_HARD |
环境变量在导入时读取一次(改后需重启进程),非法值回退默认。超限抛
ValueError,不会静默截断。
判定是调用点级的:守卫在调用 Python 算子之前算出这次调用的结果上界;算不出来
时(类型不匹配 / 无法枚举)该调用注定抛原生 TypeError/KeyError,而不是"跳过
守卫"。求值侧探针也不按函数白名单跳过,每次 $F.* 调用都按同一条规则复核,输入规模
用内容量(字符串长度、memoryview/array 的字节数、容器内容之和)而不是元素/参数
个数;容器逐项求和,绝不用可能低估的抽样近似。
在分配之前拦截的算子:
| 算子 | 何时拒绝 |
|---|---|
mul |
任意「支持 __mul__ 且有 __len__」对象的重复超限:seq * int 与 int * seq 双向,覆盖 str / bytes / bytearray / list / tuple / array.array(数值乘法不受限) |
replace |
len(s) + 命中次数 × (len(new) - len(old)) 超限(空模式按每字符间插入计);bytes/bytearray 接受 bytes-like 的 pattern/replacement |
mod |
str/bytes 的 % 是 printf 格式化:映射键 %(k)、flags、宽度(数字或 *,负宽度取绝对值)、精度都会解析,并按"分配量"判定——'%100000000d'、'%.900000000g'(结果 1 字符但分配 ~1GB)、'%(k)100000000s'、'%*d' % (-1e8, 1) 都在此拦住;s/r/a 的精度是截断,不拒绝。转换符与值类型不匹配时先抛原生 TypeError。数值 % 不受限 |
join |
分隔符被 元素数-1 次重复后的总长超限;被 join 的对象可以是任意可迭代——长度不可预知时由调用点先物化一次再按内容量判定(指针有 100MB 的独立字节预算),__len__ 少报也不会截断结果 |
sort |
sorted() 同样会消费单次迭代器:调用点先物化一次再判定(与 join 同一套机制) |
add extend append concat |
结果与输入同量级(线性),只有超过 hard 才会被拒 |
量级示例:"x" * 10**6 被拒(放大 10⁶ 倍);join(20 段 × 10000 字符) = 200000
字符通过;json_dumps([{"k": "v"*100}] * 2000) = 222000 字符通过;
mod("%s %s", (100KB, 100KB)) = 200001 字符通过;join(['b'], $INPUT.long_sep) 通过
(只有一个元素时分隔符根本不进结果)。反过来,today('%c' * 100000) = 2400000
字符(24 倍放大)会被拒绝——这是探针拦下的,与"非放大的大输入必须放行"不矛盾。
求值路径上的判定分两层:预检层(内建算子在调用 Python 算子之前判定)与
探针层($F.* 返回后按同一条比值规则兜底)。探针用的输入规模是调用点在调用
之前算好、并作为本帧局部量传进来的快照——没有线程本地之类的共享槽位,也
不在调用后回读实参(否则被调用者改写自己的可变实参就能抬高额度)。
{% ... %} 插值同样过守卫。
两个口径要分清:
* 结果/输入口径(探针与预检的 allowance):字符串/字节按长度;容器/映射按
内容量之和,并以元素数为下界(已知长度的内建容器用元素数豁免
['']*200000 这种"内容量为 0"的退化情形,避免误拒)。
* 计费与枚举走祖先 C 槽:str.__len__ / list.__iter__ / dict.items …,实例覆写的
__iter__/__len__/count/join 只影响上界、影响不到原生算子与 str()/repr() 的 C 槽
⇒ 一律不采信(否则能按「空迭代器」的上界放行 1 亿字符的真实渲染)。这条对所有算子
的前置检查与预留成立(第十二轮 M1:mul/replace/add/extend/append/join);
没有对应 C 槽可读的值 ⇒ 拒绝,不按实例协议猜。
* 先判后渲染:渲染类路径(%s/%r/%a、concat、{% ... %})一律先算可证上界,
不可得或超额度 ⇒ 直接报错(不调用 str());事后探针只是第二道网。
* namedtuple 不做整体渲染(__code__ 相同 ≠ 行为相同:闭包/描述符都能换实现):
索引下钻可用($INPUT.v[1] 实测返回元素),但 $INPUT.v.a 这类字段访问在本
表达式文法里不可用——静默返回 None(不是本单引入的行为);需要整体渲染请在
上游先 str(x)(表达式文法里没有裸函数调用)。
* 渲染额度口径(%s/%r/%a、bytes 模式 %s/%b、concat、{% ... %}):只用
可证渲染上界,不借用上面的元素数下界。第二十一轮起 plaita/core/expression.py
的 _RENDER_DISPATCH 是唯一判据表(key = 转换符,value = (kind, slots, bound);
_render_size/_spec_bound/_conversion_accepts/_definitely_rejected 都只查它,
归属判定由同一张表分两处驱动:数值族在 _conversion_accepts 按行 kind/slots 判定,
文本/buffer 族在行的 bound 里判定)。放行:精确内建/标准库类型(str/bytes/bytearray/int/float/
bool/None/容器/视图/迭代器/Decimal/datetime/UUID/PurePath/Pattern/Enum/
array/deque/SimpleNamespace/默认 repr 的对象)与干净子类(表里该转换符派发的
槽与安全祖先是同一原始槽);拒绝:覆写 __str__/__repr__ 的子类、bytes 模式下带
作者 __bytes__ 的非 buffer 值、只有数值协议可取数的自定义对象——消息带
"可用 str(x)/int(x)/bytes(x) 放宽"。实测例外:int 家族在 d/i/u/o/x/X/c 上
原生 C 存储直读(覆写 __int__/__index__ 被忽略 ⇒ 不设槽闸);bytes/bytearray 子类的
%s/%b 直读 C buffer(覆写 __bytes__ 被忽略 ⇒ 按 C 字节数计)。
第二十二轮补:纯 Python 祖先的实例属性也要可证——PurePath.__str__/UUID.__str__
是 Python 代码、会读实例槽(_str/_drv/_root/_parts、int),子类放一个同名
property 就能把"安全投影"换掉(实测上界 67 / 实际 10,000,000);因此从子类到最近的已知
stdlib 祖先之间任何一层类命名空间不得新增名字(_stdlib_only_below),上界改读真实
实例槽(0 作者调用),遮蔽 property 的形状 fail-closed 拒绝;SimpleNamespace 改读
SimpleNamespace.__dict__ 的 member_descriptor(C 直读真实实例 dict,property 无效);
元类遮蔽 type.__name__ 的子类一律拒绝。
第二十三轮补:精确类型也走同一道闸——精确 PurePath/UUID 的实例槽同样可写
(p._str = "E"*1e7、object.__setattr__(u, "int", 10**400000)),r22 的
value.parts 投影 / 常量 64 会被击穿(投影 67/74 vs 实际 10,000,000/332,197、
%r 峰值 22.5MB);现在精确分支也读真实实例槽,巨槽 ⇒ 上界 ≥ 实际且分配前拒绝。
第二十四轮补:常量上界也要读真实成分——类对象/enum 类/默认对象/函数/timezone/
repeat/count 的 repr 内嵌 __module__/__qualname__/__name__/name/参数,这些
建成后仍可写 ⇒ 常量 256/64 是推理不是证明;现在一律读真实成分(不可变 getsets /
C 槽 / __reduce__),读不到就拒绝。
第二十五轮补:array.array 按 typecode 实测最坏值——array('d') 的 repr 每元素
26 字符(repr(-max) 24 + ", " 2),旧的 32+len×24 投影低于实际并能在 K≈4.17e6 时
绕过 1e8 硬上限;现在 d/f 26、各整型 typecode 6–23、u 转义 10×/字符。
第二十六轮补:%a(ascii)的展开按 10× 计——%a 会把非 ASCII 字符展开成
\xNN/\uNNNN/\UNNNNNNNN(4–10×),所有 as_repr 上界里的作者可控名字
(enum 类名/成员名、PurePath/UUID 子类名、ZoneInfo.key)都按 10× 计,且 %a
的成长额度只按值的权威内容量计 ⇒ 1M 星平面名字在分配前被拒(r25 是分配后 20MB)。
第二十七轮补:str/bytes 的上界是真实逐字符转义和——\n 是 2 不是 10、é 是 4、
星号平面才是 10(+2 引号;bytes 前缀 +3/bytearray +14),所以 "\n"*9999、
"é"*9999、"\n"*100000、bytes "é"*12496 都正常渲染;%a 的成长额度按值的
权威内容量,"é"*24997/"😀"*9998 渲染、24,998/9,999 起拒("😀"*1e6 仍在渲染前拒,
消息里给阈值与逃生口:改用 %r 或提高 PLAITA_EXPR_GROWTH_FACTOR)。
第二十八轮补:bytearray 的引号规则与上界计算的内存预算——bytearray.__repr__
无条件转义 '(bytes 只在同时含 " 时转义),所以两者分开计;上界计算本身不得
放大内存:bytes 用 translate(None, delete) 计数(1.00×),str 的 re.subn 链峰值
2.1–4.2× 存储(文档与测试都写明预算);容器的 str 元素同样走真实逐字符宽度。
计费与枚举一律走祖先 C 槽(str.__len__/list.__iter__/dict.items…),实例覆写的
__iter__/__len__ 只影响上界、影响不到渲染的 C 槽,一律不采信。
类型层事实经不可变 getsets 读取(type.__dict__["__mro__"|"__dict__"|"__name__"].__get__),
元类数据描述符对 __mro__/__dict__ 撒谎不再影响判定;判据只看真实类型对象,不看
__module__ 之类自述字段;枚举期间的用户异常一律转成"不可界定 ⇒ ValueError";
join/sort 的实参在调用点物化一次,守卫与执行共用同一次迭代。
* 渲染点清单守卫(第十二轮):expression.py/expression_parser.py 里所有
str()/repr()/format()/ascii()、% 格式化、.format()/.join()/.replace()、
__str__/__repr__/__format__/__mod__ 属性取用、f-string 形参插值、logger.*
插值、以及可静态识别的间接构造,都由 AST 测试逐点登记;标为 guarded 的点,其具名
上界调用必须支配它(挪进 if False: 会红),并配每个渲染入口的行为反例。它只是
清单,不是上界证明——上界真假由祖先槽反例等行为用例承重。
* 日志不插值作者数据(第十二轮 M4 / 第十三轮 O4):评估路径上的 logger.* 只记
类型名/元数/形态;类型名截到 64 字符、异常消息只用 BaseException.args 的 C 槽取单
str 参数并截到 200 字符(不调用覆写的 __str__)。仓库级日志卫生是 plaita#151,不在本单。
* % 的上限判定不容许空转(第十三轮 O1):格式串宽度/精度本身就是分配下界;解析不出
或无法证明原生必然抛错 ⇒ 拒绝(旧实现 projected=0 时 27 字节能造出 477MB)。星号
宽度只接受精确 int 并用 C 槽读(不跑 __int__);映射取值只读一次并用同一份值渲染
(消除 TOCTOU),dict 子类走 dict.get(不触发 __missing__)。
* 精确 zoneinfo.ZoneInfo 计入上界(第十三轮 O6);ZoneInfo 子类/未知 tzinfo 仍拒。
精确 collections.UserList/UserString 走其 .data 的 C 槽;其余自定义序列/自定义
replace 保持拒绝——逃生口在上游(产值的节点/插件传精确类型或先 str(x))。
* 只收口 Exception:BaseException/KeyboardInterrupt/SystemExit 照旧穿透。
* enum / tzinfo 的作者数据计入上界(第十二轮 M6/M7):enum 成员 value 的 repr、
datetime/time 的 tzinfo 的 repr 都跑同一套计费;IntEnum/IntFlag/StrEnum/Flag
成员按各自 __str__ 原始槽给上界(不再误拒);覆写 __str__ 的 enum 子类、repr 不可
界定的 tzinfo 仍然拒绝。
类型错误优先于尺寸错误:str + list、list * str、set * int、join 遇到
非匹配元素、% 的参数个数/mapping 键缺失/非法转换符/b'%s' % 'x' 等情况在任何
规模(哪怕把三个旋钮压到 1/1/1)都抛原生 TypeError/KeyError/ValueError。
求值侧探针对未预检路径只是「检测到」,不是「阻止」
表达式求值时还会对任何 $F.* 的返回值做硬上限判定,并对没有预检的调用
套同一条比值规则。但这类路径的值已经被分配出来了才被拒绝——最典型的是
你自己 registry.register(...) 注册的函数:$F.boom(1000000) 会先造出
1MB 字符串再抛 ValueError(恶意函数内部先做一次合法大操作再返回 99MB 字符串
同样只能事后拒绝,峰值就是它自己分配的 94MB)。上表列出的内建放大算子才是
分配前拦截(受保护路径的 tracemalloc 峰值不随放大规模增长)。大块计算请放在
节点 I/O 里,不要指望这条守卫替你省内存。
这类调用的额度只由"调用之前"测得的实参快照决定:判定不存在线程本地或
跨函数通道,也不在调用后回读实参——func(*args) 期间跑的任何代码(含改写
自己的可变实参)都无法放行外层结果;{% ... %} 插值也过同一条守卫。
join/sort 需要的"先物化"由调用点按算子名完成,所以
functools.partial/lambda/绑定方法包装后同样生效。
自定义函数¶
通过 ExpressionRegistry 注册自定义函数,再传给 ExpressionEvaluator:
from plaita.core.expression import (
ExpressionEvaluator, ExpressionRegistry, FunctionCategory,
)
registry = ExpressionRegistry()
registry.register(
"greet",
lambda name: f"hello {name}",
FunctionCategory.STRING,
description="打招呼",
override=True,
)
evaluator = ExpressionEvaluator(registry=registry)
之后在流程中即可用 $F.greet($INPUT.name)。
求值 API¶
在自定义节点里,用 execution.evaluate(value) 对任意值求值(递归处理 list/dict):
子流程中的静默回退:$INPUT.x 解析为 None 时会回溯父上下文
当某表达式在当前上下文解析为 None 且存在父上下文时,plaita 会自动向上
回溯到父级再求值。这是一个跨流程边界的隐式数据通路:子流程里写
$INPUT.name,若子流程输入没有 name,实际可能拿到的是父流程的
name。它让嵌套流程少写传参,但也意味着"字段不存在"与"来自父流程"
在结果上无法区分——需要严格隔离时,给子流程输入显式传齐字段。
编译缓存与性能¶
表达式不是每次调用都重新解析:每个表达式/模板字符串首次求值时被编译成 context 参数化的 thunk(闭包树),按 parser 实例做 LRU 缓存(8192 条), 后续调用只执行闭包——典型命中路径在微秒级(首解析的 1/50 以下)。
护栏语义(对使用者透明,列出仅供排查):
- thunk 只捕获结构(根键/路径段/函数名),不捕获任何上下文值——同一 字符串在不同 context 下复用同一编译产物是安全的;
- 函数解析发生在调用时:运行中
registry.register()新增/覆盖立即生效, scoped registry 对未暴露函数返回"undefined"的契约不变; - 超过 4096 字符的字符串(长模板正文带插值槽)进独立小 LRU(64 条), 与主缓存分开计数——重复求值同样命中(微秒级),但条数上限保证大文本不会 无界驻留内存;
- 编译失败(语法错误)不缓存,
parse_function的「失败原样返回」契约不变。
下一步¶
- 内置节点 看各节点如何使用
output/condition - API: plaita.core.expression 查看
ExpressionRegistry完整接口