From 6505c80b0bf7ebeb974b17fc58a5b1ad566d7d69 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:36:00 +0000 Subject: [PATCH 1/3] wip: divisor zeroed by an edge Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AvtZGymCbY9swLnR8JwHng --- src/mathspec/_expression_resolver.py | 38 ++++++++++++++++++ tests/test_validation.py | 58 ++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/mathspec/_expression_resolver.py b/src/mathspec/_expression_resolver.py index 0e701fbb..6e1788e1 100644 --- a/src/mathspec/_expression_resolver.py +++ b/src/mathspec/_expression_resolver.py @@ -44,10 +44,12 @@ from mathspec.program import ( Add, Axis, + Cases, Constant, Divide, Dual, Expression, + ExpressionReference, Join, JoinColumns, Multiply, @@ -83,6 +85,37 @@ MAX_RESOLVED_DEPTH = 3 * MAX_DEPTH +def _zero_in(e: Expression) -> tuple[str, str] | None: + """Where the file itself makes *e* 0, with the rewrite that removes it, or ``None`` where it never does. + + ``edge=0`` on a shift puts a 0 where the shift vacates, whatever the + data says, so a divisor that holds one is refused at load. A factor, the + numerator of a quotient, the base of a power, and the operand of a + negation, a sum, a window, a shift, a lookup or a named expression make *e* + 0 where they are 0; a sum of terms only where every term is, so + ``shift(p, edge=0) + 1`` never is; a case where any region is. + """ + match e: + case Translate(fill=fill) if fill == 0: + return f'where shift(along={e.along}, edge=0) vacates', "Write edge='wrap', or an edge= value other than 0." + case Negate(operand=operand) | Sum(operand=operand) | WindowSum(operand=operand) | Translate(operand=operand): + return _zero_in(operand) + case Join(operand=operand) | ExpressionReference(body=operand): + return _zero_in(operand) + case Multiply(left=left, right=right): + return _zero_in(left) or _zero_in(right) + case Divide(numerator=numerator): + return _zero_in(numerator) + case Power(base=base): + return _zero_in(base) + case Add(left=left, right=right): + zero = _zero_in(left) + return zero if zero is not None and _zero_in(right) is not None else None + case Cases(regions=regions): + return next((zero for r in regions if (zero := _zero_in(r.value)) is not None), None) + return None + + @dataclass(frozen=True) class ExpressionResolver: """One resolution walk over an expression, and the three things every step of it reads. @@ -172,6 +205,11 @@ def _binary(self, node: BinaryOperatorNode) -> Expression | None: case '*': return Multiply(left, right) case '/': + if (zero := _zero_in(right)) is not None: + self.errors.append( + f'{self.context}: the divisor is 0 {zero[0]}, which no number divides by. {zero[1]}' + ) + return None return Divide(left, right) case '**': return Power(left, right) diff --git a/tests/test_validation.py b/tests/test_validation.py index 452beb88..a350f5dd 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -2675,3 +2675,61 @@ def test_a_dump_that_leaves_ordered_out_still_writes_the_dimension(dump): def test_a_row_a_set_a_curve_and_an_assumption_sit_outside_the_flat_namespace(patch): """What the language reference states under name resolution: none of these four is read by a bare name.""" spec_of(EXAMPLES / 'sos.yaml', **patch) + + +#: The shift a divisor below is 0 at the edge of, wherever it stands in the divisor. +EDGE_ZERO = 'shift(c, along=g, offset=1, edge=0)' + + +@pytest.mark.parametrize( + ('divisor', 'patch'), + [ + pytest.param(EDGE_ZERO, {}, id='the-shift'), + pytest.param('shift(c, along=g, offset=-1, edge=0)', {}, id='a-shift-ahead'), + pytest.param(f'2 * {EDGE_ZERO}', {}, id='a-factor'), + pytest.param(f'-{EDGE_ZERO}', {}, id='negated'), + pytest.param(f'{EDGE_ZERO} / 2', {}, id='a-numerator'), + pytest.param(f'{EDGE_ZERO} ** 2', {}, id='a-base'), + pytest.param(f'sum({EDGE_ZERO}, over=g)', {}, id='summed'), + pytest.param(f'shift({EDGE_ZERO}, along=g, offset=1, edge=1)', {}, id='shifted-again'), + pytest.param(f'{EDGE_ZERO} + {EDGE_ZERO}', {}, id='every-addend'), + pytest.param('e', {'expressions.e': EDGE_ZERO}, id='a-named-expression'), + pytest.param( + 'e', + { + 'expressions.e': { + 'dims': ['g'], + 'cases': {'on': {'when': 'flag', 'expression': EDGE_ZERO}}, + 'otherwise': '1', + } + }, + id='a-case', + ), + pytest.param( + 'at(shift(d, along=h, offset=1, edge=0), by=lk[h])', {'parameters.d': {'dims': ['h']}}, id='a-lookup' + ), + ], +) +def test_a_divisor_a_shift_fills_with_zero_is_refused(divisor, patch): + """`shift(c, edge=0)` loaded as a divisor, so the row at the edge divided by the 0 that `edge=0` stands for.""" + message = _refusal(**patch, **{'constraints.cap': {'dims': ['g'], 'expression': f'p / ({divisor}) <= 1'}}) + vacates = ( + 'the divisor is 0 where shift(along=g, edge=0) vacates', + 'the divisor is 0 where shift(along=h, edge=0) vacates', + ) + assert any(v in message for v in vacates), message + assert 'which no number divides by' in message + assert "Write edge='wrap', or an edge= value other than 0" in message, 'the refusal names the rewrite' + + +@pytest.mark.parametrize( + 'divisor', + [ + pytest.param('shift(c, along=g, offset=1, edge=1)', id='another-edge'), + pytest.param("shift(c, along=g, offset=1, edge='wrap')", id='a-wrap'), + pytest.param('shift(c, along=g, offset=1, edge=0) + 1', id='an-addend-the-edge-does-not-zero'), + ], +) +def test_a_divisor_a_shift_fills_with_another_value_loads(divisor): + spec = to_spec(varied(SMALL_MODEL, **{'constraints.cap': {'dims': ['g'], 'expression': f'p / ({divisor}) <= 1'}})) + assert 'cap' in spec.program.constraints From b81bc0eab8e8b8258add1014cec22f31a182fdfa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:37:05 +0000 Subject: [PATCH 2/3] fix(language): a divisor that a shift fills with 0 at the edge is refused at load `edge=0` puts a 0 where a shift vacates, whatever the data holds, so `p / shift(c, along=t, offset=1, edge=0)` divided by 0 at the edge and loaded. The refusal walks the whole divisor: a factor, a numerator, a base, the operand of a sum, a window, a shift or a lookup, a named expression or a region of a case, and a sum only where every term can be 0. The message names the rewrite: `edge='wrap'`, or another edge value. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AvtZGymCbY9swLnR8JwHng --- docs/reference/language/expressions.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index 1fcb7dd5..2ef9bcc1 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -70,6 +70,14 @@ objective: because an expression reads no [dimension](#name-resolution). Write `x * x` for a square. +A divisor that `edge=0` can make 0 is refused at load, because the file itself +puts that 0 in. `p / shift(c, along=t, offset=1, edge=0)` divides by 0 at the +first `t`, whatever the data holds. The same holds where the shift is a factor, +a numerator, a base, the operand of a sum, another shift or a lookup, a named +expression or a region of a case, and in a sum only where every term can be 0: +`shift(c, along=t, offset=1, edge=0) + 1` loads. Write `edge='wrap'`, or an +`edge=` value other than 0. + ## Name resolution One flat namespace covers dimensions, relations, parameters, variables, named From 0209c0d44c8c69861e32e16d12ba2000d04e4615 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:37:28 +0000 Subject: [PATCH 3/3] docs(changelog): add the line for #889 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AvtZGymCbY9swLnR8JwHng --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b402f4cd..58b39d42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version +- fix(language): a divisor that a shift fills with 0 at the edge is refused at load ([#889](https://github.com/energy-models/mathspec/pull/889)) - docs(pypsa): an extendable unit has a capacity to choose exactly where its active flag is set, as in PyPSA ([#886](https://github.com/energy-models/mathspec/pull/886)) - refactor(api)!: a module outside spec, program and errors has a name that starts with an underscore ([#878](https://github.com/energy-models/mathspec/pull/878)) - feat(cli)!: `python -m mathspec diff` compares two specs in the canonical form, and `canonical` no longer rewrites or checks a file ([#874](https://github.com/energy-models/mathspec/pull/874))