diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index f3bb9f71..4414b405 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -64,19 +64,23 @@ variables: upper: capacity ``` -| Field | | | -| ------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------- | -| `dims` | required. The dimensions it is indexed by | | -| `where` | which coordinates exist ([absence](absence.md)) | default `null` | -| `bounds.lower` / `bounds.upper` | a number, or the name of a `float` or `int` parameter | default `-inf` / `inf` | -| `domain` | `continuous`, `integer` or `binary`. `binary` carries fixed 0/1 bounds | default `continuous` | -| `absence` | `undefined` or `zero`: what a masked-out coordinate means ([absence](absence.md#what-a-missing-coordinate-means)) | default `undefined` | -| `description` | free text | default `null` | +| Field | | | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -------------------- | +| `dims` | required. The dimensions it is indexed by | | +| `where` | which coordinates exist ([absence](absence.md)) | default `null` | +| `bounds.lower` / `bounds.upper` | a finite number, or the name of a `float` or `int` parameter. `null` leaves that side open | default `null` | +| `domain` | `continuous`, `integer` or `binary`. `binary` carries fixed 0/1 bounds | default `continuous` | +| `absence` | `undefined` or `zero`: what a masked-out coordinate means ([absence](absence.md#what-a-missing-coordinate-means)) | default `undefined` | +| `description` | free text | default `null` | !!! warning "A bound you omit leaves the variable unbounded on that side" You write non-negativity. The language does not assume it. +An open side is `null`, as every other field a file may leave open is. A bound +is never infinite: `.inf` and `-.inf` are refused, with `null` named as the +rewrite. + A bound is a name or a number: `upper: capacity` is accepted, and `upper: -rating` is refused. Ship the negated column as data. The dimensions of a bound parameter are a subset of the variable's. diff --git a/schema/math-spec.schema.json b/schema/math-spec.schema.json index 99300c13..78f55535 100644 --- a/schema/math-spec.schema.json +++ b/schema/math-spec.schema.json @@ -48,7 +48,7 @@ }, "BoundsBlock": { "additionalProperties": false, - "description": "Variable bounds \u2014 each side is a number or parameter name.\n\nAn omitted bound leaves the variable unbounded on that side, not\nimplicitly non-negative.", + "description": "Variable bounds \u2014 each side is a finite number, a parameter name, or ``None`` where it is open.\n\nAn omitted bound leaves the variable unbounded on that side, not\nimplicitly non-negative. An infinity is refused: an open side is ``null``,\nand the other infinity leaves no value at all.", "properties": { "lower": { "anyOf": [ @@ -57,8 +57,12 @@ }, { "type": "string" + }, + { + "type": "null" } ], + "default": null, "title": "Lower" }, "upper": { @@ -68,8 +72,12 @@ }, { "type": "string" + }, + { + "type": "null" } ], + "default": null, "title": "Upper" } }, @@ -617,7 +625,10 @@ }, "bounds": { "$ref": "#/$defs/BoundsBlock", - "default": {} + "default": { + "lower": null, + "upper": null + } }, "description": { "anyOf": [ diff --git a/src/math_spec/boundedness.py b/src/math_spec/boundedness.py index 018ccf87..95ab4aaa 100644 --- a/src/math_spec/boundedness.py +++ b/src/math_spec/boundedness.py @@ -13,7 +13,6 @@ from __future__ import annotations -import math from typing import TYPE_CHECKING, Literal, assert_never from math_spec.errors import Advice @@ -50,10 +49,6 @@ #: Which of a variable's two bounds a term drives it toward. BoundSide = Literal['lower', 'upper'] -#: The bound value that leaves each side open. A ``lower`` of ``+inf`` is not -#: this — that model is empty, not unbounded — so the match is by value. -_OPEN: dict[BoundSide, float] = {'lower': -math.inf, 'upper': math.inf} - def unbounded_notes(program: Program) -> list[Advice]: """Name every variable the objective can drive to infinity unopposed. @@ -90,7 +85,7 @@ def unbounded_notes(program: Program) -> list[Advice]: 'unbounded', vname, f"Variable '{vname}' makes this model unbounded: no constraint names it, and " - f'bounds.{side} is {_OPEN[side]}, which is the direction a {sign}{vname} term ' + f'bounds.{side} is open, which is the direction a {sign}{vname} term ' f'improves a {program.objective.sense} objective in. No data can change that, so ' f'the solve would answer `unbounded` and name nothing.\n' f'Give it a finite bounds.{side}, or the constraint that was meant to define it.', @@ -100,12 +95,11 @@ def unbounded_notes(program: Program) -> list[Advice]: def _is_open(vdef: VariableDeclaration, side: BoundSide) -> bool: - """Whether *vdef*'s bound on *side* is the open value itself. + """Whether *vdef* states no bound on *side*. A bound naming a parameter is finite or not by data, so it does not count. """ - bound = vdef.lower if side == 'lower' else vdef.upper - return bound == Constant(_OPEN[side]) + return (vdef.lower if side == 'lower' else vdef.upper) is None def _flip(sign: Sign) -> Sign: diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index 0e4418a0..d95a8281 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -238,7 +238,9 @@ def _frame_of(name: str, entry: Named, schema: Spec) -> tuple[str, ...]: return tuple(d for d in schema.dimensions if d in carried) -def _bound(value: float | str) -> Constant | Parameter: +def _bound(value: float | str | None) -> Constant | Parameter | None: + if value is None: + return None if isinstance(value, str): return Parameter(value) return Constant(value) diff --git a/src/math_spec/model.py b/src/math_spec/model.py index 4cf5be00..119eb8db 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -217,16 +217,17 @@ class ParameterBlock(_StrictBlock): class BoundsBlock(_StrictBlock): - """Variable bounds — each side is a number or parameter name. + """Variable bounds — each side is a finite number, a parameter name, or ``None`` where it is open. An omitted bound leaves the variable unbounded on that side, not - implicitly non-negative. + implicitly non-negative. An infinity is refused: an open side is ``null``, + and the other infinity leaves no value at all. """ _label: ClassVar[str] = 'a bounds block' - lower: float | str = float('-inf') - upper: float | str = float('inf') + lower: float | str | None = None + upper: float | str | None = None @field_validator('lower', 'upper', mode='before') @classmethod @@ -237,6 +238,12 @@ def _a_number_or_a_name(cls, v: object, info: ValidationInfo[object]) -> object: if isinstance(v, float) and math.isnan(v): msg = f'bounds.{info.field_name} is nan, which no value compares to. Write a number, or omit the bound.' raise ValueError(msg) + if isinstance(v, float | int) and math.isinf(v): + msg = ( + f'bounds.{info.field_name} is {v}, and a bound is finite. An open side is null: ' + f'write {info.field_name}: null, or leave it out.' + ) + raise ValueError(msg) return v @model_validator(mode='after') @@ -689,10 +696,8 @@ def _without_absence(value: object) -> object: def _is_absent(value: object) -> bool: - """Whether *value* is a null or an infinite bound.""" - if value is None: - return True - return isinstance(value, float) and math.isinf(value) + """Whether *value* is a null.""" + return value is None class Spec(_StrictBlock): @@ -798,7 +803,7 @@ def _check_version(cls, v: int) -> int: @model_serializer(mode='wrap') def _drop_absence(self, handler: SerializerFunctionWrapHandler) -> dict[str, object]: - """Absence is not serialised: a null, an infinite bound, a mapping that stripping emptied, a section declaring nothing. + """Absence is not serialised: a null, a mapping that stripping emptied, a section declaring nothing. An empty list stays, being a value rather than an absence (``dims: []`` is a scalar). On the serializer so that ``model_dump``, diff --git a/src/math_spec/program.py b/src/math_spec/program.py index e875184f..2122947b 100644 --- a/src/math_spec/program.py +++ b/src/math_spec/program.py @@ -21,7 +21,7 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass, field, fields, replace +from dataclasses import dataclass, fields, replace from functools import cached_property from typing import TYPE_CHECKING, Literal, assert_never @@ -599,8 +599,11 @@ class ParameterDeclaration: class VariableDeclaration: dims: tuple[str, ...] where: Mask | None = None - lower: Expression = field(default_factory=lambda: Constant(float('-inf'))) - upper: Expression = field(default_factory=lambda: Constant(float('inf'))) + #: A number or a parameter, or ``None`` where that side is open. What stands + #: for an open side in a solve is the consumer's to choose. + lower: Expression | None = None + #: As :attr:`lower`, for the other side. + upper: Expression | None = None domain: VariableDomain = 'continuous' absence: VariableAbsence = 'undefined' description: str | None = None diff --git a/src/math_spec/separability.py b/src/math_spec/separability.py index de7ba06b..d31cb0c4 100644 --- a/src/math_spec/separability.py +++ b/src/math_spec/separability.py @@ -54,7 +54,8 @@ def _built_blocks(program: Program) -> Iterator[_Block]: for name, block in program.constraints.items(): yield _Block(f"constraint '{name}'", name, (block.lhs, block.rhs), block.where, True) for name, variable in program.variables.items(): - yield _Block(f"variable '{name}'", None, (variable.lower, variable.upper), variable.where, True) + bounds = tuple(side for side in (variable.lower, variable.upper) if side is not None) + yield _Block(f"variable '{name}'", None, bounds, variable.where, True) if program.objective is not None: yield _Block('the objective', None, (program.objective.expression,), None, False) diff --git a/src/math_spec/sos.py b/src/math_spec/sos.py index b24da870..3c04d60a 100644 --- a/src/math_spec/sos.py +++ b/src/math_spec/sos.py @@ -28,7 +28,7 @@ Coefficients = tuple[float | str | None, float | str | None] -def coefficients(domain: str, lower: float | str, upper: float | str) -> Coefficients: +def coefficients(domain: str, lower: float | str | None, upper: float | str | None) -> Coefficients: """What a member's two linking rows multiply its binary by, ``None`` on a side the model leaves open. The 0 and 1 a binary's domain fixes, which no bounds block carries; @@ -39,9 +39,9 @@ def coefficients(domain: str, lower: float | str, upper: float | str) -> Coeffic does not cap, and one above it is a looser row than the bound already states. """ - fixed = domain == 'binary' - below = 0.0 if fixed else (None if lower == float('-inf') else lower) - return below, (1.0 if fixed else (None if upper == float('inf') else upper)) + if domain == 'binary': + return 0.0, 1.0 + return lower, upper @dataclass(frozen=True) @@ -141,13 +141,11 @@ def _scaled(factor: float | str, picked: str) -> str: def _coefficients(member: dict[str, object]) -> tuple[float | str, float | str]: """The two coefficients as an expression writes them, read off the member.""" - bounds: dict[str, object] = {'lower': float('-inf'), 'upper': float('inf')} declared = member.get('bounds') assert declared is None or isinstance(declared, dict), 'a validated model carries a bounds block as a mapping' - bounds.update(declared or {}) - lower, upper = bounds['lower'], bounds['upper'] - assert isinstance(lower, float | str) and isinstance(upper, float | str), ( - 'a bound is a number or the name of a parameter' + lower, upper = (declared.get('lower'), declared.get('upper')) if declared else (None, None) + assert isinstance(lower, float | str | None) and isinstance(upper, float | str | None), ( + 'a bound is a number, the name of a parameter, or open' ) below, above = coefficients(str(member.get('domain', 'continuous')), lower, upper) assert below is not None and above is not None, 'a set with a side left open is refused at load' diff --git a/src/math_spec/typesetting/walk.py b/src/math_spec/typesetting/walk.py index 1ccef519..82da079c 100644 --- a/src/math_spec/typesetting/walk.py +++ b/src/math_spec/typesetting/walk.py @@ -801,18 +801,17 @@ def _variable(self, name: str) -> Line: if block.domain == 'binary': left, right = symbol, f'{self._op("in")} {self._op("binary_set")}' else: - below, above = lower == Constant(float('-inf')), upper == Constant(float('inf')) - if below and above: - domain = self._op('integers' if block.domain == 'integer' else 'reals') - left, right = symbol, f'{self._op("in")} {domain}' - elif below: + if lower is not None and upper is not None: + left = f'{self._bound(ctx, lower)} {self._op("le")} {symbol}' + right = f'{self._op("le")} {self._bound(ctx, upper)}' + elif upper is not None: left, right = symbol, f'{self._op("le")} {self._bound(ctx, upper)}' - elif above: + elif lower is not None: left, right = symbol, f'{self._op("ge")} {self._bound(ctx, lower)}' else: - left = f'{self._bound(ctx, lower)} {self._op("le")} {symbol}' - right = f'{self._op("le")} {self._bound(ctx, upper)}' - if block.domain == 'integer' and not (below and above): + domain = self._op('integers' if block.domain == 'integer' else 'reals') + left, right = symbol, f'{self._op("in")} {domain}' + if block.domain == 'integer' and (lower is not None or upper is not None): right = f'{right}, {symbol} {self._op("in")} {self._op("integers")}' return Line(label=name, left=left, right=right, condition=condition) diff --git a/tests/test_advice.py b/tests/test_advice.py index 192a1499..fd83dd7b 100644 --- a/tests/test_advice.py +++ b/tests/test_advice.py @@ -70,7 +70,7 @@ def test_a_dimension_something_reaches_is_in_use(patch): #: A model with one note of each kind: nothing reaches `h`, and `p` is driven #: down by the objective with an open lower bound and no constraint on it. -BOTH_KINDS = override(UNREACHED, **{'objective.expression': 'sum(p)', 'variables.p.bounds': {'lower': -float('inf')}}) +BOTH_KINDS = override(UNREACHED, **{'objective.expression': 'sum(p)', 'variables.p.bounds': {'lower': None}}) def test_both_kinds_of_note_come_through_the_one_door(): @@ -84,9 +84,9 @@ def test_both_kinds_of_note_come_through_the_one_door(): def _written(model: dict, tmp_path: Path) -> Path: - """The model as a file on disk — JSON is YAML, once infinity is spelled its way.""" + """The model as a file on disk — JSON is YAML.""" path = tmp_path / 'model.yaml' - path.write_text(json.dumps(model).replace('-Infinity', '-.inf')) + path.write_text(json.dumps(model)) return path diff --git a/tests/test_validation.py b/tests/test_validation.py index 13c8fefe..e4d61ce3 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1363,11 +1363,6 @@ class TestRulesDecidedWithoutData: ('bounds.lower 5.0 is above bounds.upper 1.0, so no value satisfies them',), id='literal-bounds-that-cross', ), - pytest.param( - {'variables.p.bounds': {'lower': float('inf'), 'upper': float('-inf')}}, - ('bounds.lower inf is above bounds.upper -inf',), - id='infinite-bounds-that-cross', - ), pytest.param( {'variables.p.dims': ['g', 'g']}, ("Variable 'p' names dimension 'g' twice",), @@ -2197,3 +2192,37 @@ def test_a_plain_entry_that_breaks_a_dim_rule_is_refused_at_load_under_its_own_n model = override(SMALL_MODEL, expressions={'bad': {'expression': 'sum(k, over=g)'}}, constraints=constraints) with pytest.raises(DimensionError, match=r"^Named expression 'bad': sum\(over=g\)"): to_spec(model) + + +@pytest.mark.parametrize( + 'upper', + [ + pytest.param({}, id='omitted'), + pytest.param({'upper': None}, id='null'), + ], +) +def test_an_open_bound_is_null_in_the_file_and_in_the_program(upper): + """`upper: null` was refused, though every other field a file may leave open takes `null`.""" + spec = to_spec(override(DISPATCH_MODEL, **{'variables.p.bounds': {'lower': 0, **upper}})) + assert spec.variables['p'].bounds.upper is None + assert spec.program.variables['p'].upper is None, 'the program says the side is open rather than infinite' + assert spec.to_dict()['variables']['p']['bounds'] == {'lower': 0}, 'an open bound is not written back out' + + +@pytest.mark.parametrize( + ('side', 'value'), + [ + pytest.param('upper', float('inf'), id='the-infinity-that-opens-the-upper-side'), + pytest.param('lower', float('-inf'), id='the-infinity-that-opens-the-lower-side'), + pytest.param('lower', float('inf'), id='a-lower-bound-no-value-meets'), + pytest.param('upper', float('-inf'), id='an-upper-bound-no-value-meets'), + ], +) +def test_an_infinite_bound_is_refused_with_the_null_that_opens_a_side(side, value): + """An infinity is either the open side, which is `null`, or a bound no value meets. + + A lone `lower: .inf` loaded: only two literal bounds that cross were refused. + """ + message = _refusal(DISPATCH_MODEL, **{f'variables.p.bounds.{side}': value}) + assert f'bounds.{side} is {value}, and a bound is finite' in message + assert f'{side}: null' in message, 'the refusal names the spelling of an open side'