表达式语言
适配器是一段 JSONLogic,由 datalogic 求值;这是平台自身工作流所运行的表达式引擎,外加它的张量操作符。JSONLogic 是一门公开发布的语言,有自己的参考文档。本页只讲适配器用到的那部分,以及容易让人踩坑的规则。
审核你模型的节点和运行它比赛的节点使用同一版本的同一引擎,两者都会统计求值的每一次运算。通用的 JSONLogic 引擎,包括 DataLogic Studio,以同样的方式运行其中的 JSON 部分(差异见那一页所列),但不统计任何运算。
表达式,以及关于对象的唯一规则
数字、字符串、true、false 和 null 求值得到自身。数组会对每个元素求值。节点以 datalogic 的模板模式对适配器求值,tinybrains check 和 tinybrains adapt 用同一引擎求值,所以一个对象的含义由一条规则决定:
只有一个键、且这个键是操作符名称的对象,就是一次运算。键是操作符的名称,值是参数列表。
其他所有对象都是数据。有多个键的对象是模板:引擎对每个值求值,返回一个键相同的对象。只有一个键但这个键不是任何操作符名称的对象,原样返回。
不要把你的值放进对象里。程序的值是标量、数组和张量。返回 {"mine": …, "theirs": …} 的适配器没有返回张量,节点会拒绝它。要在 reduce 中传递两样东西,用数组:[width, points],读回时写成 accumulator.0 和 accumulator.1,而且它没有任何可能被操作符占用的键。
拼错的操作符是数据。{"scattr": …} 不是任何操作符的名称,所以引擎把它当作对象返回,你的适配器会在后面某处失败:这个对象传到了某个张量操作符,或者顶替了适配器本该返回的张量。条件里的拼写错误则哪里都不会失败:{"if": [{"=": [a, b]}, …]} 每次都走第一个分支,因为对象为真。tinybrains check 和 tinybrains adapt 会对每一个键不对应任何操作符的单键对象打印警告,并给出它在适配器中的路径。提交前先读这些警告:节点不会给出任何警告。
操作符的参数放在数组里,单个参数可以不加数组直接写:{"var": "mine"} 就是 {"var": ["mine"]}。
与操作符同名的键会调用它。张量操作符中有七个是普通单词(
shape、full、cast、pad、crop、concat、stack),所以你本想写成数据的对象,如果唯一的键是其中之一,引擎就会把它当作运算求值。用$转义这个键:{"$shape": [6, 7]}就是数据{"shape": [6, 7]},在节点上和在tinybrains中都是如此。用$转义的键是有意写成的数据,所以tinybrains不会对它发出警告。
核心操作符
适配器可以使用这些操作符,以及操作符一页中的张量操作符。datalogic 的扩展操作符族也已启用:更多的字符串、数组和数学操作符,try 和 throw,以及日期操作符。单个键若不是其中任何一个的名称,就是数据。
| 用途 | 操作符 |
|---|---|
| 访问与存在性 | var, val, missing, missing_some, ?? |
| 分支与布尔逻辑 | if, ?:, !, !!, and, or |
| 比较 | ==, ===, !=, !==, >, >=, <, <= |
| 标量算术 | +, -, *, /, %, max, min, abs, ceil, floor |
| 字符串与成员判断 | cat, substr, in |
| 集合 | merge, map, filter, reduce, all, some, none, length, slice, sort, distinct |
| 对象检查 | keys, values, entries, type |
有两条例外适用于每个适配器:
secret、now和random会被拒绝。适配器不能读取部署的密钥;而重放一次推理必须得到相同的张量,所以它也不能读时钟或取随机数。上传时,节点会拒绝用了这三者之一的适配器,tinybrains也一样。- Orion 自有的操作符只在节点上运行。节点还有
join,以及base64、base64url、hex和url编解码器(base64_encode、hex_decode等)。tinybrains无法对它们求值,所以它会拒绝调用了其中任何一个的适配器,而不是去测试别的东西。不要在适配器里用它们。
if、and 和 or 只对需要的分支求值,跳过的分支没有开销。?? 返回第一个不是 null 的参数。/ 和 % 除以零时返回 null,回合照常进行,所以当计数可能为零时,在后面接一个 ??。>、>=、< 和 <= 比较两个字符串时按字符串比较,其他情况按数字比较,并可接受第三个参数表示区间:{"<=": [0, {"var": "0"}, 63]} 就是 0 ≤ row ≤ 63。sort 把能读作数字的值按数值排序,其余按文本排序。
这门语言没有正则表达式操作符。match 的作用相当于 switch,不接受模式。要按字符串分支,就测试它是否相等。
变量与作用域
{"var": "size.0"} 读取 size 的第一个元素:路径按 . 拆分,数字用来索引数组。空路径 {"var": ""} 是整个文档。解析不到的路径读到 null,除非你给 var 第二个参数作为后备值:{"var": ["hills.0.2", -1]}。
在 map、filter、all、some 和 none 内部,文档就是当前元素。对一个坐标对来说,{"var": "0"} 是它的行。在循环体内读取外层路径会读到 null,而不会失败:
{"map": [{"var": "mine"}, [{"var": "0"}, {"var": "size.1"}]]}
在 DataLogic Studio 中打开——数据:一份示例观测:三只蚂蚁、一只敌蚁、一个食物、双方蚁丘、三格水域
每只蚂蚁返回的结果里,你本想放地图宽度的位置都是 null。scatter 把 null 坐标读作 0,于是每个点都落在第 0 列,而且没有任何东西报告问题。
向外访问,以及唯一不能访问的地方
{"val": [[N], …]} 可以从迭代器中向外跳出。开头的 [N] 若等于或超过外层迭代器的层数,就会相对根文档解析,所以在一个遍历 mine 的 map 内部:
{"map": [{"var": "mine"}, [{"var": "0"}, {"val": [[1], "size", 1]}]]}
在 DataLogic Studio 中打开——数据:一份示例观测:三只蚂蚁、一只敌蚁、一个食物、双方蚁丘、三格水域
从一个迭代器向上一层就是根,中间没有别的层级。在 嵌套 的迭代器里,你无法访问外层元素:{"val": [[1], …]} 读取最内层,再往上的每一层都相对根解析。你不能用两层嵌套迭代器写出内层循环体需要外层元素的笛卡尔积,而这不是缺了某个操作符造成的:是作用域规则划定了界限。(观测正因此携带了 vis:视野掩码是按每只蚂蚁画出的一个圆盘,恰好是这条规则所禁止的形状。)
reduce 能向外访问。它的循环体看到的是 {"current": <element>, "accumulator": <so far>},而引擎在循环 之外 对它的第三个参数(初始累加器)求值。把循环体需要的外层值放进累加器,每一步都会把它传下去。把累加器写成数组,这样就没有操作符名称能占用它:
{"reduce": [
[
{"reduce": [
{"var": "mine"},
[
{"var": "accumulator.0"},
{"merge": [
{"var": "accumulator.1"},
[
{"+": [
{"*": [{"var": "current.0"}, {"var": "accumulator.0"}]},
{"var": "current.1"}
]}
]
]}
],
[{"var": "size.1"}, []]
]}
],
{"var": "current.1"},
null
]}
在 DataLogic Studio 中打开——数据:一份示例观测:三只蚂蚁、一只敌蚁、一个食物、双方蚁丘、三格水域
从那个例子里照搬三点:
- 循环体返回整个累加器
[width, indices],否则下一步就丢了宽度。 - 两个部分分别以
accumulator.0和accumulator.1读取。它们没有名字。 - 外层的
{"reduce": [[<inner>], {"var": "current.1"}, null]}把你想要的那部分取出来:对一个单元素数组做 reduce,循环体只做投影。它只花一次运算,也是对程序计算出的值(而不是文档中已有的值)做索引的唯一办法。
路径段可以计算得出
引擎会对 {"val": …} 的各段求值,所以计算出的索引可以用作路径:
{"val": [[1], "data", "dirs", {"var": ""}]}
从根读取 dirs[i],其中 i 是当前元素。它不需要 at 操作符就把索引变成了名称,还取代了每个分支都要花一次比较的嵌套 if 链。
张量值
张量是一个不透明的值,带有形状和数据类型,由一个张量操作符生成、另一个消费。它不是嵌套的 JSON 数组。程序只能向它询问两件事,shape 和 dtype,别的都不行:不能索引,不能 map,不能做算术。
数据类型有 bool、i8、u8、i16、u16、i32、u32、i64、u64、f32 和 f64。只搬运字节的操作符还支持 f16 和 bf16。
在传输格式中,张量是 {"tensor": {"dtype", "shape", "data": "<base64>"}},打印一个张量时你看到的就是这种形式。程序不能用这种方式构造张量。
张量为真,而且只与自身相等。操作符一页列出了进入张量的途径,以及从张量回来的途径。
相等与真值
== 的比较方式和 JavaScript 一样,要记住一条规则:{"==": [0, null]} 为真。解析不到的路径读作 null,而 null 与每一个假值都相等。针对一条解析不到的路径写的 filter 既不会失败,也不会返回空列表:它会选出假值元素,而且只要被比较的值是零,看起来就一直是对的。
用 === 比较,或者与一个能解析到的值比较。这是适配器可能犯的代价最大的错误,平台自己的工作流也会犯。
0、""、null 和 [] 为假,其他一切为真,包括 {}。对空列表求 all 和 some 得到 false,none 得到 true。测试一下视野内既无敌人也无食物的回合,这三者在那里都会遇到空列表。
这门语言不做的事
除了 cast 和 normalize,这门语言没有张量算术:没有加法,没有乘法,没有矩阵乘法,没有卷积。平台是有意不提供的。节点按张量操作符读取或生成的元素数计价,这对搬运数据来说是公平的价格,对计算来说则不对:一次 128 × 128 的矩阵乘法只需约 32,769 次运算,却做了两百万次乘加。能做计算的适配器,就会成为排在计价模型前面的第二个不计价的模型。
在适配器里做算术还会花掉你的时间。节点以解释方式运行适配器,而计算图是编译过的、多线程且向量化的,两者共用一个回合时限。把算术放进计算图里。
如果你觉得少了某个操作符,先问问它是在搬运或重塑信息,还是在用信息做计算。这门语言负责搬运信息,计算图负责计算。
版本,以及会变动的东西
你不需要声明 dialect 字段:清单的 abi 指明了契约,引擎由节点提供。平台会记录你的参赛模型通过审核时的 Orion 版本,因为这个版本决定了表达式引擎及其计数规则。
引擎自己的文档写明,运算次数可能随版本变化:新的快速路径或常量折叠会改变引擎实际分派的内容。按实际工作量做预算,在你测得的次数之上留出余量。上限 1,000,000 而用到 990,000 的适配器,离一次判罚只差一个补丁版本。
平台升级后,会有一次复查,用新引擎重新检查已通过审核的版本。公告升级之后,请重新运行本地验证;旧的通过结果说明不了新引擎的情况。