Skip to content

Degree 2: state the math, choose the lowering — and the exact/relaxed contract it forces #261

Description

@FBumann

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.

If adopted — sequencing

  1. Bounds as expressions: signs and bidirectional flows #31 bounds as expressions (McCormick needs finite bounds on both factors)
  2. exact/relaxed result contract + check verdict report
  3. degree-2 plan node + declared McCormick block, expanded pre-dispatch like piecewise:
  4. Track 4 native vs reformulated wiring (Sink capabilities: declared capability classes + optional check(model, sink=...) #89)
  5. 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.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:engineLowering, IR, relational executor, the sink contractarea:languageSchema, grammar, AST, macros, primitivesdecisionA question to be answered, not work to be done; closes by resolution

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions