discopt.validation.feasibility#
The single incumbent-feasibility verifier (#908).
Every solver path that promotes a point to an incumbent — the native kernel’s cutoff seed, the convex kernel’s incumbent guard — must agree on what “feasible” means, and must be right. Before this module there were two independent implementations and both were wrong in the wrongly-accept direction.
What went wrong, and why the fix is shaped this way#
Both verifiers advanced one row index per Constraint
object while NLPEvaluator emits one row
per flat element. A constraint body may be array-valued — x <= 1 on a
3-vector is one Constraint and three rows — so on any model with a vector
constraint the two streams desynchronise and every check from that point on reads
the wrong row. Measured on a purpose-built corpus: a point violating row 2 of a
size-3 vector constraint by 5.0 was reported feasible by both verifiers, and
4 of 5 constructed-infeasible points were wrongly accepted.
The fix is not to patch the arithmetic. Rows are enumerated from
NLPEvaluator.constraint_row_map(), the evaluator’s own map, so the
misalignment class is structurally impossible: the same
_source_constraints / _constraint_flat_sizes that build the compiled
concatenation also drive the verification, and they cannot drift.
That row map also closes a second hole for free. The evaluator’s row set is
model._constraints plus model._builder_linear_constraints() (#840);
a verifier reading only _constraints never examined the builder-resident
linear rows at all — so a model built through add_linear_constraints or the
constraint(fast=True) path had those rows silently unchecked.
Tolerance#
The old form was abs_tol + rel_tol * |residual|, which is self-referential:
it scales with the residual rather than with the row. On an equality row it
collapses to a flat 1e-6 no matter whether the row’s natural magnitude is 1
or 10,782, so the rel_tol term is arithmetically dead and the check is
scale-blind. Measured consequence in the other direction: nvs22’s incumbent
was rejected at a relative residual of 8.1e-9 (absolute 1.71e-5 against a row
value of 2121.64).
This module keys the tolerance on the row’s scale:
violation_i <= abs_tol * max(1, |rhs_i|, max_j |J_ij| * |x_j|)
Two properties of that form are load-bearing and easy to get wrong:
The relative coefficient is
abs_tol(1e-6), not the repo’srel_tol(1e-4). Usingrel_tolwould loosen every unit-scale row 100x.The Jacobian term is a row-scale estimate, not a slack. Dropping it makes the test stricter, never looser — which is why the fallback below is sound.
That form still asks only “is the residual small”. #1254 is what the missing
half costs: on a row whose every partial derivative is ~2e-7, a residual of 2e-7
is inside every absolute tolerance in the solver while the nearest point that
actually satisfies the row is a third of a variable’s box away — and the point
was accepted, and certified optimal, at the wrong optimum.
feasible_distance_cap() adds the missing half — the first-order distance
from the point to the row’s surface must also stay inside 1e-4 — applied as a
min against the form above. Inert unless the row is nearly flat in every
variable. See that function for the measurement, and for why the row’s term
magnitude cannot serve here.
test_vector_constraint_corpus.py carries a control for each naive widening of
this form, showing that each one accepts a bad point this form rejects.
Why the row scale is |J_ij| * |x_j| and not |J_ij| * max(1, |x_j|) (#1151)#
|J_ij| * |x_j| is the first-order magnitude of the row’s j-th term. Flooring
|x_j| at 1 does not make it a larger term — it makes it not a term magnitude
at all, over-estimating the row’s scale by 1/|x_j| on every column whose
value is below 1. The tolerance then grows without bound as a variable shrinks,
which is the exact amplification the reformulation layer works to prevent:
_clear_divisions (_relax/factorable_reform.py) multiplies a cleared
quotient row by 1/dmin precisely so that a fixed absolute residual test on
w*D - N == 0 bounds the error in w; the floored scale divided that
scaling straight back out, leaving the check exactly as loose as if the row had
never been scaled.
Measured consequence (#1151), minimize x/y + y/x over [1e-3, 1e3]^2 whose
global minimum is exactly 2 by AM-GM. The reformulation emits
1000*(w0*y - x) == 0; at the accepted point x = y ~ 1.4e-3 that row is
violated by 9.28e-4 and was accepted, because max_j |J_ij| * max(1,|x_j|)
read the row’s scale as 1000 (the coefficient on x) rather than 1.4 (the
magnitude 1000*x actually attains), licensing a tolerance of 1e-3. The
residual maps to an error of residual / (1000*y) in w0, so the solver
reported objective = 1.9987 — below the global minimum, at
status=optimal, a false certificate. With the term-magnitude scale the row’s
tolerance is 1.4e-6 and the point is rejected.
The guarantee the term-magnitude form buys, for a defining row
s*(w*D - N) == 0 (s any positive scaling; 1/dmin as emitted here).
The row’s derivative in w is s*D, so the term-magnitude scale obeys
S >= s*|D|*|w|, and for a monomial denominator every other column’s term has
that same magnitude, so S ~ s*|D|*|w|. With |Δw| = |w*D - N| / |D| and a
residual held to abs_tol * max(1, S):
when
s*|D|*|w| >= 1:|Δw| <= abs_tol * s*|D|*|w| / (s*|D|) = abs_tol*|w|;otherwise:
|Δw| <= abs_tol / (s*|D|) <= abs_tol, since the1/dminscaling makess*|D| >= 1everywhere in the box.
So |Δw| <= abs_tol * max(1, |w|) — a bound on the aux value, keyed on the
aux’s own magnitude and on nothing about the denominator, which is exactly what
the reported objective (linear in w) needs. Under the floored form S is
instead ~ s*|D|*max(1,|w|) divided by nothing at all — it reads s itself
when s*|D| > s*|D|*|w| — and the same algebra leaves |Δw| proportional to
1/|D|, unbounded as the denominator shrinks. That 1/D law is what the issue
measured: |Δ objective| x denominator flat at ~1.9e-6 across box floors.
The change is narrow by construction: |x_j| <= max(1, |x_j|) columnwise, so
the new scale never exceeds the old one and the two differ only when the column
attaining the floored maximum carries |x_j| < 1. It moves only in the strict
direction — the accepted set shrinks — so no point this verifier now accepts was
rejected before.
Attributes#
Classes#
Outcome of a feasibility verification. |
|
A frozen record of one declared variable: name, size, bounds and type. |
|
The declared disjunctive, indicator, SOS and logical constraints (#1659). |
|
What |
Functions#
|
|
|
Per-row gradient norm restricted to moves that can REDUCE the violation (#1284). |
|
Flat mask of the model's INTEGER/BINARY columns. |
|
|
|
Cap on a row's absolute violation: |
|
Variable bounds + integrality against the ORIGINAL declared model. |
|
|
|
|
|
|
|
The constraint Jacobian at |
|
Every constraint row, enumerated from the evaluator's own row map. |
|
Largest constraint-row violation of |
|
Snapshot |
|
True when |
|
Verify |
|
Project |
|
|
Module Contents#
- discopt.validation.feasibility.logger#
- discopt.validation.feasibility.ABS_TOL = 1e-06#
- discopt.validation.feasibility.INT_TOL = 1e-05#
- discopt.validation.feasibility.BOUND_REL_TOL = 0.0001#
- discopt.validation.feasibility.FEASIBLE_DISTANCE_TOL = 0.0001#
- discopt.validation.feasibility.SMALL_ROW_ABS_FLOOR = 1e-12#
- discopt.validation.feasibility.CANCELLATION_RTOL = 1e-09#
- discopt.validation.feasibility.jacobian_row_gradient_norms(J) numpy.ndarray#
max_j |J_ij|per row — the row gradient’s sup-norm at the point.Distinct from
jacobian_row_scales()(max_j |J_ij| * |x_j|, the row’s term MAGNITUDE) and used for a different question: not “how big is this row” but “how far must the point move to satisfy it”. Non-finite rows returninf, whichfeasible_distance_cap()turns into “no cap” — an unestimatable gradient must not manufacture a strict test.A scipy sparse
Jis accepted and never densified (#1619); unstored entries are exactly 0, which can neither raise a row’s max nor make it non-finite, so the answer is the dense one.
- discopt.validation.feasibility.improving_gradient_norms(J, x, lb, ub, direction, integer_mask=None) numpy.ndarray#
Per-row gradient norm restricted to moves that can REDUCE the violation (#1284).
jacobian_row_gradient_norms()takesmax_j |J_ij|over every column, so one column that cannot help — a variable sitting on the bound that blocks its improving direction, or an integer variable — sets the cap on its own. Measured (#1284): adding+ swiths in [0, 1]ats = 0to the #1254 row gave||grad||_inf = 1, the cap became 1e-4, and the point 0.87 away inywas certified again atz = -20(true optimum -6.699).Column
jof rowicounts for what a move of at mostFEASIBLE_DISTANCE_TOLinx_j, inside[lb_j, ub_j], can remove from the violation:|J_ij| * min(1, room_ij / FEASIBLE_DISTANCE_TOL), whereroom_ijis the distance fromx_jto the bound in the direction that reduces the violation. An integer column can move only to an integer, and the one integer within that distance isround(x_j): its room is|round(x_j) - x_j|when that lies in the improving direction, else 0. The columns move together (a box of half-widthFEASIBLE_DISTANCE_TOL), so their contributions add, andFEASIBLE_DISTANCE_TOL * resultis the first-order reduction the best such move achieves: the statementfeasible_distance_cap()makes, now true of the point’s box.Summed, not maxed (measured on clay0303hfsg): a big-M row
x - 52.5 y <= 0atx = 1.0e-8(bound 0) andy = -1.8e-10(binary) is violated by 1.9e-8, exactly what roundingyand movingxonto its bound together remove. A per-column max (or zero weight fory) put the cap at 1e-8, rejected that point and every node solution like it, and the solve certified 28862 against the recorded optimum 26669.direction[i]is+1when rowi’s body must DECREASE,-1when it must increase,0when the row is not violated (plain sup-norm; the cap is irrelevant there). Clipped tojacobian_row_gradient_norms(), so it can only tighten a gate. Non-finite rows returninfas there.A scipy sparse
Jis evaluated on its stored entries only (#1619): an unstoredJ_ijis exactly 0, so its|J_ij| * fracterm is 0 and it adds nothing to the row’s sum. The dense form built an(m, n)array per intermediate (sign, room, frac, contrib), which made the post-solve screen quadratic in the mesh on DAE models (#1619 B-12a).
- discopt.validation.feasibility.model_integer_mask(model) numpy.ndarray#
Flat mask of the model’s INTEGER/BINARY columns.
- discopt.validation.feasibility.evaluator_box(evaluator)#
(lb, ub, integer_mask)for an evaluator, orNoneif it exposes no box.Unwraps the cut-augmenting (
_ev) and bound-override (_evaluator) wrappers to reach the owning model for integrality. ANoneresult makes the callers fall back to the plain sup-norm, which is exactly the pre-#1284 behaviour.
- discopt.validation.feasibility.feasible_distance_cap(grad_norms, term_scale=None) numpy.ndarray#
Cap on a row’s absolute violation:
FEASIBLE_DISTANCE_TOL * ||grad g_i||_inf.Every feasibility gate in the solver compares a row’s violation against a tolerance that is ultimately absolute —
ABS_TOL * max(1, ...)here,tol + rtol*scalein_relax/primal_heuristics, a baretolinsolver._check_constraint_feasibility. A residual is not, on its own, a statement about the point: what makes an absolute residual tolerable is that a point carrying it sits within tolerance of a point that satisfies the row. The first-order distance to the row’s surface isviolation / ||grad g||_inf, so requiring that distance to stay inside the repo’s declaredrel=1e-4is the same statement expressed where the user’s bounds and tolerances live — in variable space.Measured (#1254).
10**y1 + 10**y2 <= 10**zwithy in [-9,-6],z in [-20,0]: aty1=y2=-7, z=-20the row is violated by 2.0e-07 and every partial derivative is ~2.3e-07 or smaller, so restoring feasibility needsΔy ~ 0.87— a THIRD of that variable’s whole box. Every gate accepted the point (2e-7 < 1e-6 < 1e-4), it became the incumbent, it met the root relaxation bound, andsolvereturnedstatus="optimal",gap_certified=Trueatz = -20where the true optimum is-6.69897. A false certificate, silent, from an absolute residual alone.Why the distance and not the row’s term magnitude, which is the other obvious way to read “relative to the row”. On the Scholtes-regularized MPEC row
x*y <= tatx = 1, y = 1.67e-08, t = 1e-08, the residual 6.7e-09 is 40 % of the row’s magnitude — relatively WORSE than #1254’s point — yetdg/dx = 1, so the point is 6.7e-09 inyaway from feasible and is exactly the kind of converged local point the absolute tolerance exists to accept (test_mpec_source_residuals.pyasserts it must verify). Term magnitude cannot separate the two; distance separates them by nine orders.Applied as a
minagainst whatever tolerance the calling gate already computes, so it can only ever REJECT a point a gate would have accepted. It is inert unless||grad g||_inf < 1e-2, i.e. on a row that is nearly flat in EVERY variable — where no small move fixes the residual and calling the point near-feasible is not a statement about anything.The noise floor is ADDED to the distance allowance, not
max-ed with it (#1392). A noise floor and an allowance answer different questions — “how small a residual can this arithmetic even resolve” and “how far may the point sit from the surface” — and the residual carries both, so combining them withmaxlets the larger one erase the smaller. Measured onsynthes3vianlp_bb: the big-M rowx0 - 10*y9 <= 0atx0 = 1.0750640431770233e-12(lower bound 0),y9 = 0evaluates to1.0751399770470016e-12— the row body and its own linear term disagree by 7.6e-17, ordinary double-precision noise on a unit-scale evaluation.x0has exactly1.0750640431770233e-12of room to its bound, so the distance allowance is that same number, 7.6e-17 BELOW the violation: the cap rejected the point over a shortfall four orders of magnitude under its own declared floor, and the whole solve returnedstatus="error"with a correct incumbent (68.00974056776073 against minlplib’s proven=opt=68.00974052) withheld. Adding the floor keeps every recorded rejection — #1254’s point is 2.0e-7 against an allowance of 2.3e-11, #770’s violations are 0.4-17.6 — since a 1e-12 addition cannot reach a violation nine orders above it.
- class discopt.validation.feasibility.VerifyResult#
Outcome of a feasibility verification.
objectiveis the point’s TRUE objective in model units (MAXIMIZE un-negated), orNonewhen it was not requested or could not be computed.reasonnames the first failing check — for logs, never for control flow.- ok: bool#
- objective: float | None = None#
- reason: str = ''#
- discopt.validation.feasibility.check_variable_bounds(model, x_flat: numpy.ndarray) VerifyResult#
Variable bounds + integrality against the ORIGINAL declared model.
Bounds use
abs_tol + rel_tol * |bound|. Unlike the old constraint tolerance this is keyed on the bound — a real scale — not on the residual, so it is not self-referential: a local NLP returns a bound-active variable a few ULPs off its bound, and on a large-magnitude bound (tanksizex41 lb=536) a 4e-6 absolute slack is 8e-9 relative, inside the regime the whole solver operates in.
- discopt.validation.feasibility.snap_integer_columns(x: numpy.ndarray, int_idx) numpy.ndarray#
xwith the columns inint_idxrounded to their nearest integer.The index-keyed primitive behind
snap_integers(), for the matrix paths (StdForm.int_idx, the MILP engine’s offset list) that have noModel. Seesnap_integers()for why every feasibility test on a point with integer columns has to run on the snapped vector.
- discopt.validation.feasibility.snap_integers(model, x_flat: numpy.ndarray) numpy.ndarray#
x_flatwith every INTEGER/BINARY column rounded to its nearest integer.A point is only ever claimed at its integral realisation: a solver that reports
z = 1e-6for a binary is claimingz = 0, and the value it reports for the objective is the value of the claim, not of the fractional point it happened to compute. Verifying the fractional point instead lets a column insideINT_TOLcarryM * INT_TOLof constraint slack — on a big-M row (x <= 1e7 z) that is 10 units of violation bought with 1e-6 of fractionality, enough to certify an optimal value below the true optimum.Snapping is a no-op on a genuinely integral point (
roundof an exact integer is that integer, bit for bit), so this only ever rejects points whose feasibility rests on fractionality the solver has already declared absent.Callers must run
check_variable_bounds()FIRST, which proves every such column is withinINT_TOLof an integer; the snap then moves each by at most that much.
- discopt.validation.feasibility.jacobian_row_scales(J: numpy.ndarray, x_flat: numpy.ndarray) numpy.ndarray#
max_j |J_ij| * |x_j|per row — the row’s first-order term magnitude.The one definition of this quantity. It is not private, because three call sites need it and #1151 was in part a consequence of there having been three copies: this module’s incumbent gate,
validation/examiner.py’s scaled primal-feasibility check, and_dual_recovery’s active-set test all carried the same expression written out by hand. Fixing one left the other two vouching for exactly the point the fixed one rejects — measured, on the #1151 witness:verify_pointrejected the row at 9.276e-04 while the examiner reported[PASS] primal_con_feas (scaled)and_dual_recoveryadmitted the row into its active set. Import this rather than re-deriving it.Not floored: callers apply their own floor (
max(1, |rhs_i|, …)), and flooring|x_j|inside the max is precisely the #1151 defect — see the module docstring. Returns zeros where every term of a row vanishes; the caller’s floor is what keeps such a row on the plain absolute tolerance.Non-finite rows return 0.0, i.e. the caller’s floor, i.e. the plain absolute tolerance — the STRICTEST answer, and the same direction
_row_scales_and_gradients()already takes when the Jacobian is unavailable. This is not hypothetical: an unbounded derivative at a variable pinned to zero (d/dx log(x)atx = 0) makesinf * 0a NaN, and a NaN scale propagates intoviolation / row_scaleas a NaN that compares False against every tolerance. Reported by the second review pass on #1157: the guard lived in_row_scales_and_gradientsand did not survive being factored out here, so the two consumers that call this directly emitted a numpy RuntimeWarning and a spurious[FAIL] primal_con_feas (scaled)withscale=nan. Note the pre-#1151 floored form gave that rowinfand so an INFINITE tolerance, passing it unconditionally; 0.0 keeps the safe direction while dropping the warning and the bogus detail line.
- discopt.validation.feasibility.screen_jacobian(evaluator, x_flat: numpy.ndarray)#
The constraint Jacobian at
x_flat: scipy CSR when the evaluator has one.#1619 B-12a: the dense
evaluate_jacobianis an(m, n)scatter of a natively sparse tape, so on a collocation model the post-solve screen was quadratic in the mesh (cProfile at nfe=2400: 4.8 s in this screen against 3.3 s for the solve itself). Every consumer below is sparse-aware, and the CSR form is the same matrix (seeTapeNLPEvaluator.evaluate_sparse_jacobian: its structure is the tape’s exact COO, so it is exactly the dense matrix).Only the tape evaluator is asked. The legacy JAX evaluator’s sparse form can take its pattern from a nonzero mask traced at one interior point, which is not guaranteed to cover an entry that vanishes there; the screen keeps that evaluator on its dense Jacobian rather than inherit the assumption.
- discopt.validation.feasibility.check_constraints(model, x_flat: numpy.ndarray, evaluator=None) VerifyResult#
Every constraint row, enumerated from the evaluator’s own row map.
- discopt.validation.feasibility.max_constraint_violation(model, x_flat, evaluator=None) float#
Largest constraint-row violation of
x_flat, each row overmax(1, |rhs|).A ranking measure, not a feasibility test:
verify_point()decides whether a point may be an incumbent, and this says how much of the tolerance that decision allowed the point to use. Two points that both passverify_pointcan differ by orders of magnitude here, and the more violated one can buy an objective past every truly feasible point (#1496). Satisfied rows count as0; a non-finite residual, or an evaluator that cannot produce every row its own map declares, isinf– the worst rank, never a pass. Variable bounds and integrality are not included; callers that rank points place them in the box first.
- class discopt.validation.feasibility.DeclaredVariable(v)#
A frozen record of one declared variable: name, size, bounds and type.
What
declared_variables()captures before a solve, so a laterverify_point()judges bounds and integrality on the variables the caller declared – not on whatever the solve has since appended (structure cuts add auxiliary columns tomodel._variables, #1561) or tightened in place.- name#
- size#
- shape#
- lb#
- ub#
- var_type#
- discopt.validation.feasibility.declared_variables(model) tuple#
Snapshot
model._variablesasDeclaredVariablerecords.
- class discopt.validation.feasibility.DeclaredLogic(model)#
The declared disjunctive, indicator, SOS and logical constraints (#1659).
The NLP evaluator compiles algebraic rows only, so on a model declared with
either_or/if_then/sos1/logicalthe rows the caller wrote inside those constraints were never judged:verify_pointsaw zero rows and passed any point in the box. Measured on the #1659 witness, a hull incumbent violating its selected disjunct’s rowy >= exp(x) - 1by 5.9e-5 – an objective 5.4e-5 below the true optimum – was certified, and the #1537 E repair skipped the model as “non-algebraic”.Construct once, BEFORE the solve, like the declared evaluator: the algebraic rows inside the logic are compiled now, so a later in-place rewrite of their expressions cannot change what is judged. Every row is judged by
check_constraints()’ own per-row test; the logic decides which rows must hold at the point’s integral realisation:a disjunction holds when at least one disjunct has every item holding (
SELECT_ONEprojects to the union of its disjuncts; nested disjunctions recurse) – the"any"reading, used when the disjunction has no indicator columns, i.e. aneither_orwhoseindicatorswere never read. Once they are read (#1775 G1.1) the point carries its own selection and the"sel"reading applies instead: every selected disjunct must hold. The two are deliberately different – an unread disjunction has no declared selection to check – so do not unify them. (add_disjunctionblocks are judged row by row on the indicator rule below, which is the"sel"reading per row);an indicator row must hold when the indicator equals its active value;
SOS1 allows one nonzero member, SOS2 two that are adjacent;
a logical constraint is evaluated on the booleans’ integral values.
- check(model, x_snapped) VerifyResult#
Verdict on the point’s integral realisation (see the class docstring).
- realised_evaluator(model, x_snapped, base_evaluator)#
base_evaluator’s rows plus the logic rows that must hold at the point.Each disjunction contributes the disjunct closest to holding (by the same per-row allowance
check()uses), each active indicator its row. SOS and logical constraints contribute no rows; a move that breaks one is caught by re-checking the moved point.
- discopt.validation.feasibility.has_declared_logic(model) bool#
True when
modeldeclares any non-algebraic constraint.
- discopt.validation.feasibility.verify_point(model, x_flat, *, with_objective: bool = False, evaluator=None, variables=None, logic: DeclaredLogic | None = None) VerifyResult#
Verify
x_flatis feasible formodel; optionally return its objective.The contract is strict, because callers use this to decide whether a value may seed an incumbent cutoff and an unverified seed poisons every downstream certificate: this returns
ok=TrueONLY when the evaluator successfully evaluated every constraint row and every residual, bound and integrality condition is within tolerance. Any evaluator failure, shape mismatch or non-finite value yieldsok=False— never an optimistic pass.evaluator(#1561) supplies the rows instead of compiling them frommodelnow.Model.solvepasses the evaluator it snapshotted BEFORE the solve, because presolve may rewrite the constraint DAG in place and root cuts may append rows; the rows judged are then the ones the caller declared. Bounds and integrality are read frommodelunlessvariablesis given.variables(#1561) is the declared variable set – adeclared_variables()snapshot – thatx_flatis laid out over and that bounds and integrality are judged on. A solve can append auxiliary columns tomodel._variables; judging the declared point against the extended list is a length mismatch, not a verdict. It requiresevaluator(built on the same declared model), since the rows must match the columns.logic(#1659) is aDeclaredLogicfor the model’s disjunctive, indicator, SOS and logical constraints, which the evaluator does not compile. Without one it is built frommodelwhen the model declares any; withvariablesit must be passed, built before the solve like the evaluator.
- discopt.validation.feasibility.REPAIR_MAX_ITER = 8#
- discopt.validation.feasibility.REPAIR_NOISE_ULPS = 16.0#
- discopt.validation.feasibility.REPAIR_MAX_DENSE = 4000000#
- class discopt.validation.feasibility.RepairResult#
What
repair_point()did.xisNonewhen it declined, andreasonthen says why (for logs andsolver_stats, never control flow).- x: numpy.ndarray | None#
- excess_before: float#
- excess_after: float#
- iterations: int#
- reason: str = ''#
- discopt.validation.feasibility.repair_point(model, x_flat, *, evaluator, variables=None) RepairResult#
Project
x_flatonto its own rows, in the units the model is written in (#1537 E).Why.
verify_point()allows a rowABS_TOL * max(1, |rhs|, max_j |J_ij x_j|), and that allowance is not invariant under a change of variables that leaves the model identical: the floor1does not scale with the row, and|x_j|grows with the distance of the origin. A solver’s own iterates carry residuals at its working accuracy (LP feasibility, NLP convergence), and the allowance lets them through as the published incumbent. Measured on the in-repo corpus (test_1537_invariance.py):ex14_1_9with rows x1e-3 certifiedx1 = -9.98e-6against a true minimum of ~0 (row violated 9.98e-9 in the solver’s units = 9.98e-6 in the original’s, allowed 1e-6 in each);ex1225shifted by ~1e6 certified 30.9999959 against 31 on an equality residual of 2.1e-6 the shifted row’s term scale allowed ~1;syn05hfsglikewise, 6.7e-6. Each is a point the solver’s tolerance exploits, and the objective it reports is bought with that exploitation.The fix is not a different tolerance – no fixed absolute allowance can be invariant under row scaling – but a cleaner point. Newton steps on the rows that are violated beyond float noise (plus every equality row, so a repair cannot break one) move the continuous columns by the minimum-norm correction, with integer columns snapped and frozen and every column clipped into its box. The minimum-norm step is itself invariant under both transforms: a row rescale scales both sides of
J dx = -r, a translation changes neither. The result is feasible to float noise in ANY units, so the caller’s verdict no longer depends on which coordinates the model was written in.Accepted only if no row ends more violated than it began (beyond noise) and the largest violation-beyond-noise strictly falls. The caller must still run
verify_point()on the result and re-evaluate the objective there: this function moves the point, it does not vouch for it.variablesis adeclared_variables()snapshot, as inverify_point(); the evaluator must be built on the same declared model.
- discopt.validation.feasibility.incumbent_repair_enabled() bool#
DISCOPT_INCUMBENT_REPAIR(#1537 E): repair the published incumbent.See
repair_point().=0publishes the solver’s point unrepaired.Default ON from introduction. The §5 panel (2026-10-02, the 66 in-repo MINLPLib instances, 20 s, arms interleaved) was cert-clean: incorrect 0 in both arms, certified 49 -> 49, no certificate lost or gained, 16 incumbents repaired with an objective shift of at most 2.4e-7.
=0is kept as an opt-out for A/B-ing the published point against the solver’s raw one, not as a parked graduation.