From 743d0c75251858b71bed4d60da47df12dcad3afc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:18:08 +0200 Subject: [PATCH] refactor(program): the declaration vocabularies have one home, so a program cannot spell one differently from the file Closes #209 Co-Authored-By: Claude Opus 5 (1M context) --- src/math_spec/model.py | 14 ++++++++++--- src/math_spec/program.py | 45 +++++++++++++++++++++++----------------- tests/test_parser.py | 31 +++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 22 deletions(-) diff --git a/src/math_spec/model.py b/src/math_spec/model.py index e33d49d2..a9d6cb34 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -74,17 +74,25 @@ def _reject_unknown_keys(cls, data: Any) -> Any: return data -#: The dtype a dimension index may declare (the declaration rules). +#: The dtype a dimension index may declare (the declaration rules), and what +#: its labels are. ``datetime`` is a dimension's alone — labels on a timeline +#: order and compare, where a *value* of that type is a moment nothing +#: computes with. DimensionDtype = Literal['float', 'int', 'str', 'datetime'] #: The dtype a parameter may declare (the declaration rules), and what its bound -#: column must be. +#: column must be. ``bool`` is a parameter's alone — a value column may be a +#: flag a mask reads, where a label set of two members is a dimension nothing +#: indexes by. ParameterDtype = Literal['float', 'int', 'bool', 'str'] #: The domain a variable may declare. VariableDomain = Literal['continuous', 'integer', 'binary'] -#: What a masked variable's non-existence *means*. +#: What a masked variable's non-existence *means* where it does not exist. +#: ``undefined`` is the absence rules' default — a term carrying it takes its +#: row. ``zero`` says the quantity *is* zero there, so the term contributes +#: nothing and the row stands. VariableAbsence = Literal['undefined', 'zero'] #: Which way an objective is optimised (the declaration rules). diff --git a/src/math_spec/program.py b/src/math_spec/program.py index 66f94872..797722e6 100644 --- a/src/math_spec/program.py +++ b/src/math_spec/program.py @@ -14,7 +14,11 @@ *means*, with macros expanded, names typed, operators resolved to nodes and every dim rule already checked. Consumers dispatch on these nodes and read them; nothing here is built by hand, so what ships beside the nodes is the -walk (:func:`children`), not builders. +walk (:func:`children`), not builders. A program is trusted by construction: +:func:`~math_spec.lowering.to_program` is the only thing that builds one, and +nothing checks one assembled by hand. The language's refusals happen at load, +where the file and its author are, and a program put together some other way +is outside that guarantee rather than inside a pass restating it. What a consumer needs from this module falls in three, and only the middle one has to be *called* to be got right: @@ -32,7 +36,10 @@ A mask is the language's own resolved ``where`` node (:mod:`math_spec.where_parser`) rather than a second set spelling the same predicates — one home, so the two cannot come to disagree about what a -comparison is. +comparison is. The declaration vocabularies are the language's own for the +same reason (:mod:`math_spec.model`): a ``dtype``, a domain and an absence +reading cross into a program by a cast, and a member added to one spelling +alone would arrive as a string no consumer's branch recognises. Frozen dataclasses only — no execution logic, and nothing imported from a consumer. @@ -50,6 +57,7 @@ from types import MappingProxyType from typing import TYPE_CHECKING, Literal, NamedTuple, assert_never, get_args +import math_spec.model as _model from math_spec.errors import did_you_mean if TYPE_CHECKING: @@ -133,23 +141,22 @@ #: and hears about it when the language admits another. QUADRATIC_POSITIONS = frozenset(get_args(QuadraticPosition)) ComparisonOperator = Literal['==', '!=', '<=', '>=', '<', '>'] -VariableType = Literal['continuous', 'binary', 'integer'] - -#: What a masked variable's non-existence means where it does not exist. -#: ``undefined`` is the absence rules' default — a term carrying it takes its -#: row. ``zero`` says the quantity *is* zero there, so the term contributes -#: nothing and the row stands. -VariableAbsence = Literal['undefined', 'zero'] - -#: What a dimension's labels are. ``datetime`` is a dimension's alone — labels -#: on a timeline order and compare, where a *value* of that type is a moment -#: nothing computes with. -DimensionDtype = Literal['float', 'int', 'str', 'datetime'] - -#: What a parameter's values are. ``bool`` is a parameter's alone — a value -#: column may be a flag a mask reads, where a label set of two members is a -#: dimension nothing indexes by. -ParameterDtype = Literal['float', 'int', 'bool', 'str'] + +#: What a dimension's labels are — the language's own vocabulary +#: (:data:`~math_spec.model.DimensionDtype`), under the name a consumer reads +#: it by. +DimensionDtype = _model.DimensionDtype + +#: What a parameter's values are (:data:`~math_spec.model.ParameterDtype`). +ParameterDtype = _model.ParameterDtype + +#: What a masked variable's non-existence means +#: (:data:`~math_spec.model.VariableAbsence`). +VariableAbsence = _model.VariableAbsence + +#: A variable's domain (:data:`~math_spec.model.VariableDomain`), under the +#: name this module's field carries. +VariableType = _model.VariableDomain # -------------------------------------------------------------------------- diff --git a/tests/test_parser.py b/tests/test_parser.py index 28aaa932..b7425071 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -15,6 +15,7 @@ BinaryOperatorNode, ComparisonNode, FunctionCallNode, + NameListNode, NameNode, NumberNode, UnaryOperatorNode, @@ -103,6 +104,36 @@ def test_a_keyword_given_twice_is_refused_not_overwritten(): parse_expression('sum(p, over=snapshot, over=generator)') +def test_a_list_of_names_is_a_kwarg_value(): + """`by=[a, b]` is one value, so the operator reads one grouping and not two.""" + node = parse_expression('sum(p, by=[a, b])') + assert node.kwargs['by'] == NameListNode(('a', 'b')) + + +@pytest.mark.parametrize( + 'text', + [ + pytest.param('sum(p, by=[a,])', id='a-trailing-comma'), + pytest.param('sum(p, by=[])', id='no-names-at-all'), + pytest.param('sum(p, by=[a b])', id='a-missing-comma'), + pytest.param('sum(p, by=[a)', id='an-unclosed-bracket'), + pytest.param('sum([p], over=g)', id='a-positional-argument'), + pytest.param('p + [c]', id='a-term'), + pytest.param('[a, b]', id='the-whole-expression'), + ], +) +def test_a_list_the_grammar_cannot_read_is_refused_at_load(text): + """A list is a kwarg value and nothing else, and the last three say so. + + Which is a claim about the *grammar*: a list admitted as a term would be + a second thing `[a, b]` could mean, and one read past a missing comma + would be a grouping the file does not write. Neither is decidable later — + a parse is what every consumer starts from. + """ + with pytest.raises(SchemaError, match='Failed to parse expression'): + parse_expression(text) + + @pytest.mark.parametrize( ('text', 'value'), [('1e5', 1e5), ('2.5E-3', 2.5e-3), ('1e+3', 1e3), ('7.e2', 700.0)],