Saving and loading models#

You fit some parameters today, and tomorrow you want the same model back — on another machine, in another script, for a different analysis. That is a reusability problem in the [Wilkinson et al., 2016] sense: the artifact has to outlive the session that produced it, and it has to be readable by something other than the exact interpreter that wrote it.

discopt can already export to the standard exchange formats — AMPL .nl [Fourer et al., 2003], GAMS .gms, MPS [Murtagh and Saunders, 1983] and LP — and those are the right choice when the consumer is another solver. They are the wrong choice when the consumer is you, tomorrow:

  • .nl renames every variable positionally (x0, x1, …), so the names you modeled with are gone.

  • None of them carry the solution, so a fit and the model it belongs to end up in two files that drift apart.

  • They refuse the non-algebraic relations (indicator, SOS, disjunction) outright.

Model.save() and discopt.load() are the native round trip that keeps all three. The format is a single JSON document — optionally gzipped — chosen so that it carries no numpy format version and no Python version, stays readable from any language, and diffs in git.

import json

import numpy as np

import discopt
import discopt.modeling as dm
from discopt.serialize import dumps, loads

A first round trip#

Build a small model, save it, load it back.

m = dm.Model("reactor")
k = m.continuous("k", shape=(2,), lb=0.0, ub=10.0)
T = m.parameter("T", 350.0)
m.minimize(dm.sum((k - 1.5) ** 2) + T / 100.0)
m.subject_to(k[0] + k[1] <= 4.0)
m.subject_to(dm.exp(k[1]) <= 20.0)

m.save("reactor.dopt")
reloaded = discopt.load("reactor.dopt")

print("variables: ", [v.name for v in reloaded._variables])
print("parameters:", [(p.name, float(p.value)) for p in reloaded._parameters])
print("constraints:", reloaded.num_constraints)
variables:  ['k']
parameters: [('T', 350.0)]
constraints: 2

The names survive. Compare that with the .nl route, which is what you would have had to use before:

m.to_nl("reactor.nl")
via_nl = dm.from_nl("reactor.nl")
print("names after a .nl round trip:", [v.name for v in via_nl._variables])
names after a .nl round trip: ['x0', 'x1']

The motivating case: save a fit, reload it tomorrow#

Parameter estimation produces two things that belong together — a model and the values you fitted into it [Biegler, 2010]. Pass the SolveResult to save() and it travels inside the same document, so there is no second file to keep in sync.

Here is a small rate-constant fit against synthetic data.

t_data = np.linspace(0.1, 2.0, 12)
y_data = 3.0 * np.exp(-1.7 * t_data)          # true A = 3.0, lambda = 1.7

fit = dm.Model("decay_fit")
A = fit.continuous("A", lb=0.1, ub=10.0)
lam = fit.continuous("lam", lb=0.1, ub=5.0)
residuals = [(A * dm.exp(-lam * float(t)) - float(y)) ** 2 for t, y in zip(t_data, y_data)]
fit.minimize(sum(residuals[1:], residuals[0]))

result = fit.solve(time_limit=60)
print(f"status={result.status}  A={result.value(A):.6f}  lam={result.value(lam):.6f}")
status=optimal  A=3.000000  lam=1.700000
fit.save("decay_fit.dopt", result=result)

# ... tomorrow, in a different process ...
restored = discopt.load("decay_fit.dopt")

A2, lam2 = restored._variables[0], restored._variables[1]
print("fitted values came back:")
print(f"  A   = {restored.saved_result.value(A2)}")
print(f"  lam = {restored.saved_result.value(lam2)}")
print(f"  objective = {restored.saved_result.objective}")
print(f"  status    = {restored.saved_result.status}")
fitted values came back:
  A   = 3.0000000024714573
  lam = 1.7000000020126242
  objective = 5.214655376114126e-18
  status    = optimal

The values are recovered exactly, not to plotting precision:

print("A identical:  ", restored.saved_result.value(A2) == result.value(A))
print("lam identical:", restored.saved_result.value(lam2) == result.value(lam))
print("obj identical:", restored.saved_result.objective == result.objective)
A identical:   True
lam identical: True
obj identical: True

