Skip to content

feat(language): a bound is null where it is open, never infinite, in the file and in the program - #689

Merged
FBumann merged 2 commits into
mainfrom
feat/open-bound-is-null
Sep 24, 2026
Merged

FBumann merged 2 commits into
mainfrom
feat/open-bound-is-null

Conversation

@FBumann

@FBumann FBumann commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: I think the default in upper and lower should be null instead of inf

Prompt: Shouldn't the engine (specsolve) handle this? We can drop inf fully…? Or why do we need to keep the constant inf in program?

Note

The following content was generated by AI.

A bound is a finite number, a parameter name, or null, and null is the default. An infinite bound is refused, and the refusal names null. The program holds an open side as None, not as Constant(±inf). The engine chooses what stands for it in a solve: fluxopt/specsolve#1737.

What this changes

  • File. BoundsBlock.lower / upper are float | str | None = None. upper: null loads; 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.
  • Program. VariableDeclaration.lower / upper are Expression | None = None. Lowering passes None through.
  • Readers. The unboundedness advice, the typesetter's domain line and the separability walk ask whether a side is None, where they compared it with an infinite constant.
  • Removed. sos.coefficients loses its infinity comparisons, _is_absent its infinite-bound branch, and boundedness its _OPEN table.
  • Schema regenerated. declarations.md states the default and the refusal. Golden output unchanged.

Why

  • One spelling of "absent". Every other field a file may leave open (where, description, dims on a named expression, activity, points) accepts null and defaults to it. Bounds were the one exception.
  • Merge and override. The composition functions of feat(language): a file says what it reads, and fragments and patches compose into one model #691 lay a patch over a base, and feat(language): merge and override load every model they compose and return one #690 makes a null in 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. With null as the default, the value a patch writes and the default it falls back to are the same. null means absent in a file and in a patch alike.
  • Infinity is a solver's spelling. The program states what the file means, and "no bound" is not a number. The readers here asked "is it the infinite constant?" to mean "is it open?", and is None says that directly. linopy wants ±inf, and a relational sink may want another value; that choice is the engine's.
  • A bound no value meets now fails at load. lower: .inf with 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_program covers upper omitted and null. Each case asserts the model holds None, the program holds None, and to_dict() writes no upper. On main, null was a SchemaError and the program held Constant(inf).
  • test_an_infinite_bound_is_refused_with_the_null_that_opens_a_side covers .inf and -.inf on each side. On main all four loaded.

Guard. The infinity refusal was deleted and the suite run; all four refusal cases fail. Restored.

Coverage moved.

  • infinite-bounds-that-cross in test_a_rule_decided_without_data is gone, because the refusal above now covers both of its infinities.
  • test_advice.py states an open lower bound as None, where it wrote -inf and rewrote the JSON to -.inf.

Gates run. pixi run lint passes. pixi run test passes. mkdocs build shows no warnings except one: this sandbox cannot fetch the docs.python.org inventory, which fails --strict. render-tex rendered 31 models.

Not run. compile-tex: the tectonic bundle download is blocked here. CI runs it.

Breaks a consumer. VariableDeclaration.lower / upper may now be None, 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.

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
@read-the-docs-community

read-the-docs-community Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

… 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 FBumann changed the title feat(language): an open bound is null, as every other open field is feat(language): a bound is null where it is open, never infinite, in the file and in the program Sep 24, 2026
@FBumann
FBumann merged commit 8df92d0 into main Sep 24, 2026
6 checks passed
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
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants