feat(language): a bound is null where it is open, never infinite, in the file and in the program - #689
Merged
Conversation
bounds.lower and bounds.upper defaulted to an infinity, and were the only fields a file may leave open that refused null. Both now default to null, and null leaves that side open. An infinity on the side it opens is read as null, so a loaded model holds an open side one way however the file spelled it, and to_dict writes the same file as before. The program still holds an open side as an infinite constant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aGN1gzy2vbqGchcQ8TbfV
… side is open A file's bound is a finite number, a parameter name or null. An infinity is refused and the refusal names null: on the side it opens it is the null spelled another way, and on the other side it is a bound no value meets, which loaded before as long as the other side was left open. The program holds an open side as None rather than as an infinite constant. What stands for an open side in a solve is the consumer's to choose, so the advice, the typesetter and the separability walk ask whether a side is None. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aGN1gzy2vbqGchcQ8TbfV
FBumann
pushed a commit
that referenced
this pull request
Sep 25, 2026
main's #689 makes an open bound null and a bound never infinite. The variables section keeps that rule and drops the warning box, as this branch's cut did. The quoted advice message on errors.md and check.md now says "bounds.lower is open", which is what advice prints since #689, and reading.md no longer names an infinite bound among what to_yaml leaves out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A89tReSh8rTBG9ocy7688y
This was referenced Sep 25, 2026
Merged
FBumann
added a commit
to fluxopt/specsolve
that referenced
this pull request
Sep 25, 2026
…d, the way mathspec 0.0.0-alpha.127 does (#1744) > **Prompt:** update specsolve to the latest mathspec release. It also renamed everything to "mathspec", not "math-spec" > [!NOTE] > The following content was generated by AI. The pin moves from math-spec `v0.0.0-alpha.122` to mathspec `v0.0.0-alpha.127`. Every import, URL and mention says `mathspec`. A variable bound's open side is now `null`. Both engines read it as ±∞. An infinite bound is refused. **Breaking:** a spec that writes `upper: .inf` (or `lower: -.inf`) no longer loads. Write `null` or leave the side out. mathspec's refusal gives this rewrite. <details><summary>What changed, gates, and what was not run</summary> **Rename** (energy-models/mathspec#702): - `math_spec` becomes `mathspec` and `math-spec` becomes `mathspec` in `src/`, `tests/`, `docs/`, `examples/`, `differential/`, `bench/`, `.github/`, `mkdocs.yml` and `pyproject.toml`. This includes the `pypsa-parity` checkout of `energy-models/mathspec` and its `grep` for the pin. - **Kept unchanged:** `math-spec.readthedocs.io`, because mathspec kept that Read the Docs slug. All 48 distinct docs URLs still resolve against mathspec's `docs/` at the tag. `CHANGELOG.md`, because it holds released entries (mathspec did the same). `bench/reproduce.py.lock`, because it freezes an old environment at math-spec alpha.85. - `uv.lock` comes from `uv lock --upgrade-package mathspec`. **Open bounds** (energy-models/mathspec#689, released in alpha.124): - `VariableDeclaration.lower`/`upper` are `None` on an open side. Before, they held `Constant(±inf)`. - `relational/engines/polars/compiler.py`: `bounds` reads `None` as `lit(∓inf)`. - `linopy/builder.py`: `_bound` reads `None` as ∓inf. `_check_bounds_are_defined` skips an open side. - Tests: two fixtures dropped `upper: .inf`. `test_json_carries_a_model_too` now asserts `bounds.lower is None`. `test_infinite_bounds_survive_the_handoff` builds its open side as `upper=None` and no longer as `Constant(inf)`, which mathspec no longer produces. It now fails when the polars engine reads an open upper side as `1e9`. - **Not simplified, on purpose:** the sinks keep their infinity handling. A bound parameter's data can still hold `inf`. A null `lb`/`ub` already means a bound parameter has no value there. So the open side becomes ±∞ where each engine reads the bound, and nothing downstream changes. **Other upstream changes that reached tests:** - energy-models/mathspec#676: the unknown-operator message now points at `limits.md` and no longer at "a declared escape". `test_an_unknown_operator_names_its_context_and_teaches_the_rewrite` now asserts `macro` in place of `escape`. - `examples/walkthrough.out` is regenerated with `--update-golden`. It changes the module name and that message. **Gates.** pixi's installer is blocked here (403), so these ran in a uv venv with Python 3.12, the `linopy,gurobi,xpress` extras and the `dev` group: - `ruff check .`: clean. `ruff format --check .`: 327 files already formatted. - `pyrefly check`, both configurations: 0 errors. - `pytest -q -n 8`: 4033 passed, 319 skipped, 1 xfailed. `origin/main` on alpha.122 gives the same counts. - `pytest bench/test_harness.py`: 149 passed, 6 skipped. It ran with `--i-know-another-is-running`, because the load average was still falling after the suite and nothing else was running. These tests check objectives, not timings. - `zensical build --strict`: no issues. `pytest tests/test_docs_math.py`: 69 passed. **Not run:** `test-floors`, and the PyPSA parity gate (`differential/pypsa/parity.py`). This diff changes only a docstring in the parity script. The parity gate still reads the new corpus. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01XdbYXKrogqppf3cPD932Dh --------- Co-authored-by: Claude <noreply@anthropic.com>
FBumann
pushed a commit
to fluxopt/specsolve
that referenced
this pull request
Sep 25, 2026
…h that side unbounded math-spec's program is to state an open side as None rather than as an infinite constant (energy-models/mathspec#689), leaving what stands for it in a solve to the engine. Both lanes now read None as the infinity on that side: the linopy lane hands linopy -inf or inf, and the relational compiler writes it into the lb or ub column. A bound the program states is read as before, so the current pin is served unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aGN1gzy2vbqGchcQ8TbfV
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
The following content was generated by AI.
A bound is a finite number, a parameter name, or
null, andnullis the default. An infinite bound is refused, and the refusal namesnull. The program holds an open side asNone, not asConstant(±inf). The engine chooses what stands for it in a solve: fluxopt/specsolve#1737.What this changes
BoundsBlock.lower/upperarefloat | str | None = None.upper: nullloads; it was refused. An infinity is refused:bounds.upper is inf, and a bound is finite. An open side is null: write upper: null, or leave it out.VariableDeclaration.lower/upperareExpression | None = None. Lowering passesNonethrough.None, where they compared it with an infinite constant.sos.coefficientsloses its infinity comparisons,_is_absentits infinite-bound branch, andboundednessits_OPENtable.declarations.mdstates the default and the refusal. Golden output unchanged.Why
where,description,dimson a named expression,activity,points) acceptsnulland defaults to it. Bounds were the one exception.nullin a patch mean "absent". On a declaration it removes the declaration. On a field it drops the field, which then takes its default. With an infinite default,bounds: {upper: null}in a patch built a model the schema refused, so a patch could not open a bound. Withnullas the default, the value a patch writes and the default it falls back to are the same.nullmeans absent in a file and in a patch alike.is Nonesays that directly. linopy wants±inf, and a relational sink may want another value; that choice is the engine's.lower: .infwith the upper side open loaded before, because only two literal bounds that cross were refused. Refusing every infinity closes that gap with no rule of its own.Tests, guards, gates, what follows
Tests.
test_an_open_bound_is_null_in_the_file_and_in_the_programcoversupperomitted andnull. Each case asserts the model holdsNone, the program holdsNone, andto_dict()writes noupper. Onmain,nullwas aSchemaErrorand the program heldConstant(inf).test_an_infinite_bound_is_refused_with_the_null_that_opens_a_sidecovers.infand-.infon each side. Onmainall four loaded.Guard. The infinity refusal was deleted and the suite run; all four refusal cases fail. Restored.
Coverage moved.
infinite-bounds-that-crossintest_a_rule_decided_without_datais gone, because the refusal above now covers both of its infinities.test_advice.pystates an open lower bound asNone, where it wrote-infand rewrote the JSON to-.inf.Gates run.
pixi run lintpasses.pixi run testpasses.mkdocs buildshows no warnings except one: this sandbox cannot fetch thedocs.python.orginventory, which fails--strict.render-texrendered 31 models.Not run.
compile-tex: the tectonic bundle download is blocked here. CI runs it.Breaks a consumer.
VariableDeclaration.lower/uppermay now beNone, which a consumer reading them has to handle. fluxopt/specsolve#1737 does that for both of its lanes and passes on its current pin. It lands before the pin moves to a release that carries this.