A model saved without a result reloads with saved_result is None, so you can always tell “no fit was stored” from “the fit was zero”.

fit.save("decay_bare.dopt")
print("saved_result:", discopt.load("decay_bare.dopt").saved_result)
saved_result: None

Verifying the round trip#

“The reloaded model gives the same objective” is a weak test — it passes while a coefficient is quietly dropped from a row that did not bind. Two stronger checks are used throughout discopt’s test suite, and you can run them on your own models.

1. The document is stable under reload. Writing, reading and re-writing must produce the same bytes. Anything dropped on read shows up immediately.

once = dumps(fit)
twice = dumps(loads(once))
print("document stable under reload:", once == twice)
document stable under reload: False

2. An independent writer sees the same model. Export .nl from the original and from the reloaded model and compare bytes. This walks the whole expression DAG through code that knows nothing about the serializer, so a lost coefficient or a reordered row cannot hide behind a matching optimum.

print("`.nl` byte-identical:", loads(dumps(fit)).to_nl() == fit.to_nl())
`.nl` byte-identical: True

Run both over real instances — here, models from the MINLPLib collection [Bussieck et al., 2003] that ship with discopt’s test data.

What is compared matters. The model payload must be byte-stable under loads/dumps; the whole document must not be, because provenance records when each document was written and which document it came from. So the check is on the payload, plus the .nl re-emission, plus the presence of the derived_from link the chain exists to preserve:

import json
from pathlib import Path

corpus = sorted(Path("../../python/tests/data/minlplib_nl").glob("*.nl"))
if not corpus:
    raise FileNotFoundError("MINLPLib .nl test data not found next to this notebook")

checked = 0
for path in corpus[:8]:
    original = dm.from_nl(str(path))
    doc = dumps(original)
    back = loads(doc)
    redoc = dumps(back)

    # The MODEL payload must be byte-stable. The whole document is not, and must
    # not be: `provenance` records when this document was written and what it was
    # derived from, so `dumps(loads(d)) == d` is a property the format
    # deliberately does not have (provenance.py: "created always describes the
    # document being written now -- it would be a lie otherwise").
    payload = {k: v for k, v in json.loads(doc).items() if k != "provenance"}
    repayload = {k: v for k, v in json.loads(redoc).items() if k != "provenance"}
    assert repayload == payload, f"{path.stem}: model payload not stable"

    # ...and the chain the provenance block exists to preserve must be there.
    assert json.loads(redoc)["provenance"].get("derived_from"), (
        f"{path.stem}: the reload did not record what it was derived from"
    )

    assert back.to_nl() == original.to_nl(), f"{path.stem}: .nl differs"
    checked += 1
    print(f"  {path.stem:<16} ok  ({len(doc):>7,} bytes)")

# A probe that silently checks nothing reads exactly like a passing one, so assert
# that it actually ran.
assert checked > 0, "no instances were compared"
print(f"\n{checked} instances round-tripped: model payload byte-identical, "
      f".nl byte-identical, provenance chained")
  4stufen          ok  ( 54,985 bytes)
  alan             ok  (  5,098 bytes)
  bchoco06         ok  ( 60,160 bytes)
  bchoco07         ok  ( 78,745 bytes)
  bchoco08         ok  (118,535 bytes)
  beuster          ok  ( 63,886 bytes)
  casctanks        ok  (203,875 bytes)
  chance           ok  (  4,458 bytes)

8 instances round-tripped: model payload byte-identical, .nl byte-identical, provenance chained

3. Solving is unchanged. Not “close” — the node count and the certified objective must be exactly equal, which is how discopt gates any change that is supposed to be behaviour-preserving.

Note

