15拼图游戏
就这么个玩意,我没玩过,看起来类似中国的华容道
我们将以 15 拼图为例——我们将展示如何编写一个 Cairo 程序来验证 15 拼图的解决方案(初始状态将是一个输入),从而让您证明您知道初始状态的解决方案状态(不必向验证证明的人透露解决方案!)。
我们需要检查什么
我们将解决方案表示为两个列表:第一个将包含空图块的位置(行和列,两者都从零开始索引),因此在上面的示例中我们有:[(0, 2), (1 , 2), (1, 3), (2, 3), (3, 3)]。第二个列表将包含要移动的图块上的数字,因此我们有:[3, 7, 8, 12]。请注意,第一个列表总是长一个元素。我们称第二个列表的长度为 n_steps(这确实是步数),第一个列表的长度为 n_steps + 1。
我们将验证以下属性是否成立:这里类似编写zk电路添加约束的过程
- 第一个列表中的位置是有意义的——所有数字都在 0 到 3 之间,每对连续的数字代表相邻的位置。
约束块不能越界(初始状态和边界)
- 根据第一个列表中的位置,第二个列表中的数字对应于图块的值。例如,3 是空白块初始状态下的位置应放的图块, 同时是 (0, 2) 图块下一个状态下位置(1,2)的图块。类似地,7 是第二状态中位置 (1, 3) 处的图块,以及下一个状态中位置 (1, 2) 处的图块。
约束中间的移动过程
- 最终状态是“已解决”的。
约束答案是正确的(结果)
定义位置结构体
让我们首先定义一个表示图块位置的结构:
struct Location {
row: felt,
col: felt,
}
第一行 struct Location { 开始定义结构。接下来我们定义两个成员 row 和 col,都是 felt 类型。最后我们用 } 字符关闭结构。
验证单个位置的有效性
func verify_valid_location(loc: Location*) {
// Check that row is in the range 0-3.
tempvar row = loc.row;
assert row * (row - 1) * (row - 2) * (row - 3) = 0;
// Check that col is in the range 0-3.
tempvar col = loc.col;
assert col * (col - 1) * (col - 2) * (col - 3) = 0;
return ();
}
表达式 loc: Location* 指示 Cairo 将 loc 解释为 Location 实例的地址。这意味着它会期望地址 loc 处内存的值是该位置的行,地址 loc + 1 处的值是列。 Cairo 让我们使用 loc.row 和 loc.col 解决这两个值。
接下来我们看到一个临时变量的定义。我们在上面提到 Cairo 内存是不可变的,所以“变量”这个名字可能会产生误导(因为它的值不能改变)。形式为 tempvar a = expr 的语句;分配一个内存单元,将其命名为 a,并为其分配 值。
临时变量的范围是有限的。例如,临时变量可能由于跳转(例如,if 语句)或函数调用而被撤销。你可能还记得,在第一节中我们提到Cairo有一些微妙的地方,这就是其中之一。因为这个函数非常简单,没有跳转,也没有使用 tempvar 调用其他函数,所以这里是没有问题的。
Cairo没有 < 运算符。原因是在 Cairo 机器中,小于操作是一个复杂的操作,所以 Cairo 有一个名为 range-check 的内置函数,它允许比较值。讲道理,读到这,有点zk的感觉了
Cairo有调用range-check的库函数,例如 assert_nn_le(),它获取两个参数 x 和 y 并验证0<=x<=y
划重点,这里不翻译了我直接解释吧
这里这个代码是用了一个数学技巧,就是几个多项式乘积为0,那么至少存在一项为0。那么我们看row * (row - 1) * (row - 2) * (row - 3) = 0
,就是row只能是0或者1或者2或者3这4个值,否则乘积就不为0了,进而约束了row的范围是0到3。
函数的最后一行是return();,不像高级语言中return语句是隐式的,即使没有返回值,你也必须在函数的末尾显式地使用return()。
到这里完成了第一步的约束
验证两个连续的位置
让我们继续验证两个连续位置是否相邻:如果我们查看两个位置之间的差异,我们希望看到 (0, 1)、(0, -1)、(1, 0)、(-1, 0).例如,上例中的前两个位置是 (0, 2) 和 (1, 2),实际上是 (0, 2) - (1, 2) = (-1, 0)。
func verify_adjacent_locations(
loc0: Location*, loc1: Location*
) {
alloc_locals;
local row_diff = loc0.row - loc1.row;
local col_diff = loc0.col - loc1.col;
if (row_diff == 0) {
// The row coordinate is the same. Make sure the
// difference in col is 1 or -1.
assert col_diff * col_diff = 1;
return ();
} else {
// Verify the difference in row is 1 or -1.
assert row_diff * row_diff = 1;
// Verify that the col coordinate is the same.
assert col_diff = 0;
return ();
}
}
该函数使用局部变量。这些类似于临时变量,除了它们可以访问的范围受到的限制要少得多——您可以从它们的定义开始到函数结束访问它们。
Alloc_locals;是Cairo机制的一部分。它分配了函数本地变量所需的内存。通常,这应该是使用局部变量的函数中的第一个语句。如果您尝试使用没有该行的本地变量,则编译将失败。
因此,如果编译器知道我何时使用局部变量,为什么它不能为我添加该行?有两个原因:
- Cairo 是一种显式语言——在大多数情况下,除非代码明确说明,否则它不会自动添加指令。
- 在某些情况下,可以避免此语句,并通过增加 ap 寄存器作为其他指令的一部分来手动分配所需的内存。在其他情况下,将它放在代码的不同部分是有意义的。
让我们回顾一下函数的流程:首先,我们计算行和列的差异(回想一下,我们期望它们为 -1、0 或 1)然后,如果行相同,则列差col_diff 必须为 -1 或 1(相当于 col_diff * col_diff = 1)。如果不为零,则列必须相同且行差必须为 -1 或 1。
引用、临时变量和局部变量
引用是使用 let 语句定义的,例如 let x = y * y * y;。您应该将 x 视为表达式 y * y * y 的别名,这意味着指令 let x = y * y * y;本身不会导致执行任何计算。另一方面,后面的指令如 assert x * x = 1;将变成 assert (y * y * y) * (y * y * y) = 1;。定义引用的范围源于定义别名表达式的范围。
注意
:语法 let x = foo(…) 是上述的一个例外——它立即调用 foo() (不像其他 let 语句,它不会导致实际计算),并创建一个对返回值的引用 x的 foo()。一般来说,引用不能包含函数调用
临时变量和局部变量是引用的特例。它们指向一个特定的存储单元,存储计算结果。因此语句 tempvar x = y * y * y;将调用计算,并且 x 将是包含结果的存储单元的别名,而不是表达式 y * y * y
临时变量不需要事先分配内存,但它们的范围是有限的。局部变量位于函数堆栈的开头,因此需要使用指令 alloc_locals 预先分配,但在函数的整个执行过程中都可以访问它们。
函数调用结果的范围类似于临时变量的范围。如果稍后需要访问返回值,则应将结果复制到局部变量。
如果您收到临时变量已被撤销的错误,您可以尝试将其设为局部变量。
到这就完成了第二部分,约束中间计算过程的正确性
验证位置列表
让我们将其包装在一个循环(准确地说是递归)中,在整个位置列表上调用这两个函数。
func verify_location_list(loc_list: Location*, n_steps) {
// Always verify that the location is valid, even if
// n_steps = 0 (remember that there is always one more
// location than steps).
verify_valid_location(loc=loc_list);
if (n_steps == 0) {
return ();
}
verify_adjacent_locations(
loc0=loc_list, loc1=loc_list + Location.SIZE
);
// Call verify_location_list recursively.
verify_location_list(
loc_list=loc_list + Location.SIZE, n_steps=n_steps - 1
);
return ();
}
添加虚拟main()函数
在我们继续之前,让我们编写一个虚拟主函数,让我们可以运行 verify_location_list(稍后我们将删除它,并用真正的主函数替换它):
from starkware.cairo.common.registers import get_fp_and_pc
func main() {
alloc_locals;
local loc_tuple: (
Location, Location, Location, Location, Location
) = (
Location(row=0, col=2),
Location(row=1, col=2),
Location(row=1, col=3),
Location(row=2, col=3),
Location(row=3, col=3),
);
// Get the value of the frame pointer register (fp) so that
// we can use the address of loc_tuple.
let (__fp__, _) = get_fp_and_pc();
// Since the tuple elements are next to each other, we can
// use the address of loc_tuple as a pointer to the 5
// locations.
verify_location_list(
loc_list=cast(&loc_tuple, Location*), n_steps=4
);
return ();
}
此函数使用元组来定义和存储 Location 元素列表。元组是有序的有限列表,可以包含有效类型的任意组合,例如,五个 Location 结构。可以使用从零开始的索引访问每个元素(例如,loc_tuple[2] 是第三个元素。请参阅元组)
在函数的开头,我们使用类型化的局部变量分配了 5 个位置。 Cairo 查找常量 Location.SIZE 以找出每个变量需要多少个单元格,然后按照定义的顺序分配它们。由于 loc_tuple 是一个包含 5 个位置的元组,Cairo 分配了 5 * Location.SIZE 内存单元。每个 Location 实例都分配了一些坐标(根据上面的示例)。
由于 verify_location_list 需要指向位置列表的指针,因此我们传递 &loc_tuple,它表示 loc_tuple 在内存中的地址。由于 &loc_tuple 的类型是指向元组的指针而不是 Location*,我们需要强制转换操作来指示编译器将此地址视为 Location*。
由于技术原因,当 Cairo 需要检索局部变量 (&loc_tuple) 的地址时,需要告知帧指针寄存器 fp 的值(请参阅 fp 寄存器)。这可以通过语句 let ( __ fp__, _ ) = get_fp_and_pc() 来完成,它调用库函数 get_fp_and_pc() 来检索 fp。结果命名为 __ fp__,这是 Cairo 在必须知道 fp 时查找的名称。如果忘记写这行,可能会出现如下形式的错误:直接使用 fp 的值,需要定义一个名为 fp 的变量。
语法 let (…) = foo(…) 调用一个函数 foo,它返回一个值元组并将该元组的每个条目存储到一个单独的变量中。符号 _ 可用于跳过条目。
练习
- 使用位置坐标的值,如果它们代表非法值,请确保程序失败。例如,尝试将 loc_tuple[0].row 从 0 更改为 10。您应该会看到 verify_valid_location 中的断言失败。或者您可以将此值更改为 1,这将使第一个转换不合法(空块不能留在同一个地方)。
- 修改 verify_location_list 以便它检查最后一个位置确实是 (3, 3)。
原文连接: https://www.cairo-lang.org/docs/hello_cairo/puzzle.html