Poly lowering rebuilds nested member-read auxiliary expressions from the wrong component
Summary
When polynomial lowering introduces an auxiliary member for an expression involving a nested member read, rebuildExprInCompute rebuilds every struct.readm from the enclosing compute function's %self. It does not rebuild the original read's component operand. A constrain-side path like parent.child.val can therefore become parent.val in compute().
Root cause
rebuildExprInCompute handles MemberReadOp by creating a new read from computeFunc.getSelfValueFromCompute() and the same member name. It should recursively rebuild readOp.getComponent() instead.
The helper is used when poly lowering writes generated auxiliary members here, and the R1CS lowering path uses the same helper for aux writes here.
Reproduction
Use a parent and child that both have a compatible @val member:
module attributes {llzk.lang} {
struct.def @Child {
struct.member @val : !felt.type {llzk.pub}
function.def @compute(%x: !felt.type) -> !struct.type<@Child> {
%self = struct.new : !struct.type<@Child>
struct.writem %self[@val] = %x : !struct.type<@Child>, !felt.type
function.return %self : !struct.type<@Child>
}
}
struct.def @Parent {
struct.member @child : !struct.type<@Child>
struct.member @val : !felt.type
function.def @compute(%x: !felt.type) -> !struct.type<@Parent> {
%self = struct.new : !struct.type<@Parent>
function.return %self : !struct.type<@Parent>
}
function.def @constrain(%self: !struct.type<@Parent>, %x: !felt.type) {
%child = struct.readm %self[@child] : !struct.type<@Parent>, !struct.type<@Child>
%child_val = struct.readm %child[@val] : !struct.type<@Child>, !felt.type
%sq = felt.mul %child_val, %child_val
%quartic = felt.mul %sq, %sq
%parent_val = struct.readm %self[@val] : !struct.type<@Parent>, !felt.type
constrain.eq %parent_val, %quartic : !felt.type
function.return
}
}
}
Run:
llzk-opt -I . -split-input-file -llzk-full-poly-lowering nested_member_read_aux_rebuild.llzk
Observed output from llzk-opt 2.0.0 at 22931df604ba40ecf50c6260c90365df16a9c222:
function.def @compute(%arg0: !felt.type) -> !struct.type<@Parent> {
%self = struct.new : <@Parent>
%0 = struct.readm %self[@val] : <@Parent>, !felt.type
%1 = felt.mul %0, %0 : !felt.type, !felt.type
struct.writem %self[@__llzk_poly_lowering_pass_aux_member_0] = %1 : <@Parent>, !felt.type
function.return %self : !struct.type<@Parent>
}
The lowered constrain() still reads @child and then child @val; only the generated compute-side aux assignment uses direct parent @val.
Expected behavior
The compute-side auxiliary expression should preserve the member path shape: read %self[@child], then read @val from that child, then compute the auxiliary value.
Impact
The generated witness computation can disagree with the lowered constraints. In this example, the aux value is computed from Parent.val^2, while the constrain-side relation expects it to correspond to Child.val^2. This changes the semantics of lowered LLZK programs that use nested component fields in high-degree expressions.
Fix direction
In rebuildExprInCompute, map the original constrain %self to compute %self, then recursively rebuild the original MemberReadOp component before recreating the read. Do not replace every member-read base with the enclosing compute %self.
Regression test
Add a poly-lowering test with both Parent.@val and Child.@val. FileCheck should require a compute-side struct.readm %self[@child] followed by struct.readm <child>[@val], and reject computing the aux from direct Parent.@val.
Poly lowering rebuilds nested member-read auxiliary expressions from the wrong component
Summary
When polynomial lowering introduces an auxiliary member for an expression involving a nested member read,
rebuildExprInComputerebuilds everystruct.readmfrom the enclosing compute function's%self. It does not rebuild the original read's component operand. A constrain-side path likeparent.child.valcan therefore becomeparent.valincompute().Root cause
rebuildExprInComputehandlesMemberReadOpby creating a new read fromcomputeFunc.getSelfValueFromCompute()and the same member name. It should recursively rebuildreadOp.getComponent()instead.The helper is used when poly lowering writes generated auxiliary members here, and the R1CS lowering path uses the same helper for aux writes here.
Reproduction
Use a parent and child that both have a compatible
@valmember:Run:
llzk-opt -I . -split-input-file -llzk-full-poly-lowering nested_member_read_aux_rebuild.llzkObserved output from
llzk-opt2.0.0 at22931df604ba40ecf50c6260c90365df16a9c222:The lowered
constrain()still reads@childand then child@val; only the generated compute-side aux assignment uses direct parent@val.Expected behavior
The compute-side auxiliary expression should preserve the member path shape: read
%self[@child], then read@valfrom that child, then compute the auxiliary value.Impact
The generated witness computation can disagree with the lowered constraints. In this example, the aux value is computed from
Parent.val^2, while the constrain-side relation expects it to correspond toChild.val^2. This changes the semantics of lowered LLZK programs that use nested component fields in high-degree expressions.Fix direction
In
rebuildExprInCompute, map the original constrain%selfto compute%self, then recursively rebuild the originalMemberReadOpcomponent before recreating the read. Do not replace every member-read base with the enclosing compute%self.Regression test
Add a poly-lowering test with both
Parent.@valandChild.@val. FileCheck should require a compute-sidestruct.readm %self[@child]followed bystruct.readm <child>[@val], and reject computing the aux from directParent.@val.