Latest Results
Auto-convert unsupported constraint types in SolverAdapter (#814)
## Summary
- Add `Instance::reduce_capabilities(supported)` in the Rust SDK. For
every capability in `required_capabilities() - supported`, it invokes
the corresponding bulk conversion
(`convert_all_indicators_to_constraints`,
`convert_all_one_hots_to_constraints`,
`convert_all_sos1_to_constraints`) and returns the list of converted
capabilities.
- Switch `SolverAdapter.__init__` from `check_capabilities` (which
rejected unsupported types) to `reduce_capabilities`. Unsupported
capabilities are now converted to regular constraints in place, and each
conversion is logged at `INFO` level.
- Remove `Instance::check_capabilities` and the
`UnsupportedCapabilities` error from the Rust SDK (and
`Instance.check_capabilities` from the Python binding). Internal callers
in `as_qubo_format` / `as_hubo_format` now use `required_capabilities()`
directly with an inline bail. Expose `Instance.required_capabilities` as
a property on the Python binding so callers can diff it against an
adapter's `ADDITIONAL_CAPABILITIES` themselves.
- Rename `convert_one_hots_to_constraints` β
`convert_all_one_hots_to_constraints` so the bulk-conversion API is
uniform across all three capability types.
- Update the implement-adapter tutorial (en / ja) and the 3.0 release
notes to describe the new flow.
## Motivation
Previously, declaring `ADDITIONAL_CAPABILITIES` on an adapter meant
rejecting any instance whose constraint types weren't in the declared
set. Since regular constraints are always supported and we already have
Big-M / linear-equality conversions for indicator, one-hot, and SOS1,
rejection was unnecessarily strict. The new behavior lets every adapter
accept any instance by automatically degrading unsupported capabilities
into regular constraints.
The resulting conversions are visible on the instance via
`removed_indicator_constraints` / `removed_one_hot_constraints` /
`removed_sos1_constraints` (each has a `reason` and a `constraint_ids`
parameter pointing to the generated regular constraints), plus an `INFO`
log line per converted capability.
## Notes
- `reduce_capabilities` mutates the instance in place, matching the
existing `convert_*` APIs.
- Each per-type `convert_all_*` is atomic, but `reduce_capabilities` is
not atomic *across* types: earlier conversions aren't rolled back if a
later one fails.
- SOS1 / indicator conversion can still fail (non-finite bounds,
semi-continuous / semi-integer variables, domain excluding 0); those
errors now surface from the adapter's `__init__` rather than from a
separate capability check.
- **Breaking**: `Instance.check_capabilities` is removed. Callers that
relied on it should either use `reduce_capabilities` (to auto-convert)
or read `required_capabilities` and diff against their supported set
manually.
## Test plan
- [x] `task rust:test` β 438 + 44 doctests pass, including 6 new tests
for `reduce_capabilities` (no-op, partial conversion, full conversion,
unrequired capability skipped, error propagation, integer SOS1 with
fresh indicator)
- [x] `task python:test` β all adapter and core SDK tests pass
- [x] `task rust:clippy` β clean
- [x] `task format` β applied
π€ Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Convert indicator constraints to regular constraints via Big-M (#811)
## Summary
- Adds `Instance::convert_indicator_to_constraint(id)` /
`convert_all_indicators_to_constraints()` in Rust and Python, following
the SOS1 pattern from #810. Unlike SOS1, no fresh indicator variables
are allocated β the existing `indicator_variable` of the
`IndicatorConstraint` plays the role of $y$ in the Big-M.
- For an indicator $y = 1 \Rightarrow f(x) \leq 0$ (or $= 0$), the
upper-side Big-M `f(x) + u y - u <= 0` is emitted when $u = \sup f(x) >
0$. For equality indicators, the lower-side `-f(x) - l y + l <= 0` is
additionally emitted when $l = \inf f(x) < 0$. Sides already implied by
the variable bounds (e.g. $u \leq 0$) are skipped as redundant.
Validation is all up-front: a non-finite bound on a required side fails
the whole call without mutating state, and the bulk variant stays atomic
across every active indicator.
- Python surface mirrors the SOS1 / one-hot set:
`RemovedIndicatorConstraint`, `Instance.removed_indicator_constraints`,
`removed_indicator_constraints_df`. The original indicator is moved to
`removed_indicator_constraints` with `reason =
"ommx.Instance.convert_indicator_to_constraint"` and a comma-separated
`constraint_ids` parameter (empty when no Big-M was emitted).
## Test plan
- [x] `cargo test -p ommx --lib instance::indicator` β 7 new tests:
inequality-only upper Big-M, equality emits both sides, redundant-side
skipping, non-finite-bound rejection without mutation, missing-id
without mutation, bulk per-indicator IDs, bulk atomicity on error
- [x] `cargo test -p ommx --lib` β 431 pass (up from 424)
- [x] `cargo clippy --all-targets` clean
- [x] `task python:test` β full Python suite green; Rust doctest +
Python doctest both pass
- [x] 6 new Python integration tests in
`python/ommx-tests/tests/test_indicator_constraint.py` cover inequality,
equality, redundant skip, non-finite rejection, bulk atomic rollback,
and `removed_indicator_constraints_df` round-trip
## Notes
- Out of scope: updating solver adapters to advertise post-conversion
capability. After this PR, callers can drop
`AdditionalCapability::Indicator` requirements by converting first.
- The `RemovedReason.reason` convention tightened in #805 and #810 is
followed here (`ommx.Instance.convert_indicator_to_constraint`).
π€ Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Benchmark pyscipopt adapter: SOS1 vs big-M (#809)
## Summary
Adds a Plant Placement Problem benchmark for `ommx-pyscipopt-adapter`
that compares **eight** equivalent OMMX formulations of "at most one
plant per region", spanning three orthogonal axes:
- **`Ξ΄ + big-M`** β whether the binary opening indicator `Ξ΄_i β {0, 1}`
and the big-M link `c_i β€ C_i Β· Ξ΄_i` are introduced.
- **`Σδ β€ 1`** β whether the explicit linear cardinality bound on `Ξ΄` is
added. *Note*: when SOS1 is also declared (on `c`, on `Ξ΄`, or both),
this linear bound is **logically redundant** β SOS1 already implies it.
So the `_with_card` row in each pair is testing whether SCIP gets value
out of the redundant hint, or whether it just adds overhead. The
exception is (8) `bigm`, which has no SOS1 β there `Σδ β€ 1` is the only
thing enforcing "at most one plant per region", not redundant.
- **SOS1 location** β on the continuous capacities `{c_i}`, on the
binary indicators `{Ξ΄_i}`, on **both** (redundant), or absent.
The eight builders enumerate every well-defined combination:
| | Builder | δ + big-M | `Σδ †1` | SOS1 on `c` | SOS1 on `δ` |
|---|---|:---:|:---:|:---:|:---:|
| (1) | `build_sos1` | β | β | β | β |
| (2) | `build_sos1_on_c_with_delta` | β | β | β | β |
| (3) | `build_sos1_on_c_with_delta_with_card` | β | β | β | β |
| (4) | `build_sos1_on_delta` | β | β | β | β |
| (5) | `build_sos1_on_delta_with_card` | β | β | β | β |
| (6) | `build_sos1_on_both_with_delta` | β | β | β | β |
| (7) | `build_sos1_on_both_with_delta_with_card` | β | β | β | β |
| (8) | `build_bigm` | β | β | β | β |
All eight have the same feasible region on `(s, c)` and the same optimum
(verified by `test_placement_equivalence.py`).
A note on (2): `Ξ΄` is introduced but is *not* constrained by anything
except the big-M link `c_i β€ C_i Β· Ξ΄_i`, which only enforces "`Ξ΄_i = 0`
β `c_i = 0`" (one direction). Multiple `Ξ΄_i` could remain `1`
simultaneously; "at most one plant per region" is enforced *entirely* by
SOS1 on `c`. So `Ξ΄` in (2) is essentially a "free spectator" variable.
As an `(s, c)` optimization problem this is mathematically valid
(objective doesn't depend on `Ξ΄`), but `Ξ΄` carries no semantic meaning.
(2) functions as a **control case** isolating "does adding `Ξ΄` to the
model alone slow SCIP down" from any other effect.
## What is measured
The benchmark measures **SCIP's own processing time** (presolve + B&B),
isolated from the adapter:
- `Input.random` β `ommx.v1.Instance` β `pyscipopt.Model` is built in
**session-scoped fixtures**, outside the measured region.
- Each benchmark calls `model.freeTransform()` (discards SCIP's
transformed problem from any previous run) + `model.optimize()` on the
pre-built models.
This avoids biasing the comparison by adapter conversion cost, which
scales with variable/constraint count and is therefore *cheaper* for
`build_sos1` (no `Ξ΄`, fewer constraints) than for the seven Ξ΄-bearing
variants.
Since the measured time is essentially "how long SCIP takes" (the
adapter is out of the loop), this benchmark is most useful as a
**one-off comparison run locally** to understand how the formulation
choice interacts with SCIP. It is **not** wired into
`.github/workflows/bench.yml` β tracking it in the CodSpeed CI dashboard
would mostly track SCIP's behavior across upstream changes, which is out
of scope for this repo.
## Placement problem module (reusable)
Lives at **`ommx.testing.placement`** so other adapters
(`ommx-highs-adapter`, `ommx-python-mip-adapter`, β¦) can reuse the same
comparison. The module docstring documents the full mathematical
formulation β sets, parameters, decision variables, constraints,
objective, and the 8-row table above β rendered under KaTeX in the API
Reference (AutoAPI picks up `ommx.testing.placement` automatically under
the existing `ommx.testing` entry).
To keep existing imports `from ommx.testing import
SingleFeasibleLPGenerator, DataType` working, `testing.py` was converted
to a `testing/` package (contents moved to `testing/__init__.py`
unchanged); `placement.py` is a sibling submodule.
## Files
- `python/ommx/ommx/testing/placement.py` β `Input/Plant/Client`
dataclasses, the eight builders, a shared `_build_with_delta` backbone
parameterised by the three axis flags, and the full problem spec in the
module docstring.
- `python/ommx/ommx/testing/__init__.py` β renamed from the old
`testing.py`, contents unchanged.
- `python/ommx-pyscipopt-adapter/tests/test_placement_equivalence.py` β
verifies all eight builders reach the same optimum on a small instance.
- `python/ommx-pyscipopt-adapter/tests/test_bench_placement.py` β
`@pytest.mark.benchmark` tests parameterised over problem sizes for each
builder; measures pure SCIP time via fixture-prebuilt models +
`freeTransform()` + `optimize()`.
- `python/ommx-pyscipopt-adapter/Taskfile.yml` β `pytest` now excludes
`test_bench_*.py`; adds `pytest-bench` (walltime) and `bench`
(`--codspeed`).
## Local codspeed measurement (pure SCIP time)
`task python:ommx-pyscipopt-adapter:bench` on a dev machine (3 instances
per param set; walltime mode; total runtime β5.5 min):
| size | sos1 (1) | sos1_on_c_with_delta (2) | β¦with_card (3) |
sos1_on_delta (4) | β¦with_card (5) | sos1_on_both (6) | β¦with_card (7) |
bigm (8) |
|---|---|---|---|---|---|---|---|---|
| 6 Γ 10 | 1.8 ms | 1.8 ms | 4.5 ms | 4.8 ms | 5.2 ms | 4.6 ms | 4.6 ms
| 5.1 ms |
| 12 Γ 20 | 17.5 ms | 17.5 ms | 1.04 s | 374.5 ms | 378.1 ms | 1.05 s |
1.04 s | 370.6 ms |
| 24 Γ 40 | 246.9 ms | 242.2 ms | 1.67 s | 1.90 s | 1.87 s | 1.71 s |
1.67 s | 1.88 s |
| 48 Γ 80 | 3.02 s | 3.02 s | 12.64 s | 13.48 s | 13.17 s | 12.59 s |
12.58 s | 13.18 s |
### How to read the table
**Reproducibility vs typicality.** `random.seed(42)` is set in
`placement_inputs` per size, and `Input.random` β
`Instance.from_components` β `pyscipopt.Model` are all deterministic, as
is SCIP in sequential mode. So these numbers are **fully reproducible**:
anyone running `task β¦:bench` on a comparable machine sees the same SCIP
B&B paths and ms values (modulo OS-level noise). However, the Plant
Placement problem's difficulty depends strongly on the specific random
draw β plant positions, east/west balance, capacity distribution β and a
quick 5-seed sweep at 12Γ20 shows the "slow" variants swinging by 3β5Γ
between seeds:
| seed | (3) c+Ξ΄+card | (4) Ξ΄ | (5) Ξ΄+card | (6) both | (7) both+card |
(8) bigm |
|---|---|---|---|---|---|---|
| **42 (table above)** | 501 | 654 | 721 | 603 | 499 | 729 ms |
| 1 | 132 | 178 | 182 | 134 | 135 | 178 ms |
| 7 | 143 | 148 | 139 | 162 | 143 | 139 ms |
| 100 | 151 | 179 | 173 | 153 | 150 | 173 ms |
| 2026 | 152 | 177 | 173 | 162 | 153 | 167 ms |
So **individual ms values are reproducible but not representative** β
seed=42 happens to draw 12Γ20 instances that are notably hard. What we
read off the main table should be the tier structure, not the
fine-grained ordering within a tier.
### Observations
- **(1) and (2) are robustly the fastest, and they are tied at every
size.** Adding `Ξ΄ + big-M` to a SOS1-on-`c` model (going from (1) to
(2)) does *not* slow SCIP down β even when `Ξ΄` has no role beyond the
big-M link. The cost of `Ξ΄` per se is essentially zero.
- **The gap opens up as size grows.** At 6Γ10 all eight are within ~3Γ.
By 48Γ80, (1)/(2) at 3s are roughly **4Γ faster** than the cluster at
12β13s. The SOS1-on-`c`-alone advantage is the benchmark's strongest and
most robust signal, and it holds across every seed in the sweep above.
- **Any extra per-region structure layered on top of SOS1-on-`c` breaks
the advantage.** The moment cardinality (3) or a second SOS1 on `Ξ΄`
((6), (7)) is added, the runtime jumps by roughly an order of magnitude
compared to (1)/(2). The redundancy seems to confuse rather than help
SCIP's branching. This is robust across seeds.
- **Without SOS1 on `c`, the model lands in the same slow tier.** (4),
(5), (8) cluster together (within noise of each other at each size).
Replacing the cardinality bound with SOS1 on `Ξ΄` is a wash.
- **Fine-grained ordering within the slow tier is instance-specific.**
At 12Γ20 seed=42, (3)/(6)/(7) are notably slower than (4)/(5)/(8); at
24Γ40 seed=42, they're comparable; at 48Γ80 seed=42, they're slightly
faster; at other 12Γ20 seeds, the ordering reverses or collapses. These
reversals are not informative β read the table for tier-level structure,
not for individual cell ordering.
Bottom line: for this problem on SCIP, the lever is **declaring SOS1 on
the continuous capacities alone**, with no other per-region structure
layered on top. `Ξ΄ + big-M` may be present (it's a no-op in this
regime), but adding either the linear cardinality bound or a second SOS1
on `Ξ΄` costs roughly an order of magnitude. Without SOS1 on `c` at all,
SCIP lands in the same slower regime regardless of how cardinality is
expressed.
## Test plan
- [x] `task python:ommx-pyscipopt-adapter:test` passes (40 unit tests
incl. eight-way equivalence test)
- [x] `task python:ommx:test` passes β existing `from ommx.testing
import ...` consumers (`highs`, `python-mip`, `pyscipopt` adapters) all
green after the package-ification
- [x] `task python:ommx-pyscipopt-adapter:pyright` / `task β¦:lint` clean
- [x] `task python:ommx-pyscipopt-adapter:bench` runs end-to-end under
`--codspeed` for all eight targets
- [x] Sphinx docs build; `autoapi/ommx/testing/placement/index.html` is
generated with KaTeX-rendered math (alignment blocks wrapped in
`\begin{aligned}...\end{aligned}`)
π€ Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Hiromi Ishii <konn.jinro@gmail.com> docs: resolve all Sphinx warnings and gate builds on -W (#813)
## Summary
- Resolve all 45 Sphinx warnings in the English and Japanese
documentation builds.
- Add `-W --keep-going` to every `sphinx-build` invocation so new
warnings become hard CI failures.
## What changed
- **`docs/conf_base.py`**
- Register `sphinx.ext.doctest` so `.. doctest::` blocks in the
PySCIPopt / Python-MIP adapters render (fixes 12 `Unknown directive type
"doctest"` errors).
- Suppress `autoapi.python_import_resolution` warnings β `ommx.v1`,
`ommx._ommx_rust`, and `ommx.artifact` are intentionally documented via
`pyo3_stub_gen_ext` rather than autoapi, so those import-resolution
notices are expected (fixes 17 warnings).
- Exclude `autoapi/ommx/index.rst` from Sphinx's toctree check. Every
submodule is already linked from `docs/api/index.rst`, so the
auto-generated package index is redundant (fixes 1 "document isn't
included in any toctree" warning).
- **`python/ommx/src/instance.rs`** β change the `Instance.stats()`
return-shape fenced block from ` ```json ` to ` ```text `. The snippet
uses `int` as a type placeholder, which is not valid JSON and was
tripping Pygments (fixes 1 `misc.highlighting_failure`).
- **`python/ommx-highs-adapter/ommx_highs_adapter/adapter.py`** β
rewrite the module/`solve` docstring:
- Replace GitHub-flavored Markdown tables with RST `.. list-table::`
directives (fixes undefined-substitution errors caused by the ` | ---- |
---- | ` separators being parsed as RST substitution references).
- Flatten the awkwardly-indented bullet list under `Parameters` that
produced "Unexpected indentation" / "Block quote ends without a blank
line".
- **`docs/api/api_reference.json` +
`python/ommx/ommx/_ommx_rust/__init__.pyi`** β regenerated via `task
python:stubgen` to pick up the Rust docstring edit.
- **`docs/en/Taskfile.yml`, `docs/ja/Taskfile.yml`,
`python/Taskfile.yml`** β add `-W --keep-going` to every sphinx-build
task (`book`, `book:test`, `book:test:ja`, `book:test:en`,
`book_en:build`, `book_ja:build`). CI runs `task
python:book:test:${lang}`, so the gate is now enforced in CI as well.
## Test plan
- [x] `task book_en:build` (English docs) β build succeeded with `-W`, 0
warnings
- [x] `task book_ja:build` (Japanese docs) β build succeeded with `-W`,
0 warnings
- [x] `task python:stubgen` β regenerates `api_reference.json` and the
`.pyi` stub cleanly
- [ ] CI `test-book` job (ja and en) passes on this PR
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Convert SOS1 constraints to regular constraints via Big-M (#810)
## Summary
- Adds `Instance::convert_sos1_to_constraints(id)` /
`convert_all_sos1_to_constraints()` in Rust and Python, following the
pattern of #805 (one-hot conversion). Unlike one-hot, SOS1 needs Big-M
since the rewrite is not a mathematical identity, so the return type is
`Vec<ConstraintID>` per SOS1 (and a `BTreeMap<Sos1ConstraintID,
Vec<ConstraintID>>` for the bulk variant).
- Per variable: if `x_i` is binary with bound `[0, 1]`, reuse it as its
own indicator; otherwise introduce a fresh binary `y_i` and emit `x_i -
u_i y_i <= 0` and `l_i y_i - x_i <= 0` (trivial sides are skipped). One
cardinality constraint `sum_i y_i - 1 <= 0` is always added. Non-binary
variables with non-finite bounds or bounds that exclude 0 are rejected;
validation happens before any mutation so failures leave the instance
unchanged.
- Python surface mirrors the one-hot set: `RemovedSos1Constraint`,
`Instance.removed_sos1_constraints`, and `sos1_constraints_df` /
`removed_sos1_constraints_df` accessors. The original SOS1 is moved to
`removed_sos1_constraints` with `reason =
"ommx.Instance.convert_sos1_to_constraints"` and a comma-separated
`constraint_ids` parameter.
## Test plan
- [x] `cargo test -p ommx --lib instance::sos1` (7 new tests: binary
reuse, integer Big-M pair, trivial-side skipping, infinite-bound
rejection, bound-excluding-zero rejection, missing-ID atomicity, bulk)
- [x] `cargo test -p ommx --lib` (421 tests pass, up from 414)
- [x] `cargo clippy --all-targets` clean
- [x] `task python:test` (full Python suite passes; doctests exercise
single + bulk for the all-binary reuse path)
- [x] 3 additional Python integration tests: integer Big-M pair with
fresh indicators, domain-excluding-zero rejection without mutation, and
`removed_sos1_constraints_df` round-trip
## Notes
- Out of scope: updating solver adapters to advertise post-conversion
capability. After this PR, callers can drop SOS1 support requirements by
converting first.
- The `RemovedReason.reason` convention tightened in #805 is followed
here (`ommx.Instance.convert_sos1_to_constraints`).
π€ Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> docs(book): update examples for #806 + gate CI on notebook errors (#808)
## Summary
Three-part change prompted by #806 silently breaking every Book example
while CI stayed green:
1. **CI gate** β set `nb_execution_raise_on_error = True` in
[docs/conf_base.py](docs/conf_base.py) so `sphinx-build` (via `task
python:book:test:{en,ja}`) exits non-zero when any `{code-cell}
ipython3` cell raises. Before this, the `test-book` CI jobs reported
`success` even with every Book example broken by #806 ([test-book (en)
on #806
merge](https://github.com/Jij-Inc/ommx/actions/runs/24647276119/job/72062542623)).
2. **Book content** β update every EN/JA notebook under
`docs/{en,ja}/{user_guide,tutorial}/` to the post-#806 API. 22 files,
Β±80 lines.
3. **Migration guide** β backfill
[PYTHON_SDK_MIGRATION_GUIDE.md](PYTHON_SDK_MIGRATION_GUIDE.md) with the
two v2.5.1βv3 changes the Book fix revealed but the guide had missed
(evaluate exception type, `ParametricInstance.parameters` DataFrame
split).
## Book content changes
Applied mechanically, once per language:
- `Instance.from_components(constraints=[...])` β `constraints={id:
...}` β `constraints` arg is now `dict[int, Constraint]` (Β§4.1 of the
migration guide).
- `for c in instance.constraints:` β `for cid, c in
instance.constraints.items():` when the loop body needs the ID β `.id`
on `Constraint` no longer exists (Β§3.1, Β§4.2).
- Drop `.set_id(...)` on detached constraints; ID comes from the
`from_components` dict key (Β§3.1).
Pre-existing breakages exposed once the gate started working (now also
covered in the migration guide):
- `except RuntimeError` β `except ValueError` for `Function.evaluate` /
`Linear.evaluate` / etc. (new Β§6.5).
- `Parameter.new(id=...)` β `Parameter(id, ...)` (already in Β§5.3 β Book
just hadn't been updated).
- `ParametricInstance.parameters` now returns `list[Parameter]`; use
`parameters_df` for the DataFrame (new Β§6.6).
## Migration guide additions
New sections in
[PYTHON_SDK_MIGRATION_GUIDE.md](PYTHON_SDK_MIGRATION_GUIDE.md):
- **Β§6.5** β `evaluate` / `partial_evaluate` raise `ValueError` (was
`RuntimeError` via anyhow in v2.5.1), with a before/after snippet.
- **Β§6.6** β `ParametricInstance.parameters` is `list[Parameter]`;
`parameters_df` gives the DataFrame, mirroring the existing `_df` split
on `decision_variables` / `constraints`.
- Two matching entries in the migration checklist.
## Verification
**Before Book fix** (only gate added):
- [test-book
(en)](https://github.com/Jij-Inc/ommx/actions/runs/24647753609/job/72063901646)
**fail** with `myst_nb.core.execute.base.ExecutionError:
docs/en/tutorial/implement_adapter.md`
- [test-book
(ja)](https://github.com/Jij-Inc/ommx/actions/runs/24647753609/job/72063901655)
**fail** analogously
**After Book fix** (local):
```
task python:book:test:en # β build succeeded, 45 warnings (no execution errors)
task python:book:test:ja # β build succeeded, 45 warnings (no execution errors)
```
(The 45 remaining warnings are pre-existing `autoapi` issues β unknown
`doctest` directive and undefined substitutions in RST generated from
adapter docstrings. Unrelated to notebook execution.)
## Test plan
- [ ] `test-book (en)` passes on GitHub Actions
- [ ] `test-book (ja)` passes on GitHub Actions
- [ ] Other CI jobs stay green (no code changes outside `docs/` and
`PYTHON_SDK_MIGRATION_GUIDE.md`)
## Out of scope
`release_note/ommx-1.*.md` are already excluded from notebook execution
via `nb_execution_excludepatterns`, so outdated historical examples
there (`set_id(...)`, list-form `constraints`) are left as-is to
preserve the record of older APIs.
π€ Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Convert one-hot constraints to regular equality constraints (#805)
## Summary
A one-hot constraint over `{x_1, ..., x_n}` is mathematically equivalent
to the linear equality `x_1 + ... + x_n - 1 == 0`. This PR adds helpers
that perform that conversion, and along the way formalizes how
`RemovedReason.reason` strings should be written so external libraries
(solver adapters, preprocessing passes) can emit unambiguous, traceable
values.
## Rust
- `Instance::convert_one_hot_to_constraint(id) -> ConstraintID` β
single-shot conversion.
- `Instance::convert_one_hots_to_constraints() -> Vec<ConstraintID>` β
bulk conversion of every active one-hot.
- Each new regular `Constraint` records a
`Provenance::OneHotConstraint(id)` entry on its metadata so the
transformation chain stays traceable.
- The original one-hot is moved to `removed_one_hot_constraints` with
`reason = "ommx.Instance.convert_one_hot_to_constraint"` and a
`constraint_id` parameter pointing at the new regular constraint.
- The new `ConstraintID` comes from `ConstraintCollection::unused_id`,
avoiding collisions with existing active or removed regular constraints.
### RemovedReason convention
`RemovedReason.reason` is now documented as a fully qualified,
dot-separated path identifying the code that performed the removal.
External libraries are expected to mutate constraint collections, so
having a stable identifier matters. Existing ommx-internal call sites
are updated to match:
| Before | After |
| --- | --- |
| `penalty_method` | `ommx.Instance.penalty_method` |
| `uniform_penalty_method` | `ommx.Instance.uniform_penalty_method` |
| `unit_propagation` | `ommx.Instance.partial_evaluate.unit_propagation`
|
| `convert_inequality_to_equality_with_integer_slack` |
`ommx.Instance.convert_inequality_to_equality_with_integer_slack` |
## Python
- `Instance.convert_one_hot_to_constraint(one_hot_id)` /
`Instance.convert_one_hots_to_constraints()` β PyO3 wrappers with
runnable doctests exercised by the existing `test_doctests` harness.
- New `RemovedOneHotConstraint` class (exposed via `ommx.v1`) wrapping
`(OneHotConstraint, RemovedReason)` so the removal reason and parameters
are inspectable.
- `Instance.removed_one_hot_constraints` β dict keyed by
`OneHotConstraintID`, mirroring the existing `removed_constraints`
accessor.
- DataFrame getters: `Instance.one_hot_constraints_df` /
`Instance.removed_one_hot_constraints_df`. Columns: `id` (index),
`variables` (set), `num_variables`, `used_ids`, `name`, `subscripts`,
`description`; the removed variant additionally emits `removed_reason`
and `removed_reason.<key>` columns.
## Notes
- SOS1 has an analogous shape, but converting it to regular constraints
requires Big-M (not a mathematically-equivalent rewrite), so it's out of
scope here.
- The `RemovedReason.reason` field remains `String` on the wire (proto
`string removed_reason`); this PR only tightens the convention
documented on the Rust struct. Value updates will surface in any stored
artifacts that round-trip through this code path.
- Rebased on top of #806 (constraint types no longer carry `id` fields)
β the new code uses BTreeMap keys as the sole source of ID throughout.
## Test plan
- [x] `cargo test -p ommx --lib instance::one_hot` (3 new tests:
single-shot, missing ID atomicity, bulk)
- [x] `cargo test -p ommx --lib` (full Rust suite, 414 tests)
- [x] `task python:ommx:test` (pytest + doctests, including the updated
`penalty_method` / `uniform_penalty_method` doctest output)
- [x] Manual: inspect `one_hot_constraints_df` /
`removed_one_hot_constraints_df` output on a two-one-hot instance
- [x] Each commit compiles and tests pass in isolation (bisect-friendly)
π€ Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Latest Branches
N/A
copilot/fix-cd351290-4957-4ccd-92ca-cb64db14baf1 N/A
N/A
migrate-solution-sampleset Β© 2026 CodSpeed Technology