discopt.transformations#

Named model transformations: apply_to / create_using (#1479).

discopt’s reformulations are module functions the solver calls at fixed points of solve_model() (the GDP lowering, the exact integer and binary product linearizations, the MPEC complementarity lowerings). Each one was reachable only through a whole Model.solve() selected by a keyword or a DISCOPT_* flag, so the only way to look at what a reformulation does was a whole-solve A/B. This module gives each of them a name and a uniform entry point, so a transformation can be run on its own, on a copy, and diffed:

import discopt.transformations as dt

dt.available()                                  # registered names
hull = dt.create_using("gdp.hull", m)           # m is untouched
dt.apply_to("mpec.sos1", m, pairs=m._complementarities)   # m is changed

report = dt.check("integer.bilinear", m)        # copy + diff, no solve
report.diff.added_variables

What this module is not. It changes no transformation: every registered entry resolves, at call time, to the function it names on its module (so a test’s monkeypatch of that module attribute still takes effect). The solver’s call sites – every GDP lowering including the AMP route’s respect_disjunction_methods=False call, the binary/integer product passes, the MPEC and bilevel complementarity lowerings – dispatch through Transformation.apply(), which is the bare function call plus a check of its return type. The solver still decides when each runs and whether to adopt the result; the registry decides nothing. That dispatch was verified bound-neutral per CLAUDE.md §5 (identical status, objective, bound and node count against main on the #1479 panel). One entry is not a solver pass: core.relax_integrality (#1620, discopt.modeling._relax_integrality), a user-facing continuous relaxation that no solve path calls.

Two function contracts, one API. The wrapped functions come in two shapes:

  • functional – f(model, **options) -> Model: returns a new model (or model itself when there is nothing to do) and leaves the input alone. The new model shares the input’s Variable objects, which still report var.model is input.

  • in-place – f(model, **options) -> None: appends rows and variables to model.

apply_to gives both an in-place meaning and create_using gives both a copy meaning. For a functional transformation apply_to adopts the returned model’s state into the caller’s object and re-points the new auxiliaries’ .model at it, so afterwards every variable of the model reports that model as its owner – which the raw functional result does not. The adoption refuses (before touching anything) if the returned model is referenced by anything it does not know how to re-point.

After a transformation that changed the model, two records that describe the pre-transformation model are cleared: _source_nl_path (the solver would otherwise hand POUNCE the original .nl for a model that no longer matches it) and _last_solve_result.

Attributes#

Classes#

Transformation

One named transformation.

ModelDiff

What a transformation changed, by name and expression text.

TransformationReport

The outcome of check().

Functions#

model_fingerprint(→ tuple)

A hashable structural snapshot of model.

diff_models(→ ModelDiff)

Structural difference between two models (typically a model and its transform).

check(→ TransformationReport)

Apply one transformation to a copy of model and diff it, without a solve.

register(→ Transformation)

Register a transformation under name; a duplicate name is refused.

get(→ Transformation)

The registered transformation name, or a KeyError listing the names.

available(→ list[str])

Sorted names of every registered transformation.

apply_to(→ discopt.modeling.core.Model)

get(name).apply_to(model, **options): transform model in place.

create_using(→ discopt.modeling.core.Model)

get(name).create_using(model, **options): transform a copy.

Module Contents#

class discopt.transformations.Transformation#

One named transformation.

Variables:
  • name (str) – Registry key, dotted by family ("gdp.hull", "mpec.sos1").

  • target (str or callable) – The implementing function, or "module:attr" resolved on first use (so importing this module imports none of the reformulation layers).

  • style ({"functional", "in_place"}) – The implementing function’s contract; see the module docstring.

  • summary (str) – One line for available() listings.

  • exact (bool) – True when the transformation preserves the feasible set and optimum (an exact reformulation, possibly with auxiliary variables); False for a relaxation or regularization (Scholtes), whose optimum is only an approximation of the source model’s.

  • fixed_options (mapping) – Options this entry always passes ("gdp.hull" is "gdp" with method="hull"). Passing one of them again is refused.

name: str#
target: str | Callable[..., Any]#
style: Style#
summary: str#
exact: bool = True#
fixed_options: Mapping[str, Any]#
property function: Callable[..., Any]#

The implementing function (the object the solver itself calls).

apply(model: discopt.modeling.core.Model, **options: Any) → discopt.modeling.core.Model#

Run the function with its own contract; return the model it produced.

Functional: the function’s return value (possibly model itself). In-place: model, after the function mutated it. This is exactly the call a solver call site makes – no adoption, no invalidation.

apply_to(model: discopt.modeling.core.Model, **options: Any) → discopt.modeling.core.Model#

Transform model in place and return it.

create_using(model: discopt.modeling.core.Model, **options: Any) → discopt.modeling.core.Model#

Transform a copy of model and return the copy; model is untouched.

The model and the options are deep-copied together, so an option that names parts of the model (pairs= relations, a t= parameter) is mapped onto the copy’s objects rather than left pointing at the original.

discopt.transformations.model_fingerprint(model: discopt.modeling.core.Model) → tuple#

A hashable structural snapshot of model.

Covers the variables (name, type, shape, bounds), parameters (name, value), every constraint (type, name, exact expression text), the objective, the builder-resident linear rows and objective, and the complementarity records. Two models with equal fingerprints state the same problem in the same order; a fingerprint that changes across a call means the call mutated the model. Expressions are rendered by _canon, not the display repr: every float at full precision and every array constant by its full contents (or a SHA-256 of its bytes when large), so a change in the last digit of a coefficient or deep inside a large constant changes the fingerprint (#1497).

class discopt.transformations.ModelDiff#

What a transformation changed, by name and expression text.

Constraints are compared as multisets of their exact canonical text (see _canon: full-precision floats, full array contents), so a constraint that moved position is not reported, and one that was replaced by a textually identical copy (as every rebuilding pass does) is not either.

added_variables: tuple[str, ...]#
removed_variables: tuple[str, ...]#
rebounded_variables: tuple[str, ...]#
added_constraints: tuple[str, ...]#
removed_constraints: tuple[str, ...]#
objective_changed: bool#
property unchanged: bool#
discopt.transformations.diff_models(before: discopt.modeling.core.Model, after: discopt.modeling.core.Model) → ModelDiff#

Structural difference between two models (typically a model and its transform).

class discopt.transformations.TransformationReport#

The outcome of check().

name: str#
result: discopt.modeling.core.Model#
diff: ModelDiff#
discopt.transformations.check(name: str, model: discopt.modeling.core.Model, **options: Any) → TransformationReport#

Apply one transformation to a copy of model and diff it, without a solve.

Raises AssertionError if the input model changed – the one property create_using must never violate. The returned report carries the transformed copy (for a feasibility or bound experiment) and the ModelDiff against the input. This is the unit of work CLAUDE.md §5 verifies a reformulation with: apply it to a copy and compare, rather than A/B a whole solve behind a flag.

discopt.transformations.register(name: str, target: str | Callable[..., Any], *, style: Style, summary: str, exact: bool = True, fixed_options: Mapping[str, Any] | None = None) → Transformation#

Register a transformation under name; a duplicate name is refused.

discopt.transformations.get(name: str) → Transformation#

The registered transformation name, or a KeyError listing the names.

discopt.transformations.TransformationFactory#
discopt.transformations.available() → list[str]#

Sorted names of every registered transformation.

discopt.transformations.apply_to(name: str, model: discopt.modeling.core.Model, **options: Any) → discopt.modeling.core.Model#

get(name).apply_to(model, **options): transform model in place.

discopt.transformations.create_using(name: str, model: discopt.modeling.core.Model, **options: Any) → discopt.modeling.core.Model#

get(name).create_using(model, **options): transform a copy.