You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This proposal is the longer-term architecture behind #542.
The core design rule is:
Polynomial lowering should introduce compiler-generated existential SSA auxiliaries with an explicit dynamic freshness domain. It should not immediately materialize those auxiliaries as struct.member storage.
A later materialization layer should decide how each auxiliary is represented for a particular target: scalar component member, backend-private signal, static unrolled member, array element, column/trace cell, product-program value, deterministic witgen value, or diagnostic rejection.
The intent is not to make #542 wait for a large rewrite. The immediate #542 fix can be a conservative rejection in the current scalar-member path. This proposal explains the architecture that keeps that fix from becoming a chain of special cases.
Motivation
Polynomial lowering is an existential-introduction transform.
At the algebraic level, it changes:
P[E]
into:
exists a. (a = E) and P[a]
That is only sound when the scope of exists a matches the dynamic execution of E.
For a component-invariant expression, this may be sound:
exists a_component.
a_component = E
and P[a_component]
For an expression that depends on a loop-carried value, the required meaning is different:
forall i.
exists a_i.
a_i = E(i)
and P_i[a_i]
The unsound form is:
exists a_component.
forall i.
a_component = E(i)
and P_i[a_component]
That form adds constraints not present in the source program unless E(i) is proven identical for every iteration.
#542 demonstrates this exact failure. A loop-local expression depending on an scf.for loop-carried region argument is lowered to one component-scope auxiliary member. The source constraint is tautological, but the lowered constraints can require the same generated member to equal different per-iteration values.
Concrete current behavior
The current pass contract already points to the semantic mismatch. The pass documentation says polynomial lowering rewrites constraints into an observationally equivalent bounded-degree system, but also says high-degree subexpressions are factored into auxiliary struct members:
That means polynomial lowering currently makes three decisions at once:
which algebraic expression should be factored;
what freshness domain the generated existential should have;
how the generated value is stored and computed.
The bug comes from coupling those decisions too early.
Why loop-carried values make this unsound
MLIR scf.for body arguments are dynamic values for the current loop iteration. The induction variable is the first region argument, followed by one region argument per loop-carried variable: https://mlir.llvm.org/docs/Dialects/SCFDialect/
So in #542, %acc is not merely “some degree-one felt value.” It is the loop-carried value for the current dynamic iteration.
An expression like:
%sq = felt.mul%acc, %acc : !felt.type, !felt.type
has per-iteration freshness when %acc changes per iteration.
A single component member has component-instance lifetime. It does not distinguish loop iterations. Therefore, using one generated member for %sq changes:
forall i. exists aux_i
into:
exists aux. forall i
That is the semantic break.
Why compute rebuilding is not the architectural fix
rebuildExprInCompute currently maps a BlockArgument by ordinal position into the compute function:
Value val, FuncDefOp computeFunc, OpBuilder &builder, DenseMap<Value, Value> &memo
) {
if (auto it = memo.find(val); it != memo.end()) {
return it->second;
}
if (auto barg = llvm::dyn_cast<BlockArgument>(val)) {
unsigned index = barg.getArgNumber();
Value mapped = computeFunc.getArgument(index - 1);
return memo[val] = mapped;
}
That is valid only for a narrow class of entry-block argument correspondences. It is not valid for nested region arguments such as:
scf.for induction variables;
scf.for loop-carried body arguments;
branch block arguments;
values derived from nested region-local values.
Making this helper smarter may fix some cases, but it does not solve the core issue. The core issue is that polynomial lowering should not assume that every generated existential can be represented as one component-lifetime member written in @compute.
Why use replacement must be dominance-aware
The current use-replacement helper is based on same-block order:
A replacement is valid only if the replacement value dominates the use and is visible from that use. Same-block textual ordering is a useful local fact, but it is not the semantic rule.
Core design rule
A polynomial auxiliary and a component member are different abstractions.
A polynomial auxiliary is:
an existential value with a required dynamic freshness domain
A component member is:
storage with component-instance lifetime
The compiler should preserve that distinction.
Poly lowering may introduce the auxiliary. A materialization pass may choose storage. Storage may be chosen only after proving it preserves the required freshness domain.
Required invariant
For every generated auxiliary A replacing expression E:
A must be fresh over a required dynamic domain D_required.
D_required must be at least as fine as every dynamic dependency of E.
Every replacement use of E by A must be dominated by A.
Every replacement use must remain inside a region where A is visible.
Chosen storage must have a domain D_storage equal to or finer than D_required.
Coarser storage is allowed only with an explicit proof that all dynamic instances are value-identical and that sharing does not add source-invisible constraints.
If no available representation is sound, compilation must fail with a diagnostic.
In short:
Never lower a fresh existential auxiliary directly to a component field.
First preserve freshness.
Then materialize only after proving the storage is sound.
Proposed architecture
Use three responsibilities that are currently mixed together.
1. PolyLoweringCore
This layer performs algebraic degree lowering only.
If %expr depends on the current loop iteration, %aux must be placed inside that loop body unless a separate pass proves hoisting is semantics-preserving.
2. AuxFreshnessAnalysis / AuxScopeAnalysis
This layer computes the minimum freshness domain required by each generated auxiliary.
It should classify dependencies on:
scf.for induction variables;
scf.for loop-carried region arguments;
values derived from loop-local values;
nested loop iteration vectors;
branch-local values;
dynamic array indices;
dynamic member/table offsets;
affine map operands;
function calls without sufficient summaries;
unknown region or control-flow constructs.
This must be dependency-based, not purely location-based.
Examples:
An expression syntactically inside a loop may still be component-invariant.
An expression with degree one may still be dynamically scoped because it depends on a loop IV, offset, affine operand, or branch-local value.
A struct member read may be component-lifetime for one member path, but dynamic for another if table offsets or affine operands are dynamic.
3. AuxMaterialization
This layer chooses concrete representation.
Possible materialization targets include:
private scalar component member;
backend-private R1CS signal;
one scalar member per statically unrolled dynamic instance;
array cell;
column/trace cell;
product-program value;
deterministic witgen computation;
rejection diagnostic.
Every materializer must prove:
Domain soundness: storage granularity is equal to or finer than required freshness.
Constraint preservation: replacing the semantic auxiliary does not add or remove constraints.
Witness consistency: deterministic targets can compute the value in the same dynamic domain.
Visibility correctness: generated private auxiliaries do not become public outputs.
Backend legality: the resulting IR is legal for the selected pipeline.
Freshness domains
Freshness should be modeled as a partial order, not as a single boolean.
Useful dynamic axes include:
component invocation;
function call instance;
loop iteration;
nested loop iteration vector;
branch execution;
while iteration;
dynamic table/trace row;
unknown or unsupported dynamic execution.
Storage is sound when:
D_storage >= D_required
where >= means “equal or finer dynamic granularity.”
Examples:
component member < loop iteration
outer loop iteration < nested loop iteration vector
branch execution incomparable with unrelated loop iteration
unknown unsupported unless proven by a target-specific analysis
%sq depends on %acc
%acc is an scf.for loop-carried region argument
therefore %sq requires per-iteration freshness
one scalar struct.member has component-invocation freshness
component-invocation freshness is coarser than per-iteration freshness
therefore scalar component-member materialization is unsound
Compatibility scalar materialization
The current scalar-member representation can remain as a compatibility materializer. It just should not be the semantic representation of polynomial auxiliaries.
A scalar component member is valid only if all of these are proven:
The required freshness domain is component invocation, or all dynamic instances are value-identical.
The expression can be rebuilt in @compute.
The rebuild uses the same semantic inputs: arguments, member paths, constants, affine operands, offsets, and dynamic dependencies.
The generated member is written exactly once per component invocation, unless the target explicitly supports another write model.
The generated member is private.
The generated type is supported, initially !felt.type.
error: cannot materialize polynomial auxiliary as one scalar component member
note: factored expression depends on scf.for loop-carried region argument %acc
note: scalar struct.member storage has component-invocation freshness
note: required freshness domain is per dynamic iteration of this scf.for
note: use semantic llzk.aux lowering, static unrolling, product materialization,
or an R1CS-private per-iteration auxiliary materializer
Why not reuse llzk.nondet
llzk.nondet is close, but it is not the right abstraction for generated polynomial auxiliaries.
The current op is documented as producing a nondeterministic SSA value and is marked AlwaysSpeculatable:
A dedicated llzk.aux op can carry the generated-auxiliary contract directly. V1 can be conservative: !felt.type only, not CSE-able, not Pure, and not freely hoistable across dynamic-scope boundaries.
Pipeline direction
The near-term safe behavior for existing users can be:
auto eqOp = builder.create<EmitEqualityOp>(val.getLoc(), aux, lhs);
auxAssignments.push_back({auxName, lhs});
degreeMemo[aux] = 1;
rewrites[aux] = aux;
replaceSubsequentUsesWith(lhs, aux, eqOp);
That can be supported during migration, but the final design should not require generated polynomial auxiliaries to become component-lifetime struct storage first.
A cleaner R1CS path is:
llzk.aux
-> backend-private R1CS variable/signal
with one private value per required dynamic instance. If loops remain at that point, the R1CS materializer must either unroll, use a loop-aware representation, or reject. It must not silently collapse all dynamic instances into one signal.
Test plan
The regression suite should cover both the immediate bug and the architecture boundary.
Summary
This proposal is the longer-term architecture behind #542.
The core design rule is:
A later materialization layer should decide how each auxiliary is represented for a particular target: scalar component member, backend-private signal, static unrolled member, array element, column/trace cell, product-program value, deterministic witgen value, or diagnostic rejection.
The intent is not to make #542 wait for a large rewrite. The immediate #542 fix can be a conservative rejection in the current scalar-member path. This proposal explains the architecture that keeps that fix from becoming a chain of special cases.
Motivation
Polynomial lowering is an existential-introduction transform.
At the algebraic level, it changes:
into:
That is only sound when the scope of
exists amatches the dynamic execution ofE.For a component-invariant expression, this may be sound:
For an expression that depends on a loop-carried value, the required meaning is different:
The unsound form is:
That form adds constraints not present in the source program unless
E(i)is proven identical for every iteration.#542 demonstrates this exact failure. A loop-local expression depending on an
scf.forloop-carried region argument is lowered to one component-scope auxiliary member. The source constraint is tautological, but the lowered constraints can require the same generated member to equal different per-iteration values.Concrete current behavior
The current pass contract already points to the semantic mismatch. The pass documentation says polynomial lowering rewrites constraints into an observationally equivalent bounded-degree system, but also says high-degree subexpressions are factored into auxiliary struct members:
llzk-lib/include/llzk/Transforms/LLZKTransformationPasses.td
Lines 66 to 79 in aced31c
The implementation creates auxiliary
struct.members during expression factoring:llzk-lib/lib/Transforms/LLZKPolyLoweringPass.cpp
Lines 178 to 224 in aced31c
It also creates auxiliary members for nonlinear struct constrain call arguments:
llzk-lib/lib/Transforms/LLZKPolyLoweringPass.cpp
Lines 253 to 278 in aced31c
llzk-lib/lib/Transforms/LLZKPolyLoweringPass.cpp
Lines 420 to 438 in aced31c
The pass records each auxiliary as an auxiliary member name plus the value to compute:
llzk-lib/lib/Transforms/LLZKPolyLoweringPass.cpp
Lines 51 to 54 in aced31c
At the end, it rebuilds the recorded expression in
@computeand emits astruct.writem:llzk-lib/lib/Transforms/LLZKPolyLoweringPass.cpp
Lines 453 to 464 in aced31c
That means polynomial lowering currently makes three decisions at once:
The bug comes from coupling those decisions too early.
Why loop-carried values make this unsound
MLIR
scf.forbody arguments are dynamic values for the current loop iteration. The induction variable is the first region argument, followed by one region argument per loop-carried variable:https://mlir.llvm.org/docs/Dialects/SCFDialect/
So in #542,
%accis not merely “some degree-one felt value.” It is the loop-carried value for the current dynamic iteration.An expression like:
has per-iteration freshness when
%accchanges per iteration.A single component member has component-instance lifetime. It does not distinguish loop iterations. Therefore, using one generated member for
%sqchanges:into:
That is the semantic break.
Why compute rebuilding is not the architectural fix
rebuildExprInComputecurrently maps aBlockArgumentby ordinal position into the compute function:llzk-lib/lib/Transforms/LLZKLoweringUtils.cpp
Lines 29 to 40 in aced31c
That is valid only for a narrow class of entry-block argument correspondences. It is not valid for nested region arguments such as:
scf.forinduction variables;scf.forloop-carried body arguments;Making this helper smarter may fix some cases, but it does not solve the core issue. The core issue is that polynomial lowering should not assume that every generated existential can be represented as one component-lifetime member written in
@compute.Why use replacement must be dominance-aware
The current use-replacement helper is based on same-block order:
llzk-lib/lib/Transforms/LLZKLoweringUtils.cpp
Lines 102 to 118 in aced31c
That is not enough for region-aware SSA rewriting.
MLIR values are governed by dominance, nested region scope, and hierarchical visibility:
https://mlir.llvm.org/docs/LangRef/
A replacement is valid only if the replacement value dominates the use and is visible from that use. Same-block textual ordering is a useful local fact, but it is not the semantic rule.
Core design rule
A polynomial auxiliary and a component member are different abstractions.
A polynomial auxiliary is:
A component member is:
The compiler should preserve that distinction.
Poly lowering may introduce the auxiliary. A materialization pass may choose storage. Storage may be chosen only after proving it preserves the required freshness domain.
Required invariant
For every generated auxiliary
Areplacing expressionE:Amust be fresh over a required dynamic domainD_required.D_requiredmust be at least as fine as every dynamic dependency ofE.EbyAmust be dominated byA.Ais visible.D_storageequal to or finer thanD_required.In short:
Proposed architecture
Use three responsibilities that are currently mixed together.
1. PolyLoweringCore
This layer performs algebraic degree lowering only.
It should:
It should not:
struct.memberdeclarations;struct.writemoperations in@compute;@constrainexpressions in@compute;BlockArguments to compute arguments by ordinal position.A semantic lowering could look like:
If
%exprdepends on the current loop iteration,%auxmust be placed inside that loop body unless a separate pass proves hoisting is semantics-preserving.2. AuxFreshnessAnalysis / AuxScopeAnalysis
This layer computes the minimum freshness domain required by each generated auxiliary.
It should classify dependencies on:
scf.forinduction variables;scf.forloop-carried region arguments;This must be dependency-based, not purely location-based.
Examples:
3. AuxMaterialization
This layer chooses concrete representation.
Possible materialization targets include:
Every materializer must prove:
Freshness domains
Freshness should be modeled as a partial order, not as a single boolean.
Useful dynamic axes include:
Storage is sound when:
where
>=means “equal or finer dynamic granularity.”Examples:
For #542:
Compatibility scalar materialization
The current scalar-member representation can remain as a compatibility materializer. It just should not be the semantic representation of polynomial auxiliaries.
A scalar component member is valid only if all of these are proven:
@compute.!felt.type.This compatibility path should reject #542.
A good diagnostic would be:
Why not reuse
llzk.nondetllzk.nondetis close, but it is not the right abstraction for generated polynomial auxiliaries.The current op is documented as producing a nondeterministic SSA value and is marked
AlwaysSpeculatable:llzk-lib/include/llzk/Dialect/LLZK/IR/Ops.td
Lines 28 to 53 in aced31c
The same documentation already warns that merging distinct nondet values can change circuit semantics:
llzk-lib/include/llzk/Dialect/LLZK/IR/Ops.td
Lines 67 to 81 in aced31c
Generated polynomial auxiliaries need a placement-sensitive contract:
A loop-local generated auxiliary must not be freely hoisted out of the loop, because that changes:
into:
MLIR's side-effect and speculation model is the right place to express this kind of movement boundary:
https://mlir.llvm.org/docs/Rationale/SideEffectsAndSpeculation/
A dedicated
llzk.auxop can carry the generated-auxiliary contract directly. V1 can be conservative:!felt.typeonly, not CSE-able, notPure, and not freely hoistable across dynamic-scope boundaries.Pipeline direction
The near-term safe behavior for existing users can be:
That fits the current full poly lowering pipeline shape:
llzk-lib/lib/Transforms/LLZKTransformationPassPipelines.cpp
Lines 54 to 64 in aced31c
A semantic mode can be opt-in while backends are updated:
Semantic mode should:
llzk.aux;constrain.eq %aux, %expr;struct.def;@computewrites;A target pipeline that cannot consume
llzk.auxshould run a verifier/materializer before backend lowering.Backend note: R1CS
The R1CS lowering path currently maps struct members to signals:
llzk-lib/backends/r1cs/lib/r1cs/Transforms/R1CSLoweringPass.cpp
Lines 602 to 617 in aced31c
It also has its own auxiliary-member normalization path:
llzk-lib/backends/r1cs/lib/r1cs/Transforms/R1CSLoweringPass.cpp
Lines 257 to 352 in aced31c
That can be supported during migration, but the final design should not require generated polynomial auxiliaries to become component-lifetime struct storage first.
A cleaner R1CS path is:
with one private value per required dynamic instance. If loops remain at that point, the R1CS materializer must either unroll, use a loop-aware representation, or reject. It must not silently collapse all dynamic instances into one signal.
Test plan
The regression suite should cover both the immediate bug and the architecture boundary.
Required tests:
Exact Poly lowering materializes loop-local expressions as one component-scope auxiliary #542 repro.
llzk.auxinside thescf.forbody.Loop-carried dependency.
%acc-derived expression requires per-iteration freshness.Loop-IV dependency.
%iv-derived expression requires per-iteration freshness.Loop-invariant expression inside a loop.
Nested loops.
%i-only dependency can be outer-loop fresh.%iand%jdependency requires nested iteration freshness.Branch-local expression.
Dynamic offset or affine operand.
Nonlinear struct constrain call argument.
llzk.nondetseparation.llzk.auxdoes not merge with userllzk.nondet.Cleanup/canonicalization stability.
Phased implementation plan
Phase 1: immediate safety patch
Before creating a scalar auxiliary member, prove:
@compute;If not, emit a diagnostic and fail.
This phase prevents the #542 unsound lowering without requiring backend-wide changes.
Phase 2: dominance-aware replacement
Replace same-block “subsequent use” rewriting with MLIR dominance and region-scope checks.
This is needed even if scalar compatibility remains the default.
Phase 3: semantic auxiliary op
Add
llzk.auxwith generated existential semantics.V1 constraints:
!felt.type;Pure;ConstantLike;Phase 4: semantic poly lowering mode
Add an opt-in mode that emits
llzk.auxand equality constraints, but does not mutatestruct.defand does not emit@computewrites.Phase 5: materializers
Add materializers incrementally:
Acceptance criteria
This design is successful when:
llzk.auxwithout mutatingstruct.def;llzk.auxdeliberately or reject before unsupported lowering;References
Motivating issue:
Current LLZK implementation:
llzk-lib/include/llzk/Transforms/LLZKTransformationPasses.td
Lines 66 to 79 in aced31c
llzk-lib/lib/Transforms/LLZKPolyLoweringPass.cpp
Lines 178 to 224 in aced31c
llzk-lib/lib/Transforms/LLZKPolyLoweringPass.cpp
Lines 253 to 278 in aced31c
llzk-lib/lib/Transforms/LLZKPolyLoweringPass.cpp
Lines 420 to 438 in aced31c
llzk-lib/lib/Transforms/LLZKPolyLoweringPass.cpp
Lines 453 to 464 in aced31c
rebuildExprInComputeblock-argument mapping:llzk-lib/lib/Transforms/LLZKLoweringUtils.cpp
Lines 29 to 40 in aced31c
llzk-lib/lib/Transforms/LLZKLoweringUtils.cpp
Lines 102 to 118 in aced31c
llzk.nondetoperation:llzk-lib/include/llzk/Dialect/LLZK/IR/Ops.td
Lines 28 to 87 in aced31c
llzk-lib/lib/Transforms/LLZKTransformationPassPipelines.cpp
Lines 54 to 64 in aced31c
llzk-lib/backends/r1cs/lib/r1cs/Transforms/R1CSLoweringPass.cpp
Lines 257 to 352 in aced31c
llzk-lib/backends/r1cs/lib/r1cs/Transforms/R1CSLoweringPass.cpp
Lines 602 to 617 in aced31c
MLIR references:
scf.forloop-carried region argument semantics:https://mlir.llvm.org/docs/Dialects/SCFDialect/
https://mlir.llvm.org/docs/LangRef/
https://mlir.llvm.org/docs/Rationale/SideEffectsAndSpeculation/