From d1feb24ce6f0ea1321f8a7ae0a79c497d57a69d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 15:47:55 +0000 Subject: [PATCH 1/2] feat(language): an open bound is null, as every other open field is 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 Claude-Session: https://claude.ai/code/session_012aGN1gzy2vbqGchcQ8TbfV --- docs/reference/language/declarations.md | 20 ++++++++++++-------- schema/math-spec.schema.json | 15 +++++++++++++-- src/math_spec/lowering.py | 8 +++++--- src/math_spec/model.py | 23 ++++++++++++++--------- src/math_spec/sos.py | 16 +++++++--------- tests/test_validation.py | 18 +++++++++++++++++- 6 files changed, 68 insertions(+), 32 deletions(-) diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index f3bb9f71..fe356b83 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 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. `.inf` +on the upper side and `-.inf` on the lower side mean the same, and the loaded +model holds `null` for either. + 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..857c15f2 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 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 on the side it opens is read as\n``None``, so an open side is held one way however the file spells it.", "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/lowering.py b/src/math_spec/lowering.py index 0e4418a0..c80a90c2 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -128,7 +128,8 @@ def lower(schema: Spec) -> Program: if vdef.domain == 'binary': lower_bound, upper_bound = Constant(0.0), Constant(1.0) else: - lower_bound, upper_bound = _bound(vdef.bounds.lower), _bound(vdef.bounds.upper) + lower_bound = _bound(vdef.bounds.lower, float('-inf')) + upper_bound = _bound(vdef.bounds.upper, float('inf')) variables[vname] = VariableDeclaration( tuple(vdef.dims), where=mask_of(where), @@ -238,10 +239,11 @@ 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, open_side: float) -> Constant | Parameter: + """One side of a bound as the program holds it, an open side being *open_side*.""" if isinstance(value, str): return Parameter(value) - return Constant(value) + return Constant(open_side if value is None else value) def _assumption(name: str, block: AssumptionBlock, ns: Namespace, errors: list[str]) -> Assumption | None: diff --git a/src/math_spec/model.py b/src/math_spec/model.py index 4cf5be00..5bd4dd71 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -216,21 +216,28 @@ class ParameterBlock(_StrictBlock): description: str | None = None +#: The infinity that leaves each side of a bound open, read as ``None``. +_OPEN_SIDE = {'lower': float('-inf'), 'upper': float('inf')} + + class BoundsBlock(_StrictBlock): - """Variable bounds — each side is a number or parameter name. + """Variable bounds — each side is a 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 on the side it opens is read as + ``None``, so an open side is held one way however the file spells it. """ _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 def _a_number_or_a_name(cls, v: object, info: ValidationInfo[object]) -> object: + if v == _OPEN_SIDE.get(str(info.field_name)): + return None if isinstance(v, bool): msg = f'bounds.{info.field_name} is a boolean, and a bound is a number or a parameter name.' raise ValueError(msg) @@ -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/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/tests/test_validation.py b/tests/test_validation.py index 13c8fefe..9d8230d2 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -14,7 +14,7 @@ from math_spec._yaml import parse_yaml from math_spec.errors import DimensionError, LanguageError, SchemaError -from math_spec.program import DimensionPosition +from math_spec.program import Constant, DimensionPosition from math_spec.resolution import Namespace from math_spec.typesetting import to_markdown from math_spec.validation import to_spec @@ -2197,3 +2197,19 @@ 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'), + pytest.param({'upper': float('inf')}, id='an-infinite-number'), + ], +) +def test_an_open_bound_is_null_however_the_file_spells_it(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, 'an open bound is stored one way, whatever the file wrote' + assert spec.program.variables['p'].upper == Constant(float('inf')), 'the program reads an open side as infinity' + assert spec.to_dict()['variables']['p']['bounds'] == {'lower': 0}, 'an open bound is not written back out' From f0d453331becb527c57df56d6fe4b99711e4f155 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 17:35:30 +0000 Subject: [PATCH 2/2] feat(language): a bound is never infinite, and a program says an open 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 Claude-Session: https://claude.ai/code/session_012aGN1gzy2vbqGchcQ8TbfV --- docs/reference/language/declarations.md | 8 +++--- schema/math-spec.schema.json | 2 +- src/math_spec/boundedness.py | 12 +++------ src/math_spec/lowering.py | 10 ++++---- src/math_spec/model.py | 18 +++++++------- src/math_spec/program.py | 9 ++++--- src/math_spec/separability.py | 3 ++- src/math_spec/typesetting/walk.py | 17 ++++++------- tests/test_advice.py | 6 ++--- tests/test_validation.py | 33 +++++++++++++++++-------- 10 files changed, 64 insertions(+), 54 deletions(-) diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index fe356b83..4414b405 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -68,7 +68,7 @@ variables: | ------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -------------------- | | `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. `null` leaves that side open | 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` | @@ -77,9 +77,9 @@ variables: 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. `.inf` -on the upper side and `-.inf` on the lower side mean the same, and the loaded -model holds `null` for either. +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 diff --git a/schema/math-spec.schema.json b/schema/math-spec.schema.json index 857c15f2..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, 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 on the side it opens is read as\n``None``, so an open side is held one way however the file spells it.", + "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": [ 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 c80a90c2..d95a8281 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -128,8 +128,7 @@ def lower(schema: Spec) -> Program: if vdef.domain == 'binary': lower_bound, upper_bound = Constant(0.0), Constant(1.0) else: - lower_bound = _bound(vdef.bounds.lower, float('-inf')) - upper_bound = _bound(vdef.bounds.upper, float('inf')) + lower_bound, upper_bound = _bound(vdef.bounds.lower), _bound(vdef.bounds.upper) variables[vname] = VariableDeclaration( tuple(vdef.dims), where=mask_of(where), @@ -239,11 +238,12 @@ 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 | None, open_side: float) -> Constant | Parameter: - """One side of a bound as the program holds it, an open side being *open_side*.""" +def _bound(value: float | str | None) -> Constant | Parameter | None: + if value is None: + return None if isinstance(value, str): return Parameter(value) - return Constant(open_side if value is None else value) + return Constant(value) def _assumption(name: str, block: AssumptionBlock, ns: Namespace, errors: list[str]) -> Assumption | None: diff --git a/src/math_spec/model.py b/src/math_spec/model.py index 5bd4dd71..119eb8db 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -216,16 +216,12 @@ class ParameterBlock(_StrictBlock): description: str | None = None -#: The infinity that leaves each side of a bound open, read as ``None``. -_OPEN_SIDE = {'lower': float('-inf'), 'upper': float('inf')} - - class BoundsBlock(_StrictBlock): - """Variable bounds — each side is a number, a parameter name, or ``None`` where it is open. + """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. An infinity on the side it opens is read as - ``None``, so an open side is held one way however the file spells it. + 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' @@ -236,14 +232,18 @@ class BoundsBlock(_StrictBlock): @field_validator('lower', 'upper', mode='before') @classmethod def _a_number_or_a_name(cls, v: object, info: ValidationInfo[object]) -> object: - if v == _OPEN_SIDE.get(str(info.field_name)): - return None if isinstance(v, bool): msg = f'bounds.{info.field_name} is a boolean, and a bound is a number or a parameter name.' raise ValueError(msg) 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') 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/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 9d8230d2..e4d61ce3 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -14,7 +14,7 @@ from math_spec._yaml import parse_yaml from math_spec.errors import DimensionError, LanguageError, SchemaError -from math_spec.program import Constant, DimensionPosition +from math_spec.program import DimensionPosition from math_spec.resolution import Namespace from math_spec.typesetting import to_markdown from math_spec.validation import to_spec @@ -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",), @@ -2204,12 +2199,30 @@ def test_a_plain_entry_that_breaks_a_dim_rule_is_refused_at_load_under_its_own_n [ pytest.param({}, id='omitted'), pytest.param({'upper': None}, id='null'), - pytest.param({'upper': float('inf')}, id='an-infinite-number'), ], ) -def test_an_open_bound_is_null_however_the_file_spells_it(upper): +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, 'an open bound is stored one way, whatever the file wrote' - assert spec.program.variables['p'].upper == Constant(float('inf')), 'the program reads an open side as infinity' + 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'