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
Supersedes the degree-axis position in ROADMAP.md and the "Quadratic / bilinear terms" row in Deliberate non-primitives. Touches #89 (which currently instructs "enforce the [aligned] restriction at lowering"), #31 (bounds as expressions), #38 (escape islands), #82 (value-only re-solve), Track 3's LaTeX rendering.
The claim: degree 2 should be statable in the language, with how it is solved declared in the file — and the case for it is not the one the roadmap currently argues against.
Why the current refusal does not hold as written
The roadmap gives two reasons. Neither survives contact.
1. "General bilinear coupling is a cross join with |terms|² rows." True of undeclared coupling and only that. Four shapes hide under "bilinear":
Form
Join
Rows
x[i] * y[i] (aligned)
equi-join on i
|terms| — diagonal Q
x[i] * y[i,j]
broadcast join
the same shape as any affine broadcast today
sum(x[i] * y[j] * a[i,j]), a a declared table
equi-join against a
nnz(a)
sum(x, over=i) * sum(y, over=j)
cross join
|i|×|j| — this is what the ban describes
Row 3 is the honest general-bilinear form, and it is pointwise: PolarsCompiler.parameter_join is already exactly that join. The axis is not aligned vs. bilinear, it is is the coupling declared as data? — the same principle as "topology is data, not structure" and as Track 1 item 2 (at() as the adjoint of group_sum).
Consequence for docs/benchmarks.md: "the diagonal argument dies with the aligned restriction" is correct, but the replacement bound is nnz(coupling), a declared-shape quantity — not |terms|². That sentence is currently carrying the word permanently and cannot.
2. Memory / whole-Hessian handoff. Arguing on the wrong axis. Quadratic constraints do not appear in large models — spatial branch-and-bound caps them well below the scale this engine is built for. What binds is the solve, not the build. At QCQP scale passHessian taking Q whole is free, a non-diagonal Q is free, and the PSD convexity guard that would be infeasible at 10⁷ columns is a dense Cholesky in milliseconds.
The argument that actually decides it
The workaround is already expressible, and that is the problem. Fix y to its previous-iteration value and x * y_fixed is variable × parameter — degree 1, sayable today (lowering.py:210). Successive substitution needs no new language.
What is blocked is stating the model. A pre-linearised YAML encodes one iteration of a solution method, not the math. It cannot be checked as the model it represents, cannot render to LaTeX as the math it claims (Track 3), cannot be port-verified against a published optimum — the optimum is for x*y — and is not self-contained without a driver loop around it. Against a thesis of "the product is the declarative math" and hard rule 5, that is the real cost.
Separate gap surfaced by the same path: #82 does not cover successive linearisation. A changing linearisation coefficient rewrites A and needs changeCoeff; Track 2c as written is changeColsBounds / changeRowsBounds. Worth noting on #82 either way.
Proposed reframe
Degree 2 becomes statable in the core AST and plan. The lowering is a declared choice, and the default is a formulation, not a new sink stream.
McCormick envelopes as the opt-in lowering. For w = x·y with finite bounds on both factors:
w >= xL*y + x*yL - xL*yL w <= xU*y + x*yL - xU*yL
w >= xU*y + x*yU - xU*yU w <= xL*y + x*yU - xL*yU
Every coefficient is a bound times a variable — degree 1. It emits one new variable and four constraints, which is precisely the piecewise.py shape: a formulation that expands to ordinary declarations before dispatch. #31 (bounds as expressions) is the prerequisite it needs.
Three consequences, all load-bearing:
It lands on every sink that ships today. LP file, HiGHS direct. No Gurobi, no spatial branch-and-bound.
The oracle comes back. linopy refuses quadratic constraints outright — verified against linopy 0.8.0.post1.dev140: m.add_constraints(x*y <= 5) raises NotImplementedError: Quadratic expressions cannot be used in constraints. But McCormick output is affine declarations, so the differential suite covers the relaxed path exactly. The oracle only goes dark on the native-Gurobi path.
One file, two lowerings. Track 4's three-valued entry (native / reformulated / absent) is already the mechanism — this is the first real user of reformulated.
This inverts #89's sequencing note. That issue lists native quadratic as the taxed path needing "a new IR node, a lowering case, the label-ordering change and a data-time convexity guard". Under this reframe the native Gurobi handoff is the optimisation, reached last, and the label-ordering change to _label_frame — the one place ARCHITECTURE.md says order is load-bearing — is deferred to that final gate rather than being a prerequisite.
Opt-in, and where the switch lives
Quadratic is not on by default, and the authorisation belongs in the file.
Not a Python-side flag on build/solve: that is hard rule 4 in substance. Identical YAML plus identical data would return an optimum in one session and a bound in another, decided by caller state — and the kind of answer is part of the meaning. Not a grammar toggle either: that creates a dialect, which rule 3 exists to prevent.
Instead, piecewise:'s precedent — the relaxation is a declared block, written by the author, travelling with the model. That gives:
Default is refuse, not relax.x*y on an affine sink with no declared relaxation is a LanguageError, and it is already Sink capabilities: declared capability classes + optional check(model, sink=...) #89's refusal contract: name the construct, the sink, and the sinks that do take it. Extended one step: "this sink cannot take degree 2 natively; declare a McCormick relaxation to lower it affinely, or use a sink that takes it natively." The safe default falls out of machinery already designed — no permission flag is needed, and an error that hands you the block to paste is strictly better than a flag that grants permission.
Exact-vs-relaxed becomes statically decidable. Read the file, know whether you are getting an optimum or a bound — no data, no sink, no runtime state.
Rule 4 holds, because the opt-in travelled with the model.
The distinction that makes this work, and that should be written down: a declared solution strategy is not the same artifact as pre-linearised math. The x*y stays in the expression, so LaTeX renders the true math, ports verify against the published optimum, and check validates the model the author meant. The relaxation block is additive metadata about how to solve — not a rewrite of what is stated. That is exactly what the x * y_fixed workaround gets wrong, and it is why opt-in here does not reintroduce the problem this reframe exists to solve.
check may take parameters; build and solve may not
check is a query — it changes what you are asking about the file, not what the file means. That is why #89's optional sink= was safe, and the same reasoning extends to any further argument here. build/solve produce a number, so the same argument there is the rule-4 problem above.
The more valuable change is check's return type, not its arguments. With exact-vs-relaxed in play, a boolean or a raise is the wrong shape: what a caller wants is a per-sink verdict — exact / relaxed / absent — decidable with no data bound. The report is the answer to "will I get an optimum or a bound, and where", it is CI-checkable for a repository of models, and it is Track 2b-shaped (the operational surface, the 3am question).
What this forces, and what cannot be retrofitted
McCormick is a relaxation, not an equivalence. It returns a bound; piecewise-McCormick tightens without closing it. Nothing in the language today has this property — piecewise: {convex: true} is exact, the λ-formulation is exact, integrality is exact. A model that hands back a relaxation's objective as if it were the optimum is a bug:silent shipped by design.
Declared-in-file opt-in removes most of that risk at authoring time, but not all of it: the result still has to say which it is. Result carries whether the objective is exact or a bound, and on which side the bound lies. This is the only piece that cannot be added later without breaking someone who already trusted a number, and it should be settled before any plumbing.
Hard rule 3 needs a sentence it does not have.#89 established that naming an alternative sink is not the lane redirection rule 3 forbids. But SOS is a capability difference — a sink takes it or does not. Exact-vs-relaxed is a semantic one, where both sinks succeed and return different kinds of number. The architecture accommodates it; the written rule does not describe it.
What survives from the old refusal
Undeclared outer products (sum(x, over=i) * sum(y, over=j)) stay excluded — now for the honest reason (the coupling is not declared as data), not as a blanket ban on degree 2.
Escape islands still cannot lift degree. Unchanged: an island returns affine COO rows, and that refusal stands on what it emits.
QP ∧ MILP on HiGHS is still real and still the motivating case for Track 4's conjunction-exclusion axis.
Decisions to make
What is the target shape? A small standalone nonconvex model, or a small nonconvex core embedded in a large LP (a 10⁷-variable dispatch model with a few hundred bilinear blending or head×flow constraints)? These have different answers — for the first, the build is free and there is little reason to reach for this engine; for the second, "the quadratic part is small" stops protecting anything once it is welded to a large relaxation. This decides whether the deliverable is a language construct or a decomposition pattern, and should be answered before the rest.
Adopt the reframe? Degree 2 statable in the AST/plan, with a declared relaxation block as the opt-in lowering and native quadratic as a sink capability.
Granularity of the relaxation block.Per model is one flag and minimal friction, but says nothing about which terms were relaxed in a model with several. Per block or per term is the piecewise: shape and has somewhere to put piecewise-McCormick segment counts, which are a real tightening knob and inherently per-term. Leaning per-block; genuinely a trade, and it should be decided before the contract is written.
Shape of the exact/relaxed contract on Result, and of check's per-sink verdict report.
Partly closed by the opt-in design: whether "bounded extent, enforced" is a second admission route alongside the ceiling. If the relaxation is declared per block, the extent of the quadratic part is declared with it — so "small" is a property of the model, checkable at load, rather than an assumption about users. That is the same move as #82's "'value-only' has to be a property of the model, not of the user's intent", and it means no separate label-budget mechanism is needed for this construct. The general question — what admits a construct that is not streamable but is intrinsically small — is still open and probably wants its own issue.
Gurobi solver_direct, then the native quadratic handoff + _label_frame ordering change
Quadratic objectives can ride in anywhere after step 3 and no longer need to go first.
Implementation risk, noted in advance
_has_var (lowering.py:298) is the entire degree gate. One function, one match arm; the plan only documents affineness in docstrings and the compiler never re-derives it. That is what makes this reframe cheap — and also means nothing downstream would catch a degree-2 expression arriving by another route. If this lands, the guard wants a matching assertion at the plan boundary, or the invariant lives in exactly one place with no backstop.
TermFragment gains a second label column on the native path. keyed, label_dims, survives_dropping and _needs_aggregate all need re-deriving for pairs. This is the subtlest code in compiler.py, and _build_objective's own warning applies doubly: a missed aggregate is a wrong answer that still solves.
_label_frame ordering. Quadratic columns first so HiGHS's dim_ < num_col bounds the Hessian — a change to the one contract ARCHITECTURE.md calls reproducibility-critical. Final gate only.
Doc changes on resolution
ROADMAP.md — the degree-axis section; the "Quadratic / bilinear terms" row in Deliberate non-primitives
ARCHITECTURE.md — the capability-bounded row in the ceiling table; a hard-rule-3 sentence separating capability differences from semantic ones
CLAUDE.md — the triage line still reads degree 1 ∩ relational ∩ local
docs/benchmarks.md — the "diagonal argument dies" sentence; replacement bound is nnz(coupling)
SPEC.md §5/§7 if a construct lands
(Minor: #89's body refers to ir.Program; the module is relational/plan.py and the type is plan.Program.)
Supersedes the degree-axis position in
ROADMAP.mdand the "Quadratic / bilinear terms" row in Deliberate non-primitives. Touches #89 (which currently instructs "enforce the [aligned] restriction at lowering"), #31 (bounds as expressions), #38 (escape islands), #82 (value-only re-solve), Track 3's LaTeX rendering.The claim: degree 2 should be statable in the language, with how it is solved declared in the file — and the case for it is not the one the roadmap currently argues against.
Why the current refusal does not hold as written
The roadmap gives two reasons. Neither survives contact.
1. "General bilinear coupling is a cross join with |terms|² rows." True of undeclared coupling and only that. Four shapes hide under "bilinear":
x[i] * y[i](aligned)ix[i] * y[i,j]sum(x[i] * y[j] * a[i,j]),aa declared tableasum(x, over=i) * sum(y, over=j)Row 3 is the honest general-bilinear form, and it is pointwise:
PolarsCompiler.parameter_joinis already exactly that join. The axis is not aligned vs. bilinear, it is is the coupling declared as data? — the same principle as "topology is data, not structure" and as Track 1 item 2 (at()as the adjoint ofgroup_sum).Consequence for
docs/benchmarks.md: "the diagonal argument dies with the aligned restriction" is correct, but the replacement bound is nnz(coupling), a declared-shape quantity — not |terms|². That sentence is currently carrying the word permanently and cannot.2. Memory / whole-Hessian handoff. Arguing on the wrong axis. Quadratic constraints do not appear in large models — spatial branch-and-bound caps them well below the scale this engine is built for. What binds is the solve, not the build. At QCQP scale
passHessiantaking Q whole is free, a non-diagonal Q is free, and the PSD convexity guard that would be infeasible at 10⁷ columns is a dense Cholesky in milliseconds.The argument that actually decides it
The workaround is already expressible, and that is the problem. Fix
yto its previous-iteration value andx * y_fixedis variable × parameter — degree 1, sayable today (lowering.py:210). Successive substitution needs no new language.What is blocked is stating the model. A pre-linearised YAML encodes one iteration of a solution method, not the math. It cannot be
checked as the model it represents, cannot render to LaTeX as the math it claims (Track 3), cannot be port-verified against a published optimum — the optimum is forx*y— and is not self-contained without a driver loop around it. Against a thesis of "the product is the declarative math" and hard rule 5, that is the real cost.Separate gap surfaced by the same path: #82 does not cover successive linearisation. A changing linearisation coefficient rewrites
Aand needschangeCoeff; Track 2c as written ischangeColsBounds/changeRowsBounds. Worth noting on #82 either way.Proposed reframe
Degree 2 becomes statable in the core AST and plan. The lowering is a declared choice, and the default is a formulation, not a new sink stream.
McCormick envelopes as the opt-in lowering. For
w = x·ywith finite bounds on both factors:Every coefficient is a bound times a variable — degree 1. It emits one new variable and four constraints, which is precisely the
piecewise.pyshape: a formulation that expands to ordinary declarations before dispatch. #31 (bounds as expressions) is the prerequisite it needs.Three consequences, all load-bearing:
linopy 0.8.0.post1.dev140:m.add_constraints(x*y <= 5)raisesNotImplementedError: Quadratic expressions cannot be used in constraints. But McCormick output is affine declarations, so the differential suite covers the relaxed path exactly. The oracle only goes dark on the native-Gurobi path.native/reformulated/absent) is already the mechanism — this is the first real user ofreformulated.This inverts #89's sequencing note. That issue lists native quadratic as the taxed path needing "a new IR node, a lowering case, the label-ordering change and a data-time convexity guard". Under this reframe the native Gurobi handoff is the optimisation, reached last, and the label-ordering change to
_label_frame— the one placeARCHITECTURE.mdsays order is load-bearing — is deferred to that final gate rather than being a prerequisite.Opt-in, and where the switch lives
Quadratic is not on by default, and the authorisation belongs in the file.
Not a Python-side flag on
build/solve: that is hard rule 4 in substance. Identical YAML plus identical data would return an optimum in one session and a bound in another, decided by caller state — and the kind of answer is part of the meaning. Not a grammar toggle either: that creates a dialect, which rule 3 exists to prevent.Instead,
piecewise:'s precedent — the relaxation is a declared block, written by the author, travelling with the model. That gives:x*yon an affine sink with no declared relaxation is aLanguageError, and it is already Sink capabilities: declared capability classes + optionalcheck(model, sink=...)#89's refusal contract: name the construct, the sink, and the sinks that do take it. Extended one step: "this sink cannot take degree 2 natively; declare a McCormick relaxation to lower it affinely, or use a sink that takes it natively." The safe default falls out of machinery already designed — no permission flag is needed, and an error that hands you the block to paste is strictly better than a flag that grants permission.The distinction that makes this work, and that should be written down: a declared solution strategy is not the same artifact as pre-linearised math. The
x*ystays in the expression, so LaTeX renders the true math, ports verify against the published optimum, andcheckvalidates the model the author meant. The relaxation block is additive metadata about how to solve — not a rewrite of what is stated. That is exactly what thex * y_fixedworkaround gets wrong, and it is why opt-in here does not reintroduce the problem this reframe exists to solve.checkmay take parameters;buildandsolvemay notcheckis a query — it changes what you are asking about the file, not what the file means. That is why #89's optionalsink=was safe, and the same reasoning extends to any further argument here.build/solveproduce a number, so the same argument there is the rule-4 problem above.The more valuable change is
check's return type, not its arguments. With exact-vs-relaxed in play, a boolean or a raise is the wrong shape: what a caller wants is a per-sink verdict —exact/relaxed/absent— decidable with no data bound. The report is the answer to "will I get an optimum or a bound, and where", it is CI-checkable for a repository of models, and it is Track 2b-shaped (the operational surface, the 3am question).What this forces, and what cannot be retrofitted
McCormick is a relaxation, not an equivalence. It returns a bound; piecewise-McCormick tightens without closing it. Nothing in the language today has this property —
piecewise: {convex: true}is exact, the λ-formulation is exact, integrality is exact. A model that hands back a relaxation's objective as if it were the optimum is abug:silentshipped by design.Declared-in-file opt-in removes most of that risk at authoring time, but not all of it: the result still has to say which it is.
Resultcarries whether the objective is exact or a bound, and on which side the bound lies. This is the only piece that cannot be added later without breaking someone who already trusted a number, and it should be settled before any plumbing.Hard rule 3 needs a sentence it does not have. #89 established that naming an alternative sink is not the lane redirection rule 3 forbids. But SOS is a capability difference — a sink takes it or does not. Exact-vs-relaxed is a semantic one, where both sinks succeed and return different kinds of number. The architecture accommodates it; the written rule does not describe it.
What survives from the old refusal
sum(x, over=i) * sum(y, over=j)) stay excluded — now for the honest reason (the coupling is not declared as data), not as a blanket ban on degree 2.Decisions to make
piecewise:shape and has somewhere to put piecewise-McCormick segment counts, which are a real tightening knob and inherently per-term. Leaning per-block; genuinely a trade, and it should be decided before the contract is written.Result, and ofcheck's per-sink verdict report.Partly closed by the opt-in design: whether "bounded extent, enforced" is a second admission route alongside the ceiling. If the relaxation is declared per block, the extent of the quadratic part is declared with it — so "small" is a property of the model, checkable at load, rather than an assumption about users. That is the same move as #82's "'value-only' has to be a property of the model, not of the user's intent", and it means no separate label-budget mechanism is needed for this construct. The general question — what admits a construct that is not streamable but is intrinsically small — is still open and probably wants its own issue.
If adopted — sequencing
checkverdict reportpiecewise:nativevsreformulatedwiring (Sink capabilities: declared capability classes + optionalcheck(model, sink=...)#89)solver_direct, then the native quadratic handoff +_label_frameordering changeQuadratic objectives can ride in anywhere after step 3 and no longer need to go first.
Implementation risk, noted in advance
_has_var(lowering.py:298) is the entire degree gate. One function, one match arm; the plan only documents affineness in docstrings and the compiler never re-derives it. That is what makes this reframe cheap — and also means nothing downstream would catch a degree-2 expression arriving by another route. If this lands, the guard wants a matching assertion at the plan boundary, or the invariant lives in exactly one place with no backstop.TermFragmentgains a second label column on the native path.keyed,label_dims,survives_droppingand_needs_aggregateall need re-deriving for pairs. This is the subtlest code incompiler.py, and_build_objective's own warning applies doubly: a missed aggregate is a wrong answer that still solves._label_frameordering. Quadratic columns first so HiGHS'sdim_ < num_colbounds the Hessian — a change to the one contractARCHITECTURE.mdcalls reproducibility-critical. Final gate only.Doc changes on resolution
ROADMAP.md— the degree-axis section; the "Quadratic / bilinear terms" row in Deliberate non-primitivesARCHITECTURE.md— the capability-bounded row in the ceiling table; a hard-rule-3 sentence separating capability differences from semantic onesCLAUDE.md— the triage line still readsdegree 1 ∩ relational ∩ localdocs/benchmarks.md— the "diagonal argument dies" sentence; replacement bound is nnz(coupling)SPEC.md§5/§7 if a construct lands(Minor: #89's body refers to
ir.Program; the module isrelational/plan.pyand the type isplan.Program.)