Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions docs/reference/language/declarations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 13 additions & 2 deletions schema/math-spec.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -57,8 +57,12 @@
},
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Lower"
},
"upper": {
Expand All @@ -68,8 +72,12 @@
},
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Upper"
}
},
Expand Down Expand Up @@ -617,7 +625,10 @@
},
"bounds": {
"$ref": "#/$defs/BoundsBlock",
"default": {}
"default": {
"lower": null,
"upper": null
}
},
"description": {
"anyOf": [
Expand Down
12 changes: 3 additions & 9 deletions src/math_spec/boundedness.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

from __future__ import annotations

import math
from typing import TYPE_CHECKING, Literal, assert_never

from math_spec.errors import Advice
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.',
Expand All @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion src/math_spec/lowering.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
23 changes: 14 additions & 9 deletions src/math_spec/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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')
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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``,
Expand Down
9 changes: 6 additions & 3 deletions src/math_spec/program.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/math_spec/separability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
16 changes: 7 additions & 9 deletions src/math_spec/sos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -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'
Expand Down
17 changes: 8 additions & 9 deletions src/math_spec/typesetting/walk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
6 changes: 3 additions & 3 deletions tests/test_advice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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


Expand Down
39 changes: 34 additions & 5 deletions tests/test_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",),
Expand Down Expand Up @@ -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'
Loading