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’s rel_tol (1e-4). Using rel_tol would 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 the 1/dmin scaling makes s*|D| >= 1 everywhere 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#

VerifyResult

Outcome of a feasibility verification.

DeclaredVariable

A frozen record of one declared variable: name, size, bounds and type.

DeclaredLogic

The declared disjunctive, indicator, SOS and logical constraints (#1659).

RepairResult

What repair_point() did. x is None when it declined, and

Functions#

jacobian_row_gradient_norms(→ numpy.ndarray)

max_j |J_ij| per row — the row gradient's sup-norm at the point.

improving_gradient_norms(→ numpy.ndarray)

Per-row gradient norm restricted to moves that can REDUCE the violation (#1284).

model_integer_mask(→ numpy.ndarray)

Flat mask of the model's INTEGER/BINARY columns.

evaluator_box(evaluator)

(lb, ub, integer_mask) for an evaluator, or None if it exposes no box.

feasible_distance_cap(→ numpy.ndarray)

Cap on a row's absolute violation: FEASIBLE_DISTANCE_TOL * ||grad g_i||_inf.

check_variable_bounds(→ VerifyResult)

Variable bounds + integrality against the ORIGINAL declared model.

snap_integer_columns(→ numpy.ndarray)

x with the columns in int_idx rounded to their nearest integer.

snap_integers(→ numpy.ndarray)

x_flat with every INTEGER/BINARY column rounded to its nearest integer.

jacobian_row_scales(→ numpy.ndarray)

max_j |J_ij| * |x_j| per row — the row's first-order term magnitude.

screen_jacobian(evaluator, x_flat)

The constraint Jacobian at x_flat: scipy CSR when the evaluator has one.

check_constraints(→ VerifyResult)

Every constraint row, enumerated from the evaluator's own row map.

max_constraint_violation(→ float)

Largest constraint-row violation of x_flat, each row over max(1, |rhs|).

declared_variables(→ tuple)

Snapshot model._variables as DeclaredVariable records.

has_declared_logic(→ bool)

True when model declares any non-algebraic constraint.

verify_point(→ VerifyResult)

Verify x_flat is feasible for model; optionally return its objective.

repair_point(→ RepairResult)

Project x_flat onto its own rows, in the units the model is written in (#1537 E).

incumbent_repair_enabled(→ bool)

DISCOPT_INCUMBENT_REPAIR (#1537 E): repair the published incumbent.

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 return inf, which feasible_distance_cap() turns into “no cap” — an unestimatable gradient must not manufacture a strict test.

A scipy sparse J is 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() takes max_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 + s with s in [0, 1] at s = 0 to the #1254 row gave ||grad||_inf = 1, the cap became 1e-4, and the point 0.87 away in y was certified again at z = -20 (true optimum -6.699).

Column j of row i counts for what a move of at most FEASIBLE_DISTANCE_TOL in x_j, inside [lb_j, ub_j], can remove from the violation: |J_ij| * min(1, room_ij / FEASIBLE_DISTANCE_TOL), where room_ij is the distance from x_j to 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 is round(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-width FEASIBLE_DISTANCE_TOL), so their contributions add, and FEASIBLE_DISTANCE_TOL * result is the first-order reduction the best such move achieves: the statement feasible_distance_cap() makes, now true of the point’s box.

Summed, not maxed (measured on clay0303hfsg): a big-M row x - 52.5 y <= 0 at x = 1.0e-8 (bound 0) and y = -1.8e-10 (binary) is violated by 1.9e-8, exactly what rounding y and moving x onto its bound together remove. A per-column max (or zero weight for y) 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 +1 when row i’s body must DECREASE, -1 when it must increase, 0 when the row is not violated (plain sup-norm; the cap is irrelevant there). Clipped to jacobian_row_gradient_norms(), so it can only tighten a gate. Non-finite rows return inf as there.

A scipy sparse J is evaluated on its stored entries only (#1619): an unstored J_ij is exactly 0, so its |J_ij| * frac term 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, or None if it exposes no box.

Unwraps the cut-augmenting (_ev) and bound-override (_evaluator) wrappers to reach the owning model for integrality. A None result 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*scale in _relax/primal_heuristics, a bare tol in solver._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 is violation / ||grad g||_inf, so requiring that distance to stay inside the repo’s declared rel=1e-4 is the same statement expressed where the user’s bounds and tolerances live — in variable space.

Measured (#1254). 10**y1 + 10**y2 <= 10**z with y in [-9,-6], z in [-20,0]: at y1=y2=-7, z=-20 the 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, and solve returned status="optimal", gap_certified=True at z = -20 where 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 <= t at x = 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 — yet dg/dx = 1, so the point is 6.7e-09 in y away from feasible and is exactly the kind of converged local point the absolute tolerance exists to accept (test_mpec_source_residuals.py asserts it must verify). Term magnitude cannot separate the two; distance separates them by nine orders.

Applied as a min against 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 with max lets the larger one erase the smaller. Measured on synthes3 via nlp_bb: the big-M row x0 - 10*y9 <= 0 at x0 = 1.0750640431770233e-12 (lower bound 0), y9 = 0 evaluates to 1.0751399770470016e-12 — the row body and its own linear term disagree by 7.6e-17, ordinary double-precision noise on a unit-scale evaluation. x0 has exactly 1.0750640431770233e-12 of 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 returned status="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.

objective is the point’s TRUE objective in model units (MAXIMIZE un-negated), or None when it was not requested or could not be computed. reason names 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 (tanksize x41 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#

x with the columns in int_idx rounded 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 no Model. See snap_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_flat with 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-6 for a binary is claiming z = 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 inside INT_TOL carry M * INT_TOL of 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 (round of 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 within INT_TOL of 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_point rejected the row at 9.276e-04 while the examiner reported [PASS] primal_con_feas (scaled) and _dual_recovery admitted 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) at x = 0) makes inf * 0 a NaN, and a NaN scale propagates into violation / row_scale as a NaN that compares False against every tolerance. Reported by the second review pass on #1157: the guard lived in _row_scales_and_gradients and 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) with scale=nan. Note the pre-#1151 floored form gave that row inf and 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_jacobian is 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 (see TapeNLPEvaluator.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 over max(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 pass verify_point can differ by orders of magnitude here, and the more violated one can buy an objective past every truly feasible point (#1496). Satisfied rows count as 0; a non-finite residual, or an evaluator that cannot produce every row its own map declares, is inf – 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 later verify_point() judges bounds and integrality on the variables the caller declared – not on whatever the solve has since appended (structure cuts add auxiliary columns to model._variables, #1561) or tightened in place.

name#
size#
shape#
lb#
ub#
var_type#
discopt.validation.feasibility.declared_variables(model) → tuple#

Snapshot model._variables as DeclaredVariable records.

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 / logical the rows the caller wrote inside those constraints were never judged: verify_point saw zero rows and passed any point in the box. Measured on the #1659 witness, a hull incumbent violating its selected disjunct’s row y >= exp(x) - 1 by 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_ONE projects to the union of its disjuncts; nested disjunctions recurse) – the "any" reading, used when the disjunction has no indicator columns, i.e. an either_or whose indicators were 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_disjunction blocks 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 model declares 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_flat is feasible for model; 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=True ONLY 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 yields ok=False — never an optimistic pass.

evaluator (#1561) supplies the rows instead of compiling them from model now. Model.solve passes 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 from model unless variables is given.

variables (#1561) is the declared variable set – a declared_variables() snapshot – that x_flat is laid out over and that bounds and integrality are judged on. A solve can append auxiliary columns to model._variables; judging the declared point against the extended list is a length mismatch, not a verdict. It requires evaluator (built on the same declared model), since the rows must match the columns.

logic (#1659) is a DeclaredLogic for the model’s disjunctive, indicator, SOS and logical constraints, which the evaluator does not compile. Without one it is built from model when the model declares any; with variables it 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. x is None when it declined, and reason then says why (for logs and solver_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_flat onto its own rows, in the units the model is written in (#1537 E).

Why. verify_point() allows a row ABS_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 floor 1 does 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_9 with rows x1e-3 certified x1 = -9.98e-6 against 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); ex1225 shifted by ~1e6 certified 30.9999959 against 31 on an equality residual of 2.1e-6 the shifted row’s term scale allowed ~1; syn05hfsg likewise, 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.

variables is a declared_variables() snapshot, as in verify_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(). =0 publishes 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. =0 is kept as an opt-out for A/B-ing the published point against the solver’s raw one, not as a parked graduation.