AffineExpr / AffinePredicate ⇄ ISL ⇄ pelican::Expr Bridge
Scope. How a Penguin quasi-affine expression reaches isl and comes back. This is the plumbing between three layers that every other Part-5 polyhedral page assumes: the Python
AffineExpr/AffinePredicatefaces, the C++pelican::Exprlinear form they lower to, and the islAff/Setthe dependence analysis actually computes on. The single most important structural fact — and the one most likely to be assumed wrong — is that there is noAffineExpr → islfunction. The bridge is a two-module, three-stage pipeline, and the isl leg lives in a different binary entirely from the modules whose names contain "Affine". By the end of this page a reader can reproduce the round trip in both directions.
Related reading: Penguin AffineExpr Algebra (5.4 — the pelican::Expr node taxonomy this page lowers to), Penguin ISL Dependence-Graph Construction (5.16 — build_aff and the isl-side domain/access machinery), and the pelican::Expr C++ hierarchy in Part 7.
The three stages and the two modules
The forward direction is three stages crossing a single common currency — the flat pelican linear form c + Σ coeffᵢ·idxᵢ:
Penguin AffineExpr ──(A: linearize)──► FLAT pelican::Expr ──(B: build_aff)──► isl.Aff / isl.Set
isl result ──────────────────(C: enumerate)─────────────────────────► Python AffinePredicate
| Stage | What | Where it lives | isl? |
|---|---|---|---|
| A | linearize_affineexpr / linearize_affineindices, cc_div / cc_mod, pred_{eq,ge,gt,le,lt}, is_legal_predicate, wrap_expr | ir/AffineExpr.so, ir/AffinePredicate.so (Cython, unstripped) | NONE |
| B | build_aff / affine_exp — flat pelican kind → isl.Aff/PwAff verbs | C++ islwrapper::IntegerSetAnalysis in libBIR.so (driven from the Python IntegerSetAnalysis base) | all isl here |
| C | enumerate_affine_predicates — isl.Constraint → pelican expr → AffinePredicate | same islwrapper::IntegerSetAnalysis base | reads isl |
The proof that Stage A touches no isl (CONFIRMED).
strings ir/AffineExpr.so | rg -i islandstrings ir/AffinePredicate.so | rg -i islare both empty. Neither module importsislorislpy. Their import set isneuronxcc.pelican.ir(the C++ pelican factory home),neuronxcc.starfish.penguin.common, sibling…penguin.ir.Af*modules, plusnumpy/numbers/collections/functools/math. The two modules whose names contain "Affine" never see an integer set — they only build and normalisepelican::Exprnodes. The isl arithmetic is inlibBIR.so.
CORRECTION (BRIDGE-1) — the isl base is C++, not a Python
.so. The backing analysis locatedbuild_aff/enumerate_affine_predicates"in IntegerSetAnalysis / TongaIslDependenceAnalysis" with a Cython file-offset (0x4c790). That offset is the thin PythonIntegerSetAnalysisglue; the actual domain/aff/predicate algebra is the C++ classislwrapper::IntegerSetAnalysiscompiled intolibBIR.so— whose mangledbuild_aff,predicated_domain,enumerate_affine_predicates,quasi_affine_expr,convex_hullmethods are decompilable there. This page is consistent with 5.16 §CORRECTION ISL-DEP-1: "the low-level domain/aff/predicate algebra is native C++ inlibBIR.so; the Python module and the Cython subclass are the policy/glue layers above it." Wherever this page says "build_aff lives in IntegerSetAnalysis", read it as that C++ base.
Why the split is structural, not incidental. Penguin's AffineExpr is a tree — nested SumExpr / MultExpr / ModuloExpr / FloorDivExpr over AffineIdx leaves, hash-consed in an llvm::FoldingSet (see 5.4). isl wants a flat { c + Σ coeffᵢ·varᵢ } with explicit floor-div locals. Doing the tree→flat fold in pelican (Stage A) rather than inside the isl glue means the same flattened expression is reused by all three of pelican's consumers: isl dependence analysis, BIR emission (bir::QuasiAffineExpr), and JSON serialisation (toJsonv2). isl is only one of three consumers, so the flatten cannot live inside the isl glue. The flat pelican form is the hub; isl is a spoke.
Stage A.1 — linearize_affineexpr / linearize_affineindices
Both are module-level functions in AffineExpr.so (prefix __pyx_pw_9neuronxcc_8starfish_7penguin_2ir_10AffineExpr_*), bodies inlined into the pyx_pw wrappers (no separate pyx_pf symbols).
| Function | Offset | Generator body | Operates on |
|---|---|---|---|
linearize_affineexpr(expr) | 0x17e00 | __pyx_gb_…_20…generator2 @0x16290 | ONE AffineExpr tree |
linearize_affineindices(indices) | 0x171c0 | __pyx_gb_…_23…generator3 @0x157f0 | a vector of index exprs (an access's per-dim addrs) |
Each wrapper allocates two Cython closure scopes — scope_struct_*_linearize_* plus a nested _genexpr — confirmed by the referenced freelist symbols. The nested genexpr iterates the SumExpr term list (interned name n_terms, the AG10 SumExpr.n_terms@+0x28 / terms@+0x20), producing per-term (coeff, idx) pairs. The Cython function is a thin orchestrator + result-adopter; the real arithmetic — folding nested Mult/Sum into one coefficient-per-AffineIdx accumulation plus a scalar c — is the C++ pelican AffineExpr::flattenTerms / getLinearExpr / accumulateTerm (see 5.4 §the flatten).
// linearize_affineexpr(expr) @AffineExpr.so 0x17e00 [STRONG — orchestrator shape;
// arithmetic INFERRED from the pelican OPS it drives]
PyObject *linearize_affineexpr(PyObject *expr) {
// genexpr over expr's SumExpr terms (n_terms slot); the heavy fold is in C++:
// pelican::AffineExpr::flattenTerms / getLinearExpr / accumulateTerm
acc = {}; // map<AffineIdx*, int64 coeff>
int64 c = 0;
for ((coeff, idx) in walk_terms(expr)) // tree → (coeff,idx) pairs + const fold
if (idx == NULL) c += coeff; // bare scalar
else acc[idx] += coeff; // coefficient-per-index accumulation
return SumExpr_from(acc) + c; // canonical c + Σ coeffᵢ·idxᵢ (re-interned in FoldingSet)
}
linearize_affineindices is the vector variant: it flattens each address index of an Access independently and returns one flat pelican AffineExpr per access dimension. It is the function Access.linearize_indices calls to turn a multi-dim access-pattern index list into the flat addr-expr list that build_aff consumes one-per-tensor-dimension (the affs=[build_aff(a) for a in addrs] loop in 5.16 §access).
NOTE — the BIR layer has its own linearizer.
libBIR.so/libwalrus.socarry a separate C++bir::QuasiAffineExpr::linearize_affineexpr(pelican::PelicanContext*, llvm::SmallVector<unsigned long,4>&, std::vector<bir::QuasiAffineExpr>&)at0x5e9e70, called bybackend::Unroll::genPhyAPduring access-pattern materialisation. That is the BIR-emission consumer of the same flat form — corroborating that the flatten is shared currency, not isl-specific. The PythonAffineExpr.sofunction and this BIR method are distinct symbols feeding different back-ends from the identical canonical form.
Stage A.2 — cc_div / cc_mod — the collective-cyclic constructors
cc_div @0x23b30 and cc_mod @0x233d0 are byte-for-byte the same shape (both 0x760, identical call topology — confirmed by size + the symmetric ccdiv/ccmod interned-name pair). They are the Python factory front-doors for the collective-cyclic pelican expressions:
| Python | pelican class | kind | factory |
|---|---|---|---|
cc_div(numer, denom, rgid) | CCDivExpr | 27 (CCDivKind) | sub_62B8C0 |
cc_mod(numer, denom, rgid) | CCModExpr | 28 (CCModKind) | createCCModExpr @walrus 0x18f5e30 |
Each CC*Expr is a BinaryExpr + one extra field (AG10 layout, CONFIRMED):
numer @+0x20 RefPtr<Expr> // the rank / linear index being divided
denom @+0x28 int64 (>0) // the group_size / cyclic modulus (DivLike: denom>0 invariant)
replica_groups_id@+0x30 uint64 // which collective replica-group set
Semantics. For a collective op with global rank r and group size g:
CCDivExpr(r, g, rgid) = floor(r / g) // SHARD INDEX — which group the rank is in
CCModExpr(r, g, rgid) = r mod g // WITHIN-SHARD OFFSET — rank's position inside its group
This is how a sharded collective's per-replica address decomposes into (group-selector, intra-group-offset) — the cyclic / block-cyclic distribution of a tensor across the replica set. They are not generic ModuloExpr / FloorDivExpr: their denom is a runtime collective parameter (group_size), so they are tagged with replica_groups_id to keep distinct collective groups apart on the isl side.
Body (STRONG). One __Pyx__GetModuleGlobalName (resolve CCDivExpr/CCModExpr from neuronxcc.pelican.ir), one PyObject_GetAttr, one PyObject_Call (construct). A __pyx_ctuple_long (a C-tuple of int64, confirmed adjacent to the cc_div qualname in the string pool) passes the integer (denom, replica_groups_id) operands at the C level.
GOTCHA — the "CC-ness" collapses inside isl. On the isl side (Stage B) a
CCDivKindmaps to a ceilingscale_down_valand aCCModKindto a ceilingmod_val— the same primitives a plainFloorDivExpr/ModuloExpr(floor) uses, only with the rounding direction flipped. Thereplica_groups_idhas already selected which rank affine feedsnumer, so isl sees an ordinary integer-division local. The collective identity is a Penguin/pelican distinction that has no isl representation. See 5.16 §build_aff GOTCHA — mapping all four kinds onto floor semantics silently mis-rounds the ceiling pair.
Stage B — build_aff — flat pelican → isl.Aff / PwAff
build_aff is the only place a pelican Expr touches isl. It lives in the C++ islwrapper::IntegerSetAnalysis base (see CORRECTION BRIDGE-1), reversed in full on 5.16 §build_aff; reproduced here as the bridge's Stage B because the round trip is incomplete without it.
// build_aff(self, expr, space=None, loopnest=None, params=None) [CONFIRMED dispatch — 5.16]
isl_aff *build_aff(self, expr, space, loopnest, params) {
space = space ?: self->create_domain_space(loopnest, params);
return build(expr); // recursive descent on expr.kind (pelican ExprKind)
}
isl_aff *build(expr) {
switch (expr->kind) { // discriminator read by expr_kind (§Stage A.4)
case CExpr: return isl.Aff.val_on_domain(space, Val(expr->c));
case SumKind /*18*/: // the §A.1 flat form, term by term
acc = val_on_domain(space, Val(0));
for (t in expr->terms) acc = acc.add( build(t->idx) * t->coeff ); // c + Σ coeffᵢ·idxᵢ
return acc.add( val_on_domain(space, Val(expr->c)) );
case MultKind /*23*/: return build(expr->sub).scale(val_on_domain(space, Val(expr->coeff)));
case FloorDivKind /*25*/: return build(expr->numer).scale_down_val(expr->denom); // floor
case ModuloKind /*26*/: return build(expr->numer).mod_val(expr->denom);
case CCDivKind /*27*/: return build(expr->numer).scale_down_val(expr->denom); // CEILING variant
case CCModKind /*28*/: return build(expr->numer).mod_val(expr->denom); // CEILING variant
default: /* AffineIdx leaf */
pos = position_of(expr, loopnest_ivs, params); // POSITIONAL, by loopnest-IV / param order
if (pos.in_set) return isl.Aff.var_on_domain(LocalSpace(space), dim_type.set, pos.i);
if (pos.in_param) return isl.Aff.var_on_domain(LocalSpace(space), dim_type.param, pos.i);
raise("<expr> doesn't appear in params or loopnest"); // CONFIRMED literal
}
}
Two conventions matter for reproduction:
- Positional variable identity. An
AffineIdxresolves to its position indim_type.set(the enclosing loopnest induction variables, in loop-nest order) ordim_type.param(SymbolicIdx/ runtime params). The variable identity is positional, keyed by loopnest-IV order — never by a textual name. The names"sN","i","j"appear only in isl's human-readable string form ({ s0[i] : 0<=i<=10 }). One Penguin instruction ↔ one isl tuple"sN"whereN = inst.id. - Coefficient extraction. The per-term
int64 coeff(AffineExpr.terms[i].coeff@+8) becomes the islValmultiplied ontovar_on_domain; the scalar constantc(AffineExpr.c@+0x38) becomes aval_on_domainadded in. So flatc + Σ coeffᵢ·idxᵢmaps term-by-term onto islc + Σ coeffᵢ·(set|param dim). All ofval_on_domain/var_on_domain/add/scale/scale_down_val/mod_valare stock islpy ~2023.1.
affine_exp (generator) is the per-tensor-dimension sibling: it emits one isl.PwAff per access dimension over a LocalSpace (isl.LocalSpace.from_space), assembled via isl.MultiPwAff.from_pw_aff_list([...]). It feeds get_alloc_remapping's address affs.
Stage A.3 — the predicate side: five verbs, two native forms
AffinePredicate.so (prefix __pyx_pw_…_15AffinePredicate_*):
| Function | Offset | Size |
|---|---|---|
is_legal_predicate(preds) | 0x8980 | 0xA40 |
pred_lt(lhs, rhs) | 0x93c0 | 0xB50 |
pred_eq(lhs, rhs) | 0x9f10 | 0xB70 |
pred_ge(lhs, rhs) | 0xaa80 | 0xB70 |
pred_le(lhs, rhs) | 0xb5f0 | 0xB70 |
pred_gt(lhs, rhs) | 0xc160 | 0xB50 |
wrap_predicate(c_pred) | 0xccb0 | — |
The key finding (CONFIRMED, register-traced). All five comparison constructors normalise to exactly two native forms before building the predicate: e >= 0 and e == 0. There is exactly one comparison direction internally — ge — and it is a boolean keyword argument, not an operator name. (strings AffinePredicate.so | rg 'n_s_(eq|le|lt|gt|ne)' is empty; only n_s_ge exists. The interned ge is the only comparison-flag name in the whole module, resolved via _PyDict_GetItem_KnownHash as a kwarg key.) This is the classic Presburger normalisation: every isl constraint is e >= 0 (or e == 0), so the predicate layer pre-bakes that form and the isl glue drops it straight onto a Set with no per-comparator logic.
// each pred_XX(lhs, rhs) — op-shape IDENTICAL across all five:
// 1× PyNumber_Subtract [strict only: 1× PyInt_SubtractObjC($1)] 1× PyObject_Call 2× PyObject_RichCompare
PyObject *pred_XX(lhs, rhs) {
e = SUB(a, b); // operand order per verb (below)
if (STRICT) e = e - 1; // __Pyx_PyInt_SubtractObjC.constprop.0, imm $0x1
return AffinePredicate(e, ge=FLAG); // ge=True → e>=0 (inequality); ge=False → e==0 (equality)
}
The five verbs differ only in subtraction operand order, the -1 for the strict pair, and the ge bool:
| Verb | Builds | Meaning |
|---|---|---|
pred_ge(lhs,rhs) | AffinePredicate(lhs - rhs, ge=True) | lhs - rhs >= 0 |
pred_le(lhs,rhs) | AffinePredicate(rhs - lhs, ge=True) | rhs - lhs >= 0 (operands swapped) |
pred_gt(lhs,rhs) | AffinePredicate(lhs - rhs - 1, ge=True) | lhs - rhs >= 1 (strict, -1) |
pred_lt(lhs,rhs) | AffinePredicate(rhs - lhs - 1, ge=True) | rhs - lhs >= 1 (strict, swap + -1) |
pred_eq(lhs,rhs) | AffinePredicate(rhs - lhs, ge=False) | rhs - lhs == 0 (equality) |
Grounding (all measured this pass):
- Every
pred_*references the samegekwarg-name slot (mstate+0xc8) exactly once (5/5). pred_eqis the lone outlier on theTrue/Falseboolean-reference count — it passes the oppositegevalue (ge=False). The four inequality verbs carry thege=Truecount;pred_eqalone carriesge=False. This single discriminator is>= 0vs== 0.SubtractObjC(-1)appears only inpred_ltandpred_gt(1 each, 0 elsewhere) — the strict→non-strict>= 1rewrite:a < b ⟺ a-b ≤ -1 ⟺ b-a-1 ≥ 0.- The two
PyObject_RichCompareper function are constant-fold / triviality gates (isea constant of known sign → return a trivially true/false predicate); they gate, they don't change the lowering.
The pelican ICmpExpr it builds (AG10 kind 20, createICmpExpr @walrus 0x18f60f0, alloc 0x38, vtbl 0x90c290):
compare_op @+0x20 int (ICmpExpr::CmpPred) // ge=True → SGE ge=False → EQ
lhs @+0x28 RefPtr<Expr> // (the normalised e)
rhs @+0x30 RefPtr<Expr> // (the constant 0)
Because the Python layer pre-normalises, the AffinePredicate constructor reaches exactly two ICmpExpr forms: ICmp(SGE, e, 0) (four inequality verbs) and ICmp(EQ, e, 0) (pred_eq). The bool ge selects the CmpPred; no other CmpPred value is reachable from the Python predicate layer. ICmpExpr is never serialised (no toJson case) — it is a control/predicate atom consumed by InstCompareAndBranch::updateAffineExprs, never an address expr.
GOTCHA — the int that
gemaps to is not pinned. The mappingge=True → SGE,ge=False → EQis CONFIRMED. The concrete integer literals written toICmpExpr.compare_op@+0x20forSGEvsEQwould require theAffinePredicate.__init__body, which is apelican.somethod, not these wrapper.sofiles. Tagged INFERRED for the exact ordinals.
Stage A.3′ — is_legal_predicate — the validity gate
is_legal_predicate(preds) @0x8980 (STRONG) iterates the predicate list (loop var p, slot mstate+0x148) and per predicate fetches and tests two attributes (2× PyObject_GetAttr + 2× PyObject_IsTrue):
bool is_legal_predicate(preds) {
for (p in preds) {
e = p.expr; // slot mstate+0xb0 — the underlying affine expr
rt = p.has_runtime_value; // slot mstate+0xd0 — is the expr data-dependent?
if (!is_affine(e) || PyObject_IsTrue(rt)) // illegal if non-affine OR runtime-valued
return false; // -> cannot become an exact isl Presburger constraint
}
return true;
}
A predicate is legal ⟺ its expr is a genuine compile-time affine expression and has no runtime value (no IntRuntimeValue / IndirectArg / Opaque term) — only then can it be an exact isl constraint. Illegal predicates are flagged "Invalid Predicate!" (CONFIRMED string literal, raised by addPredicateExprsToInst when an illegal predicate reaches instruction attach). Legal-but-overapprox predicates are carried as is_approx and dropped by the isl simplifier before gist.
How a legal predicate becomes an isl constraint (Stage B, in in_predicate_domain / predicated_domain):
for (p in legal_preds) {
aff = build_aff(p.expr); // §Stage B — flat pelican → isl.Aff
set = p.ge ? aff.ge_set(zero) : aff.eq_set(zero); // stock islpy
domain = domain.intersect(set); // { sN[ivs] : ... and aff >= 0 }
}
Because Stage A guaranteed the >= 0 / == 0 form, the isl side needs only ge_set / eq_set — no per-comparator branching, no strict-inequality handling (already rewritten to >= 1 ⊂ >= 0).
Stage A.4 — wrap_expr / try_wrap_expr / expr_kind — the C-ptr ⇄ Python adopters
These let the Python layer hold a pelican::Expr* as a typed Python object.
expr_kind(expr)@0x24290(STRONG). Readsexpr.kind(the pelicanExprKind @+0x10); the body is dominated by the interned namekind(42 refs — the dispatch switch). This is the discriminatorwrap_exprandbuild_affread.wrap_expr(c_expr)@0x19d90(STRONG). The kind-dispatched adopter: reads the kind and instantiates the matching Python face (Expr/CExpr/AffineExpr/SumExpr/MultExpr/ModuloExpr/FloorDivExpr/CompoundExpr/CCExpr/CCDivExpr/CCModExpr/ICmpExpr). TwoGetModuleGlobalNameresolve the target class per kind, then construct.try_wrap_expr(x)@0x190e0(STRONG). Non-raising variant — returns the wrapped face orNoneifxis a plain int / non-expr.wrap_predicate(c_pred)@AffinePredicate.so 0xccb0(STRONG). Predicate-side analogue; importswrap_expr(CONFIRMED interned name) to wrap the predicate's inner expr.remove_const_term(expr)@0x1f500. Splits anAffineExprinto(Σ coeffᵢ·idxᵢ, c), dropping the constantc@+0x38— a helper for thee - rhsrewrites and for canonicalising an address's variable part separately from its offset.
Stage C — the round trip back: isl.Constraint → AffinePredicate
The return leg is enumerate_affine_predicates(constraints, cu, spmd_ids), in the C++ islwrapper::IntegerSetAnalysis base, invoked by IslSimplifier after gist / convex_hull (call site CONFIRMED). It is the exact inverse of Stage A in the canonical >= 0 basis.
// enumerate_affine_predicates(constraints, cu, spmd_ids) — INFERRED mechanics,
// grounded by the forward inverse + the AG10 factories
List<AffinePredicate> enumerate_affine_predicates(constraints, cu, spmd_ids) {
out = [];
for (c in constraints) { // domain.get_basic_sets()[0].get_constraints()
a0 = c.get_constant_val(); // a0 (stock islpy)
terms = [];
for (i in set_dims)
if ((ai = c.get_coefficient_val(dim_type.set, i))) // ai != 0
terms.push(MultExpr(cu.resolve_set(i), ai)); // kind 23, factory sub_62BAA0
for (j in param_dims)
if ((pj = c.get_coefficient_val(dim_type.param, j)))
terms.push(MultExpr(cu.resolve_param(spmd_ids, j), pj));
e = wrap_expr( SumExpr(terms) + a0 ); // kind 18 (sub_62C8D0) / AffineExpr kind 17 (sub_62BCF0)
out.push( c.is_equality() ? pred_eq(e, 0) // isl ==0 → AffinePredicate(e, ge=False)
: pred_ge(e, 0)); // isl >=0 → AffinePredicate(e, ge=True)
}
return out; // replaces inst's predicates (resetPredicates + addPredicate)
}
Each isl.Constraint is an affine a0 + Σ ai·xi {>= | ==} 0 over loop IVs xi (dim_type.set) and spmd_id params (dim_type.param). cu resolves each set-dim back to its loop AffineIdx / axis and each param-dim back to its SPMD parameter. Step 4 re-materialises coeff·idx terms into MultExpr/SumExpr — the inverse of the linearize_* fold — and step 5 wraps and builds the predicate with pred_ge / pred_eq. get_coefficient_val / get_constant_val / is_equality are stock islpy.
Why the inverse is clean. isl always returns constraints in … >= 0 / … == 0 normal form, which matches the Stage A canonical form exactly — that match is the whole reason Stage A normalises to ge. An isl >= 0 constraint maps to a single ge AffinePredicate with no sign gymnastics.
AffinePredicate(e, ge) --build_aff--> isl.Aff e --ge_set--> { e >= 0 }
{ e >= 0 } (post-gist) --get_constraints--> Constraint(a, a0) --enumerate--> AffinePredicate(e', ge)
where e' is e re-expressed in the (possibly fewer, gist-simplified) constraints. The pelican kinds used on the rebuild are AG10-confirmed: SumExpr=18, MultExpr=23, AffineExpr=17, ICmpExpr=20.
Reimplementation checklist
- Penguin
AffineExpris a hash-consed pelicanExprtree; flatten it toc + Σ coeffᵢ·AffineIdxᵢwithlinearize_affineexpr(one expr) /linearize_affineindices(a per-dim index vector), driving pelicanAffineExpr::flattenTerms/getLinearExpr. - For collectives,
cc_div/cc_modbuildCCDivExpr(27)=floor(rank/group_size)=shard-index andCCModExpr(28)=rank%group_size=intra-shard-offset, each carryingdenom>0+replica_groups_id. build_aff(C++islwrapper::IntegerSetAnalysis,libBIR.so) maps the flat form onto isl:CExpr→val_on_domain,SumKind→fold add,MultKind→scale,FloorDiv/CCDiv→scale_down_val,Modulo/CCMod→mod_val(CC pair = ceiling),AffineIdx→var_on_domainat a positionaldim_type.set|paramindex; one tuple"sN"per instruction.- Predicates:
pred_{ge,le,gt,lt,eq}normalise to just two native formse >= 0(ge=True) /e == 0(ge=False) —e = ±(lhs-rhs)with-1for the strict pair,ge=Falseonly forpred_eq— build anAffinePredicatewrappingICmpExpr(20, SGE|EQ, e, 0);is_legal_predicategates on affine ∧ ¬has_runtime_value(else"Invalid Predicate!").build_aff(e).ge_set(0)/eq_set(0)intersect them into the iteration domain. - After isl
gist/convex_hull,enumerate_affine_predicates(constraints, cu, spmd_ids)reads each constraint's coefficient/constant vector and re-materialisesSumExpr(18)+MultExpr(23)→wrap_expr→pred_ge/pred_eq— an exact inverse in the>= 0basis. islpy is stock ~2023.1 throughout; only the linearize / build_aff / predicate-normalise / enumerate glue is Neuron-authored.