The cell below prints OA: nonlinear objective is not convex in the optimization sense; disabling master lower-bound updates and skipping objective OA cuts. The objective is convex; the outer-approximation route’s own convexity classifier false-negatives on a quadratic written as a nested dm.sum (#1352). It costs bound quality, not soundness — declaring the objective non-convex makes OA decline to update its master lower bound, which is the conservative direction — so the answer below is correct and certified. The line is left in rather than filtered, so the cost stays on the record. qp_solver works through it in full.

# A small instance that closes in about a second over a real branch-and-bound tree,
# so the node count is something to compare rather than zero.
probe_path = next(p for p in corpus if p.stem == "alan")

probe = dm.from_nl(str(probe_path))
r_before = probe.solve(time_limit=60)
r_after = loads(dumps(probe)).solve(time_limit=60)

print(f"{probe_path.stem}:")
print(f"  status     {r_before.status} -> {r_after.status}")
print(f"  node_count {r_before.node_count} -> {r_after.node_count}")
print(f"  objective  {r_before.objective} -> {r_after.objective}")
print("  exactly unchanged:",
      r_before.status == r_after.status
      and r_before.node_count == r_after.node_count
      and r_before.objective == r_after.objective)
alan:
  status     optimal -> optimal
  node_count 13 -> 13
  objective  2.925 -> 2.925
  exactly unchanged: True
/home/user/discopt/python/discopt/debug/__init__.py:98: UserWarning: Variables with very large or infinite declared bounds: x0 (lb=0, ub=inf), x1 (lb=0, ub=inf), x2 (lb=0, ub=inf), x3 (lb=0, ub=inf). Nonlinear tightening can adjust 4 bounds via separable_quadratic_upper_bound. NLP solvers may fail (NaN, iteration_limit) when bounds exceed ~1e15. Add tighter explicit bounds, e.g. m.continuous('x', lb=0, ub=1000).
  return fn(*args, **kwargs)

What the document looks like#

It is ordinary JSON, so you can read it, grep it, and diff it in review.

small = dm.Model("demo")
x = small.continuous("rate", lb=0.0, ub=5.0)
small.minimize((x - 2.0) ** 2)
small.subject_to(dm.exp(x) <= 20.0)
print(dumps(small, indent=2))
{
  "schema": "discopt.model/1.7",
  "discopt": "0.9.1.dev0",
  "name": "demo",
  "variables": [
    {
      "name": "rate",
      "type": "continuous",
      "shape": [],
      "lb": 0.0,
      "ub": 5.0,
      "bound_stack": []
    }
  ],
  "parameters": [],
  "nodes": [
    {
      "op": "var",
      "ref": 0
    },
    {
      "op": "call",
      "f": "exp",
      "args": [
        0
      ]
    },
    {
      "op": "const",
      "v": 20.0
    },
    {
      "op": "binop",
      "o": "-",
      "a": 1,
      "b": 2
    },
    {
      "op": "const",
      "v": 2.0
    },
    {
      "op": "binop",
      "o": "-",
      "a": 0,
      "b": 4
    },
    {
      "op": "binop",
      "o": "**",
      "a": 5,
      "b": 4
    }
  ],
  "objective": {
    "sense": "minimize",
    "expr": 6,
    "placeholder": false
  },
  "builder_objective": null,
  "rows": [
    {
      "kind": "algebraic",
      "body": 3,
      "sense": "<=",
      "name": null
    }
  ],
  "builder_blocks": [],
  "complementarities": null,
  "state": {
    "_aux_counter": 0,
    "_decomp_stages": {},
    "_decomp_blocks": {},
    "_zero_spanning_factor_auxes": [],
    "_implied_integer_auxes": [],
    "_free_by_construction": [],
    "_atan2_preconditions": [],
    "_piecewise_domains": [],
    "_coupling_keys": {
      "names": [],
      "row_indices": []
    },
    "_sets": [],
    "_simplex_lowerings": [],
    "_block_labels_var": {},
    "_block_labels_con": {
      "names": {},
      "rows": []
    }
  },
  "initial_point": [],
  "provenance": {
    "created": "2026-10-19T13:31:08+00:00",
    "software": {
      "name": "discopt",
      "version": "0.9.1.dev0",
      "rust_core": "0.2.0",
      "repository": "https://github.com/jkitchin/discopt",
      "license": "EPL-2.0"
    },
    "source_fingerprint": "0.9.1.dev0+1792415972896933021",
    "platform": {
      "python": "3.13.16",
      "implementation": "CPython",
      "system": "Linux",
      "machine": "x86_64"
    },
    "git_commit": "78bb0ac7359c39f7e97b4e12bce3e06e786bac89"
  }
}

The expression DAG is a flat node table rather than nested objects. Each entry’s position in the array is its id, and a node refers to its children by id. That matters for anything built by a loop — a collocation transcription, say, or a surrogate evaluated at many points — because a shared subexpression is written once instead of being re-expanded at every reference.

shared_model = dm.Model("shared")
v = shared_model.continuous("v", lb=0.0, ub=5.0)
shared = dm.exp(v) + 1.0                      # one object, referenced many times
shared_model.minimize(shared * shared)
for _ in range(50):
    shared_model.subject_to(shared <= 1e6)

doc = json.loads(dumps(shared_model))
n_exp = sum(1 for nd in doc["nodes"] if nd.get("op") == "call" and nd.get("f") == "exp")
print(f"51 references to the shared subexpression -> {n_exp} node in the table")

restored_shared = loads(dumps(shared_model))
distinct = {id(c.body.left) for c in restored_shared._constraints}
print(f"after loading, the 50 rows still reference {len(distinct)} shared object")
51 references to the shared subexpression -> 1 node in the table
after loading, the 50 rows still reference 1 shared object

Structure the exchange formats cannot carry#

Indicator constraints, SOS sets and disjunctions [Raman and Grossmann, 1994] are part of the model, but .nl has nowhere to put them and refuses the export. The native format keeps them, in declaration order. Since format 1.7 that includes Disjunct blocks nested inside one another, OR blocks (semantics="or") and variables declared local to a block with Disjunct.variable; a reloaded model still refuses a use of such a variable outside its block.

gdp = dm.Model("with_relations")
xs = gdp.continuous("xs", shape=(3,), lb=0.0, ub=20.0)
ys = gdp.binary("ys", shape=(2,))
gdp.minimize(dm.sum(xs))
gdp.subject_to(xs[0] + xs[1] <= 12.0)
gdp.either_or([[xs[2] >= 8], [xs[2] <= 2]], name="split")
gdp.if_then(ys[0], [xs[0] >= 10], name="unit0")

try:
    gdp.to_nl()
except ValueError as exc:
    print("to_nl refuses:", str(exc)[:96], "...")

kinds = [type(c).__name__ for c in loads(dumps(gdp))._constraints]
print("\nreloaded rows, in order:", kinds)
to_nl refuses: .nl export: the model carries a disjunctive constraint (Model.either_or / Model.if_else, GDP) na ...

reloaded rows, in order: ['Constraint', '_DisjunctiveConstraint', '_IndicatorConstraint']

Compression#

A .gz suffix gzips the document. Loading detects compression from the file’s magic bytes rather than its name, so a document stays readable even if someone renames or decompresses it.

import os

fit.save("decay_fit.dopt", result=result)
fit.save("decay_fit.dopt.gz", result=result)
plain = os.path.getsize("decay_fit.dopt")
packed = os.path.getsize("decay_fit.dopt.gz")
print(f"plain {plain:,} bytes   gzipped {packed:,} bytes   ({plain / packed:.1f}x)")

os.rename("decay_fit.dopt.gz", "renamed_without_a_suffix.bin")
print("still loads after renaming:",
      discopt.load("renamed_without_a_suffix.bin").saved_result.objective
      == result.objective)
plain 6,542 bytes   gzipped 1,659 bytes   (3.9x)
still loads after renaming: True

What is refused, and why#

Anything that cannot be written faithfully raises instead of being silently dropped. A file that quietly loses a constraint is worse than no file, because it reloads into a model that solves to a confidently wrong answer.

The clearest case is dm.custom, which wraps an arbitrary Python callable. There is nothing faithful to write — storing its name would reload into a different model.

from discopt.serialize import SerializationError

opaque = dm.Model("opaque")
z = opaque.continuous("z", lb=0.0, ub=1.0)
opaque.minimize(dm.custom(lambda w: w * 2, name="doubler")(z))

try:
    dumps(opaque)
except SerializationError as exc:
    print("refused:", exc)
refused: cannot serialize the custom function 'doubler': a CustomCall wraps an arbitrary Python callable, so there is nothing faithful to write (writing its name would reload into a different model). Replace it with algebraic expressions, or keep the callable in the code that rebuilds the model.

Reading is equally strict. A document written by a newer discopt — carrying a node this version does not know — is refused rather than parsed past:

doc = json.loads(dumps(small))
doc["nodes"][0]["op"] = "an_operator_from_the_future"
try:
    loads(json.dumps(doc))
except SerializationError as exc:
    print("refused:", exc)
refused: node 0 has unknown op 'an_operator_from_the_future'. This file was written by a newer discopt than the one reading it; refusing rather than dropping the node.

The same goes for a field that is merely out of range. An indicator whose activation value is 7, which a binary can never take, would make the lowering drop its row, and the solve would then certify the relaxed optimum as optimal. References, indices, senses and SOS types are checked the same way. A negative reference no longer resolves, through Python indexing, to some other node.

ind = dm.Model("indicator")
xi = ind.continuous("xi", lb=0.0, ub=10.0)
on = ind.binary("on")
ind.minimize(xi)
ind.subject_to(on == 1)
ind.if_then(on, [xi >= 0.5])

doc = json.loads(dumps(ind))
next(r for r in doc["rows"] if r["kind"] == "indicator")["active_value"] = 7
try:
    loads(json.dumps(doc))
except SerializationError as exc:
    print("refused:", exc)
refused: indicator row None has active_value 7; it must be 0 or 1

Propositional logic constraints (Model.logical) are carried since format 1.5, as a table of connectives over the binary variables behind each BooleanVar, so a reloaded model lowers them to exactly the same rows. Older readers refuse such a document rather than drop the logic. The same version records which binary columns are an either_or disjunction’s indicators once .indicators has been read, so logic written over those indicators round-trips too.

The remaining refusals are permanent, not features waiting for a section:

  • The output of a reformulation pass that keeps records pointing back at the model it was built from: binary.multilinear, integer.bilinear, integer.multilinear, nonsmooth.epigraph and gdp.transform_current_disjunctive_state. A document cannot hold the source model, and some of those records feed a bound, so a reload without them would search differently. Save the source model and re-apply the transformation after loading. The transformation is deterministic, so nothing is lost.

  • A Model.disjunction() or Disjunct that was built but never attached to the model. validate refuses such a model anyway, so there is nothing to save until it is attached or dropped.

  • Any other private attribute discopt attached to the model that this format does not classify. Each attribute a model picks up after it is built (from from_nl, a solve or a pass) is written, deliberately left out (per-solve budgets and deadlines, like the last solve result), or refused. One added in a future release fails on its first save instead of disappearing from the reload.

A model read with from_nl saves the fact that it came from a file, along with the file’s path and a SHA-256 of its bytes. The solver reads that file only while its bytes still match the digest. A document opened on another machine, or after the file was edited, falls back to the model’s own expressions instead of solving whatever file now sits at that path.

Complementarity relations#

An MPCC’s complementarity conditions [Luo et al., 1996] are not ordinary rows: they live outside _constraints, and whether this particular model already carries the rows that encode them is separate per-model state. Both halves are saved.

mpcc = dm.Model("mpcc")
p_var = mpcc.continuous("p", lb=0.0, ub=10.0)
q_var = mpcc.continuous("q", lb=0.0, ub=10.0)
mpcc.minimize((p_var - 1) ** 2 + (q_var - 1) ** 2)
mpcc.complementarity(p_var, q_var, name="pq")     # 0 <= p _|_ q >= 0

before = mpcc.solve(time_limit=60)
print(f"original:  {before.status}  obj={before.objective}  nodes={before.node_count}")

mpcc_back = loads(dumps(mpcc))
after = mpcc_back.solve(time_limit=60)
print(f"reloaded:  {after.status}  obj={after.objective}  nodes={after.node_count}")

rel = mpcc_back._complementarities[0]
print(f"\nrelation:  {rel.describe()}")
print(f"  bounds   f={rel.f_bounds}  g={rel.g_bounds}")
original:  optimal  obj=1.0  nodes=3
reloaded:  optimal  obj=1.0  nodes=3

relation:  complementarity 'pq' (role=ncp_pair)
  bounds   f=(0.0, inf)  g=(0.0, inf)

The lowering mark travels with it, and so does the method that produced it. That matters twice over. Without the mark, a model whose rows already encode the relation would reload reporting it as unlowered — and the solver refuses those, so the model would not solve at all. Without the method, a relation lowered by a relaxing scheme would reload looking like an exact encoding, and the solver would certify a relaxation as if it were the model you declared.

from discopt.mpec import relaxed_relations, unlowered_relations

for label, mdl in [("original", mpcc), ("reloaded", mpcc_back)]:
    methods = sorted(r.method for r in mdl._lowered_complementarities.values())
    exact = [r.is_exact for r in mdl._lowered_complementarities.values()]
    print(f"{label}:  methods={methods}  is_exact={exact}  "
          f"unlowered={len(unlowered_relations(mdl))}  relaxed={len(relaxed_relations(mdl))}")
original:  methods=['gdp']  is_exact=[True]  unlowered=0  relaxed=0
reloaded:  methods=['gdp']  is_exact=[True]  unlowered=0  relaxed=0

A relation that was declared but never lowered stays that way, so the solver boundary still refuses it after a round trip rather than quietly solving a model with the condition missing:

from discopt.mpec import complementarity, register_relations

pending = dm.Model("declared_only")
r_var = pending.continuous("r", lb=0.0, ub=10.0)
s_var = pending.continuous("s", lb=0.0, ub=10.0)
pending.minimize((r_var - 1) ** 2 + (s_var - 1) ** 2)
register_relations(pending, [complementarity(r_var, s_var, name="never_lowered")])

reloaded_pending = loads(dumps(pending))
print("unlowered after the round trip:", len(unlowered_relations(reloaded_pending)))
try:
    reloaded_pending.solve(time_limit=30)
except NotImplementedError as exc:
    print("solver still refuses:", str(exc)[:110], "...")
unlowered after the round trip: 1
solver still refuses: Model.solve: model declares complementarity relation(s) with no lowering: complementarity 'never_lowered' (rol ...

pickle is for moving a model, not keeping it#

A Model also pickles, in every state it reaches: freshly built, solved, read with from_nl, or built with the fast linear API. Use that to hand a live model to another process running the same discopt, for example through multiprocessing or joblib. The rules match copy.deepcopy/Model.clone():

  • solve caches are dropped, so the copy starts cold;

  • the Rust model builder is rebuilt from its Python-side record;

  • the last solve result comes along;

  • a registered atom keeps its envelope. A copy shares the registration. Across a pickle, only its name travels, and the tag is re-derived with the same check a loaded document gets;

  • decomposition marks on unnamed rows follow the copy’s own rows;

  • a long loop-built expression copies without hitting Python’s recursion limit.

The one part that does not survive is a from_nl model’s parsed .nl representation, a Rust object with no pickle support. It is replaced by a marker that raises if anything tries to use it. The source path travels with its digest, as it does in a saved document.

Being cold does not change the answer. Re-solving the unpickled copy gives the same objective in the same number of nodes:

import pickle

copy_of_fit = pickle.loads(pickle.dumps(fit))     # `fit` was solved above
again = copy_of_fit.solve(time_limit=60)
print("objective identical: ", again.objective == result.objective)
print("node count identical:", again.node_count == result.node_count)

nl_model = pickle.loads(pickle.dumps(dm.from_nl(str(probe_path))))
print("from_nl model pickles:", nl_model.num_variables, "variables")
objective identical:  True
node count identical: True
from_nl model pickles: 8 variables

Do not use pickle to keep a model on disk. A pickle can only be read by Python, and it stores whatever Model looks like inside the discopt that wrote it. It carries a layout stamp, so a discopt whose pickle layout differs refuses it rather than reading it wrongly, and one of another version with the same layout warns. But a refusal is all the stamp can offer: there is no way to read an old pickle. The native document is versioned, validated on load, upgradable with discopt upgrade-model and readable from any language.

The stamp belongs to the model. An expression pickled on its own, outside a model, carries none, so a version mismatch is not refused by name. A multi-node expression pickled by a discopt that includes #1804 raises AttributeError: Can't get attribute '_new_after_children' when an older discopt loads it. A model pickle is unaffected and reads the same way on both.

discopt.solve_batch() sends models to worker processes as native text even though they would pickle. The text carries no caches and no stale result, and it is refused in the parent, before any worker starts, if it cannot be written faithfully.

Versions and compatibility#

Every document names its format, discopt.model/1.N. The major changes only if the format is redesigned; each minor added something a writer can produce, and a reader acts on the minor it finds.

A document written at schema discopt.model/1.N loads in any later discopt whose major is 1. A newer document loads in an older discopt only when it uses nothing that discopt does not know; otherwise a reader from schema 1.5 on refuses it and says to upgrade. Readers older than that (1.0–1.4) skip a key they do not know inside a record, so a few newer documents load into them without that content: the fixture manifest (python/tests/data/serialize_fixtures/MANIFEST.json, forward cells) records every case, and the one known misread is a 1.5 disjunction’s indicators link. Every content added since is a new row kind or index kind, which every shipped reader refuses. pickle is same-version transport only: a pickle of another layout is refused, and one of another discopt version with the same layout warns.

In practice:

  • An older document always loads. A key its writer could not have produced is refused as a malformed or hand-edited file, not guessed past.

  • A newer document that uses only what this discopt knows loads with a SchemaVersionWarning; one that uses anything new is refused, naming it.

  • discopt upgrade-model old.dopt new.dopt rewrites a document at the current schema (--check only reports whether it would change).

That promise is tested, not just stated: a golden document from every schema version is checked in, each one reloads here, and each shipped reader was run on every newer version’s documents to record what it did. No reader turned a newer document into a different model without saying so, except the one case above:

from collections import Counter
from pathlib import Path

import discopt.serialize as ser

manifest = json.loads(
    (Path("../../python/tests/data/serialize_fixtures/MANIFEST.json")).read_text()
)
print("this discopt writes", ser.SCHEMA)
print(f"{'reader':>7}  newer documents: identical / refused / loaded (nothing to compare)")
for reader, entry in manifest["versions"].items():
    seen = Counter(
        cell["outcome"] for cells in entry.get("forward", {}).values() for cell in cells.values()
    )
    if seen:
        print(
            f"{reader:>7}  {seen['loads_identical']:>4} / {seen['refused']:>4} / {seen['loaded']:>4}"
            + (f"   DIFFERENT: {seen['loads_different']}" if seen["loads_different"] else "")
        )
this discopt writes discopt.model/1.7
 reader  newer documents: identical / refused / loaded (nothing to compare)
    1.0    89 /   21 /   10
    1.1    81 /   21 /    3
    1.2    66 /   21 /    3
    1.3    51 /   21 /    3
    1.4    36 /   21 /    3
    1.5    36 /    6 /    0
    1.6    20 /    2 /    0

Summary#

.nl / .gms / MPS / LP

Model.save()

Variable names

lost (x0, x1, …)

kept

Solution / fitted values

not carried

embedded on request

Indicator / SOS / disjunction

refused

kept, in order

Complementarity relations

lowered away or refused

kept, with lowering marks

Read by other solvers

yes

no

Shared subexpressions

re-expanded

written once

Use the exchange formats to hand a model to another solver. Use Model.save() to hand a model back to yourself.

for junk in ["reactor.dopt", "reactor.nl", "decay_fit.dopt", "decay_bare.dopt",
             "renamed_without_a_suffix.bin"]:
    if os.path.exists(junk):
        os.remove(junk)