mathspec 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {mathspec-0.2.0 → mathspec-0.2.1}/PKG-INFO +30 -10
  2. {mathspec-0.2.0 → mathspec-0.2.1}/README.md +29 -9
  3. {mathspec-0.2.0 → mathspec-0.2.1}/pyproject.toml +3 -2
  4. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/__init__.py +3 -0
  5. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/__main__.py +51 -14
  6. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/_expression_parser.py +8 -4
  7. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/_expression_resolver.py +24 -8
  8. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/_where_parser.py +2 -1
  9. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/_where_resolver.py +28 -6
  10. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/advising.py +57 -5
  11. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/boundedness.py +4 -2
  12. mathspec-0.2.1/src/mathspec/canonical.py +268 -0
  13. mathspec-0.2.1/src/mathspec/composition.py +900 -0
  14. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/degree.py +1 -27
  15. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/dimensions.py +34 -8
  16. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/errors.py +4 -4
  17. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/exclusivity.py +0 -22
  18. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/expansion.py +23 -4
  19. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/lowering.py +107 -5
  20. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/operators.py +2 -2
  21. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/program.py +59 -9
  22. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/resolution.py +13 -8
  23. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/spec.py +144 -23
  24. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/typesetting/__init__.py +19 -2
  25. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/typesetting/legend.py +43 -2
  26. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/typesetting/symbols.py +34 -13
  27. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/typesetting/walk.py +36 -12
  28. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/validation.py +27 -4
  29. {mathspec-0.2.0 → mathspec-0.2.1}/.gitignore +0 -0
  30. {mathspec-0.2.0 → mathspec-0.2.1}/LICENSE +0 -0
  31. {mathspec-0.2.0 → mathspec-0.2.1}/LICENSES/Apache-2.0.txt +0 -0
  32. {mathspec-0.2.0 → mathspec-0.2.1}/LICENSES/CC-BY-4.0.txt +0 -0
  33. {mathspec-0.2.0 → mathspec-0.2.1}/LICENSES/CC0-1.0.txt +0 -0
  34. {mathspec-0.2.0 → mathspec-0.2.1}/LICENSES/MIT.txt +0 -0
  35. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/_sealed.py +0 -0
  36. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/_yaml.py +0 -0
  37. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/piecewise.py +0 -0
  38. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/py.typed +0 -0
  39. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/separability.py +0 -0
  40. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/sos.py +0 -0
  41. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/typesetting/README.md +0 -0
  42. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/typesetting/format.py +0 -0
  43. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/typesetting/latex.py +0 -0
  44. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/typesetting/markdown.py +0 -0
  45. {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/typesetting/typst.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mathspec
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Write the specification (spec) of an optimisation model as a YAML file. Check it and print it as math, with no data and no solver.
5
5
  Project-URL: Documentation, https://mathspec.readthedocs.io
6
6
  Project-URL: Repository, https://github.com/energy-models/mathspec
@@ -49,15 +49,6 @@ and `cost`; the decisions the solver makes, such as `dispatch`; and the rules
49
49
  those decisions obey, such as `sum(dispatch, over=generator) == load`. The file
50
50
  [below](#example) is a complete spec.
51
51
 
52
- <!--- --8<-- [start:engines] -->
53
-
54
- mathspec builds nothing and solves nothing itself.
55
- [specsolve](https://github.com/fluxopt/specsolve) and
56
- [linopy](https://github.com/PyPSA/linopy) build a model from a spec and its
57
- data, and solve it. Support in both is work in progress.
58
-
59
- <!--- --8<-- [end:engines] -->
60
-
61
52
  <!--- --8<-- [start:benefits] -->
62
53
 
63
54
  - **Check specs in CI, with no data.** A wrong name or
@@ -208,6 +199,35 @@ A [symbol table](https://mathspec.readthedocs.io/en/latest/reference/typeset/#sy
208
199
  conventional spelling, as in the folded block.
209
200
  [Print a spec as math](https://mathspec.readthedocs.io/en/latest/howto/print/) does the same from a shell.
210
201
 
202
+ ## Engines and other tools
203
+
204
+ <!--- --8<-- [start:engines] -->
205
+
206
+ mathspec builds nothing and solves nothing itself.
207
+ [specsolve](https://github.com/fluxopt/specsolve) and
208
+ [linopy](https://github.com/PyPSA/linopy) build a model from a spec and its
209
+ data, and solve it. Support in both is work in progress. Any other tool can
210
+ read the same spec through the
211
+ [Program API](https://mathspec.readthedocs.io/en/latest/reference/program/).
212
+ The solid boxes are mathspec; the dashed boxes are outside it.
213
+
214
+ ```mermaid
215
+ flowchart LR
216
+ accTitle: What mathspec does, and what other tools do with a spec
217
+ accDescr: A YAML file loads into a Spec and the Program it lowers to. mathspec checks the spec and prints it as math, with no data. Outside mathspec, drawn dashed, an engine such as specsolve or linopy reads the same spec, takes your data and returns your answers, and any other tool, such as a renderer or an analyser, reads the same spec through the Program API.
218
+ Y(["your spec<br/>one YAML file"]) --> SPEC["<b>Spec</b> and the <b>Program</b> it lowers to<br/><i>checked before any data exists</i>"]
219
+ SPEC --> CHECK["<b>check it</b><br/>python -m mathspec check"]
220
+ SPEC --> SHOW["<b>print it as math</b><br/>LaTeX · Typst · Markdown"]
221
+ SPEC -.-> OTHER["<b>your own tool</b><br/>a renderer · an analyser · …<br/>reads the Program API"]
222
+ SPEC -.-> ENGINE["<b>an engine</b><br/>specsolve · linopy<br/>builds and solves the model"]
223
+ DATA[("your data")] -.-> ENGINE
224
+ ENGINE -.-> ANS(["your answers"])
225
+ classDef outside stroke-dasharray:5 4
226
+ class ENGINE,DATA,ANS,OTHER outside
227
+ ```
228
+
229
+ <!--- --8<-- [end:engines] -->
230
+
211
231
  ## Documentation
212
232
 
213
233
  The documentation is at <https://mathspec.readthedocs.io>.
@@ -24,15 +24,6 @@ and `cost`; the decisions the solver makes, such as `dispatch`; and the rules
24
24
  those decisions obey, such as `sum(dispatch, over=generator) == load`. The file
25
25
  [below](#example) is a complete spec.
26
26
 
27
- <!--- --8<-- [start:engines] -->
28
-
29
- mathspec builds nothing and solves nothing itself.
30
- [specsolve](https://github.com/fluxopt/specsolve) and
31
- [linopy](https://github.com/PyPSA/linopy) build a model from a spec and its
32
- data, and solve it. Support in both is work in progress.
33
-
34
- <!--- --8<-- [end:engines] -->
35
-
36
27
  <!--- --8<-- [start:benefits] -->
37
28
 
38
29
  - **Check specs in CI, with no data.** A wrong name or
@@ -183,6 +174,35 @@ A [symbol table](https://mathspec.readthedocs.io/en/latest/reference/typeset/#sy
183
174
  conventional spelling, as in the folded block.
184
175
  [Print a spec as math](https://mathspec.readthedocs.io/en/latest/howto/print/) does the same from a shell.
185
176
 
177
+ ## Engines and other tools
178
+
179
+ <!--- --8<-- [start:engines] -->
180
+
181
+ mathspec builds nothing and solves nothing itself.
182
+ [specsolve](https://github.com/fluxopt/specsolve) and
183
+ [linopy](https://github.com/PyPSA/linopy) build a model from a spec and its
184
+ data, and solve it. Support in both is work in progress. Any other tool can
185
+ read the same spec through the
186
+ [Program API](https://mathspec.readthedocs.io/en/latest/reference/program/).
187
+ The solid boxes are mathspec; the dashed boxes are outside it.
188
+
189
+ ```mermaid
190
+ flowchart LR
191
+ accTitle: What mathspec does, and what other tools do with a spec
192
+ accDescr: A YAML file loads into a Spec and the Program it lowers to. mathspec checks the spec and prints it as math, with no data. Outside mathspec, drawn dashed, an engine such as specsolve or linopy reads the same spec, takes your data and returns your answers, and any other tool, such as a renderer or an analyser, reads the same spec through the Program API.
193
+ Y(["your spec<br/>one YAML file"]) --> SPEC["<b>Spec</b> and the <b>Program</b> it lowers to<br/><i>checked before any data exists</i>"]
194
+ SPEC --> CHECK["<b>check it</b><br/>python -m mathspec check"]
195
+ SPEC --> SHOW["<b>print it as math</b><br/>LaTeX · Typst · Markdown"]
196
+ SPEC -.-> OTHER["<b>your own tool</b><br/>a renderer · an analyser · …<br/>reads the Program API"]
197
+ SPEC -.-> ENGINE["<b>an engine</b><br/>specsolve · linopy<br/>builds and solves the model"]
198
+ DATA[("your data")] -.-> ENGINE
199
+ ENGINE -.-> ANS(["your answers"])
200
+ classDef outside stroke-dasharray:5 4
201
+ class ENGINE,DATA,ANS,OTHER outside
202
+ ```
203
+
204
+ <!--- --8<-- [end:engines] -->
205
+
186
206
  ## Documentation
187
207
 
188
208
  The documentation is at <https://mathspec.readthedocs.io>.
@@ -211,12 +211,13 @@ extend-exclude = ["CHANGELOG.md"]
211
211
  [tool.typos.default.extend-words]
212
212
  # Each of these is deliberate, and none is reachable by fixing a spelling.
213
213
  #
214
- # `wher` and `generatr` are misspellings *on purpose*: they are the input to the
215
- # did-you-mean suggester, so correcting them deletes the case. `missable` is the
214
+ # `wher`, `generatr` and `balnce` are misspellings *on purpose*: they are the
215
+ # input to the did-you-mean suggester, so correcting them deletes the case. `missable` is the
216
216
  # word the sentence wants — a coordinate that can go missing — and is not
217
217
  # "miscible". `unparseable` is the accepted variant and is the name of a test.
218
218
  wher = "wher"
219
219
  generatr = "generatr"
220
+ balnce = "balnce"
220
221
  missable = "missable"
221
222
  unparseable = "unparseable"
222
223
  # `fom` is PyPSA's `fom_cost` — fixed operation and maintenance — quoted by name
@@ -13,6 +13,7 @@ program instead.
13
13
 
14
14
  from mathspec import program
15
15
  from mathspec.advising import advice
16
+ from mathspec.composition import merge, override
16
17
  from mathspec.errors import Advice, AdviceKind, DimensionError, LanguageError, MathSpecError, SchemaError, did_you_mean
17
18
  from mathspec.operators import BUILTIN_NAMES
18
19
  from mathspec.spec import Spec
@@ -40,6 +41,8 @@ __all__ = [
40
41
  'SymbolTable',
41
42
  'advice',
42
43
  'did_you_mean',
44
+ 'merge',
45
+ 'override',
43
46
  'program',
44
47
  'to_latex',
45
48
  'to_markdown',
@@ -4,9 +4,11 @@
4
4
 
5
5
  """``python -m mathspec <verb> spec.yaml`` — the shell front.
6
6
 
7
- ``check`` loads the file and prints the language's advice; one further verb
8
- per typeset format, read off [`mathspec.typesetting.FORMATS`][]. Every verb
9
- reads the file as written, and nothing here writes a formulation out unasked.
7
+ ``check`` loads the file and prints the language's advice; ``canonical``
8
+ writes the spec in the form two files that state the same spec share, or with
9
+ ``--check`` asks whether the file is already in that form; one further verb per
10
+ typeset format, read off [`mathspec.typesetting.FORMATS`][]. Every verb reads
11
+ the file as written, and nothing here writes a formulation out unasked.
10
12
  The typeset verbs take ``--expand``, because a shell cannot compose
11
13
  [`expand`][mathspec.spec.Spec.expand] the way a caller does and the rows are a
12
14
  different document; ``check`` has no such flag, because advice reads a block
@@ -33,6 +35,13 @@ def parser() -> argparse.ArgumentParser:
33
35
  check = verbs.add_parser('check', help='load a spec, and print what the language advises')
34
36
  check.add_argument('spec', help='path to a mathspec YAML file')
35
37
 
38
+ canonical = verbs.add_parser('canonical', help='write a spec in the form two files that state it share')
39
+ canonical.add_argument('spec', help='path to a mathspec YAML file')
40
+ target = canonical.add_mutually_exclusive_group()
41
+ target.add_argument('-o', '--out', help='write here instead of stdout')
42
+ target.add_argument('--write', action='store_true', help='rewrite the file in the form, dropping its comments')
43
+ target.add_argument('--check', action='store_true', help='exit 1 if the file is not in the form, writing nothing')
44
+
36
45
  for name in FORMATS:
37
46
  verb = verbs.add_parser(name, help=f'render a spec as {name}')
38
47
  verb.add_argument('spec', help='path to a mathspec YAML file')
@@ -52,10 +61,26 @@ def parser() -> argparse.ArgumentParser:
52
61
  return front
53
62
 
54
63
 
64
+ def _checked(path: str, text: str) -> int:
65
+ """Exit status 0 if the file at *path* already holds *text*, the form, and 1 with the rewrite on stderr if not.
66
+
67
+ A CI job runs this, so it compares bytes and writes nothing: the job fails,
68
+ and the author runs the rewrite the message names.
69
+ """
70
+ if Path(path).read_text(encoding='utf-8') == text:
71
+ return 0
72
+ sys.stderr.write(
73
+ f'{path} is not in the canonical form. Run `python -m mathspec canonical --write {path}` to rewrite it.\n'
74
+ )
75
+ return 1
76
+
77
+
55
78
  def main(argv: list[str] | None = None) -> int:
56
79
  """Run one verb; a refused file is its message on stderr and exit status 1.
57
80
 
58
- Advice is not a refusal: ``check`` prints it and exits 0.
81
+ Advice is not a refusal: ``check`` prints it and exits 0. A file that
82
+ ``canonical --check`` finds out of the form exits 1, with the rewrite on
83
+ stderr.
59
84
  """
60
85
  args = parser().parse_args(argv)
61
86
  if args.verb == 'check':
@@ -66,16 +91,28 @@ def main(argv: list[str] | None = None) -> int:
66
91
  return 1
67
92
  sys.stdout.write(''.join(f'{note}\n' for note in notes))
68
93
  return 0
69
- spec = to_spec(args.spec).expand() if args.expand else args.spec
70
- text = typeset(
71
- spec,
72
- args.verb,
73
- symbols=args.symbols,
74
- standalone=args.standalone,
75
- legend=not args.no_legend,
76
- numbered=not args.no_numbers,
77
- inline_expressions=args.inline_expressions,
78
- )
94
+ if args.verb == 'canonical':
95
+ try:
96
+ text = to_spec(args.spec).to_yaml(canonical=True)
97
+ except MathSpecError as e:
98
+ sys.stderr.write(f'{e}\n')
99
+ return 1
100
+ if args.check:
101
+ return _checked(args.spec, text)
102
+ if args.write:
103
+ Path(args.spec).write_text(text, encoding='utf-8')
104
+ return 0
105
+ else:
106
+ spec = to_spec(args.spec).expand() if args.expand else args.spec
107
+ text = typeset(
108
+ spec,
109
+ args.verb,
110
+ symbols=args.symbols,
111
+ standalone=args.standalone,
112
+ legend=not args.no_legend,
113
+ numbered=not args.no_numbers,
114
+ inline_expressions=args.inline_expressions,
115
+ )
79
116
  if args.out:
80
117
  Path(args.out).write_text(text, encoding='utf-8')
81
118
  else:
@@ -220,6 +220,13 @@ def with_children(node: ArithmeticNode, recurse: Callable[[ArithmeticNode], Arit
220
220
  # ---------------------------------------------------------------------------
221
221
 
222
222
 
223
+ #: A bracketed list of names as a kwarg value, in an expression and in a where
224
+ #: string alike.
225
+ NAME_LIST = (pp.Suppress('[') + pp.DelimitedList(pp.Regex(NAME)) + pp.Suppress(']')).set_parse_action(
226
+ lambda t: NameListNode(tuple(str(x) for x in t))
227
+ )
228
+
229
+
223
230
  def _build_grammar() -> tuple[pp.ParserElement, pp.ParserElement]:
224
231
  """The arithmetic grammar, and the expression grammar that puts one comparison over it.
225
232
 
@@ -235,10 +242,7 @@ def _build_grammar() -> tuple[pp.ParserElement, pp.ParserElement]:
235
242
  name = pp.Regex(NAME)
236
243
 
237
244
  quoted = (pp.QuotedString("'") | pp.QuotedString('"')).set_parse_action(lambda t: KeywordNode(str(t[0])))
238
- name_list = (pp.Suppress('[') + pp.DelimitedList(name) + pp.Suppress(']')).set_parse_action(
239
- lambda t: NameListNode(tuple(str(x) for x in t))
240
- )
241
- kwarg = (name + pp.Suppress('=') + (quoted | name_list | arith)).set_parse_action(lambda t: (t[0], t[1]))
245
+ kwarg = (name + pp.Suppress('=') + (quoted | NAME_LIST | arith)).set_parse_action(lambda t: (t[0], t[1]))
242
246
  pos_arg = arith
243
247
  arg_list = pp.Optional(pp.DelimitedList(kwarg | pos_arg))
244
248
  func_call = (name + pp.Suppress('(') + arg_list + pp.Suppress(')')).set_parse_action(_make_func_call)
@@ -182,7 +182,7 @@ class ExpressionResolver:
182
182
  """
183
183
  if node.name in self.formals:
184
184
  return None
185
- if node.name in self.ns.schema.expressions:
185
+ if node.name in self.ns.bodies:
186
186
  try:
187
187
  return cast('Expression', self.ns.named(node.name, self.context))
188
188
  except SchemaError as e:
@@ -243,7 +243,7 @@ class ExpressionResolver:
243
243
  f'{self.context}: {node.name}({", ".join(f"{k}=" for k in roles)}) names a column of a relation, '
244
244
  f'and no by= names the relation. Write {builtin.usage}'
245
245
  )
246
- dims: dict[str, str | None] = {}
246
+ dims: dict[str, tuple[str, ...] | None] = {}
247
247
  amounts: dict[str, int | str | None] = {}
248
248
  edge: _Edge | None = None
249
249
  for key, value in node.kwargs.items():
@@ -251,14 +251,15 @@ class ExpressionResolver:
251
251
  case 'edge':
252
252
  edge = self._edge(value, node.name)
253
253
  case 'dimension':
254
- dims[key] = self._dim_ref(value, node.name, key)
254
+ dims[key] = self._dim_refs(value, node.name, key, several=key in builtin.dimension_or_role_kwargs)
255
255
  case 'value':
256
256
  amounts[key] = self._amount(value, node.name, key)
257
257
  case 'relation' | 'role' | None:
258
258
  pass
259
259
  read = None
260
260
  if 'by' in node.kwargs and builtin.kind_of('by') == 'relation':
261
- read = self.relation_ref(node.kwargs['by'], node.name, 'by', roles, dims.get('along'))
261
+ along = dims.get('along')
262
+ read = self.relation_ref(node.kwargs['by'], node.name, 'by', roles, along[0] if along else None)
262
263
  unread = (
263
264
  shape_error is not None
264
265
  or unrelated
@@ -277,7 +278,7 @@ class ExpressionResolver:
277
278
  self,
278
279
  operator: str,
279
280
  operand: Expression,
280
- dims: Mapping[str, str | None],
281
+ dims: Mapping[str, tuple[str, ...] | None],
281
282
  amounts: Mapping[str, int | str | None],
282
283
  edge: _Edge | None,
283
284
  read: Direction | Partition | None,
@@ -288,14 +289,15 @@ class ExpressionResolver:
288
289
  assert isinstance(read, Direction), 'a sum reads its relation in a direction'
289
290
  return GroupSum(operand, read)
290
291
  if (over := dims.get('over')) is not None:
291
- return Sum(operand, (over,))
292
+ return Sum(operand, over)
292
293
  return self._bare_sum(operand)
293
294
  if operator == 'at':
294
295
  assert isinstance(read, Direction), 'at reads its relation in a direction'
295
296
  return Pullback(operand, read)
296
297
  assert read is None or isinstance(read, Partition), 'a translation reads its relation as a partition'
297
- along = dims['along']
298
- assert along is not None
298
+ named = dims['along']
299
+ assert named is not None, 'a translation names the dimension it steps along'
300
+ (along,) = named
299
301
  wrap, fill = edge if edge is not None else (False, None)
300
302
  if operator == 'shift':
301
303
  offset = amounts['offset']
@@ -421,6 +423,20 @@ class ExpressionResolver:
421
423
  return None
422
424
  return False, literal.value
423
425
 
426
+ def _dim_refs(self, value: ArithmeticNode, operator: str, key: str, *, several: bool) -> tuple[str, ...] | None:
427
+ """An operator kwarg whose *value* names declared dimensions: one, or a list where *several* are allowed."""
428
+ if not (several and isinstance(value, NameListNode)):
429
+ found = self._dim_ref(value, operator, key)
430
+ return None if found is None else (found,)
431
+ if repeated := sorted({name for name in value.names if value.names.count(name) > 1}):
432
+ self.errors.append(
433
+ f'{self.context}: {operator}({key}={value}) names {", ".join(map(repr, repeated))} twice. '
434
+ f'Name each dimension once.'
435
+ )
436
+ return None
437
+ found = [self._dim_ref(NameNode(name), operator, key) for name in value.names]
438
+ return None if None in found else tuple(cast('list[str]', found))
439
+
424
440
  def _dim_ref(self, value: ArithmeticNode, operator: str, key: str) -> str | None:
425
441
  """An operator kwarg whose *value* must name a declared dimension."""
426
442
  if self._formal(value):
@@ -23,6 +23,7 @@ import pyparsing as pp
23
23
  from mathspec._expression_parser import (
24
24
  ARITHMETIC,
25
25
  NAME,
26
+ NAME_LIST,
26
27
  ArithmeticNode,
27
28
  KeywordNode,
28
29
  NameNode,
@@ -154,7 +155,7 @@ def _build_where_grammar() -> pp.ParserElement:
154
155
  )
155
156
  comparator = pp.one_of(list(get_args(PredicateOperator)))
156
157
 
157
- kwarg = (name + pp.Suppress('=') + (quoted | ARITHMETIC)).set_parse_action(lambda t: (t[0], t[1]))
158
+ kwarg = (name + pp.Suppress('=') + (quoted | NAME_LIST | ARITHMETIC)).set_parse_action(lambda t: (t[0], t[1]))
158
159
 
159
160
  def _call(head: pp.ParserElement) -> pp.ParserElement:
160
161
  """``<head>(<predicate>[, <kwarg>…])`` — the one shape whose operand is a predicate.
@@ -72,6 +72,7 @@ from mathspec.program import (
72
72
  TypedPredicate,
73
73
  VariableDefined,
74
74
  carries_variable,
75
+ variables_of,
75
76
  walk,
76
77
  )
77
78
 
@@ -81,6 +82,14 @@ if TYPE_CHECKING:
81
82
  from mathspec.program import DeclaredDtype
82
83
  from mathspec.resolution import Namespace
83
84
 
85
+ #: Why a mask may not read a given expression. The namespace files a given
86
+ #: expression with the variables, since it is read as a column, so the refusal
87
+ #: names it apart.
88
+ GIVEN_IN_A_MASK = (
89
+ 'A given expression may hold a variable, and a where mask is built before variables exist — it may test '
90
+ 'parameters and dimension coordinates only.'
91
+ )
92
+
84
93
 
85
94
  @dataclass(frozen=True)
86
95
  class WhereResolver:
@@ -323,9 +332,9 @@ class WhereResolver:
323
332
  value = literal.value
324
333
  else:
325
334
  value = _side_name(right)
326
- if value is not None and (value in ns.schema.expressions or ns.kind(value) == 'parameter'):
335
+ if value is not None and (value in ns.bodies or ns.kind(value) == 'parameter'):
327
336
  return None
328
- if name is None or value is None or name in ns.schema.expressions:
337
+ if name is None or value is None or name in ns.bodies:
329
338
  return None
330
339
  return _Plain(name, node.op, value, quoted)
331
340
 
@@ -361,7 +370,12 @@ class WhereResolver:
361
370
  assert len(sides) == 2, 'a side of a where builds or refuses, since a where holds no formal'
362
371
  dims: set[str] = set()
363
372
  for side in sides:
364
- if carries_variable(side):
373
+ if given := sorted(variables_of(side) & set(ns.schema.given.expressions)):
374
+ self.errors.append(
375
+ f'{context}: a where compares expressions, and one side reads the given expression '
376
+ f'{given[0]!r}. {GIVEN_IN_A_MASK}'
377
+ )
378
+ elif carries_variable(side):
365
379
  self.errors.append(
366
380
  f'{context}: a where compares expressions, and one side names a variable. A where mask '
367
381
  f'is built before variables exist — it may test parameters and dimension coordinates only.'
@@ -453,7 +467,8 @@ class WhereResolver:
453
467
  return node
454
468
  dims = tuple(ns.relations[left_name].dim(k) for k in ns.relations[left_name].key)
455
469
  return RelationPairComparison(left_name, left, right_name, right, plain.op, dims)
456
- self.errors.append(_declared_rhs_error(context, plain, value, rhs_kind))
470
+ given = right_name in ns.schema.given.expressions
471
+ self.errors.append(_declared_rhs_error(context, plain, value, rhs_kind, given=given))
457
472
  return node
458
473
 
459
474
  kind = ns.kind(left_name)
@@ -491,6 +506,8 @@ class WhereResolver:
491
506
  assert column is not None
492
507
  shape = ns.relations[left_name]
493
508
  return RelationComparison(left_name, column, plain.op, value, tuple(shape.dim(k) for k in shape.key))
509
+ case 'variable' if left_name in ns.schema.given.expressions:
510
+ self.errors.append(f"{context}: where references the given expression '{left_name}'. {GIVEN_IN_A_MASK}")
494
511
  case 'variable':
495
512
  self.errors.append(
496
513
  f"{context}: where references variable '{left_name}'. A where "
@@ -693,9 +710,14 @@ def _not_arithmetic(context: str, side: ColumnNode | KeywordNode) -> str:
693
710
  )
694
711
 
695
712
 
696
- def _declared_rhs_error(context: str, node: _Plain, value: str, kind: str) -> str:
697
- """Why the right-hand side of a where-comparison may not name a variable, a relation or a dimension."""
713
+ def _declared_rhs_error(context: str, node: _Plain, value: str, kind: str, *, given: bool = False) -> str:
714
+ """Why the right-hand side of a where-comparison may not name a variable, a relation or a dimension.
715
+
716
+ A given expression is filed with the variables, and *given* says the name is one.
717
+ """
698
718
  comparison = f"'{node.name} {node.op} {value}'"
719
+ if given:
720
+ return f'{context}: {comparison} compares against the given expression {value!r}. {GIVEN_IN_A_MASK}'
699
721
  if kind == 'variable':
700
722
  return (
701
723
  f'{context}: {comparison} compares against variable {value!r}. '
@@ -35,15 +35,57 @@ def advice(spec: str | Path | Mapping[str, object] | Spec | Program) -> tuple[Ad
35
35
  nothing expanded.
36
36
 
37
37
  Returns:
38
- The never-an-axis advice in declaration order, then the unboundedness
39
- advice; ``str()`` of each is its sentence.
38
+ The never-an-axis advice in declaration order, then one note per
39
+ declaration the program reads and does not build, then the
40
+ unboundedness advice; ``str()`` of each is its sentence.
40
41
 
41
42
  Raises:
42
43
  LanguageError: *spec* does not load; [`to_spec`][] says why.
43
44
  FileNotFoundError: A ``str`` with no newline that names no file.
44
45
  """
45
46
  program = spec if isinstance(spec, Program) else to_spec(spec).program
46
- return tuple(_never_an_axis(program) + unbounded_notes(program))
47
+ return tuple(_never_an_axis(program) + _given(program) + unbounded_notes(program))
48
+
49
+
50
+ def _given(program: Program) -> list[Advice]:
51
+ """One note per declaration the program reads and does not build.
52
+
53
+ A note rather than a refusal: the file is a spec somebody meant, and only
54
+ the consumer can tell whether the model it is layered onto provides the
55
+ name. An expression this file adds a term to is completed by `merge`
56
+ rather than by a host, so its note says that instead.
57
+ """
58
+ given = program.given
59
+ written = {entry.adds_to for entry in program.expressions.values()}
60
+ return [
61
+ *(Advice('given', name, _given_note('parameter', name)) for name in given.parameters),
62
+ *(Advice('given', name, _given_note('variable', name)) for name in given.variables),
63
+ *(
64
+ Advice(
65
+ 'given',
66
+ name,
67
+ _term_note(name) if name in written else _given_note('expression', name),
68
+ )
69
+ for name in given.expressions
70
+ ),
71
+ *(Advice('given', name, _given_note('row family', name)) for name in given.constraints),
72
+ ]
73
+
74
+
75
+ def _given_note(kind: str, name: str) -> str:
76
+ return (
77
+ f"{kind} '{name}' is read here and declared elsewhere: the model this one is layered onto "
78
+ f'provides it. A consumer checks that it does, on the same frame, and refuses the program where '
79
+ f'it does not. A fragment is composed instead: merge() folds this declaration into the one a '
80
+ f'sibling introduces{", or writes it from the terms siblings add" if kind == "expression" else ""}.'
81
+ )
82
+
83
+
84
+ def _term_note(name: str) -> str:
85
+ return (
86
+ f"expression '{name}' is read here, and this file adds a term to it: merge() sums the term with what "
87
+ f'the other files write under the name. Until then, the program reads it and does not build it.'
88
+ )
47
89
 
48
90
 
49
91
  def _never_an_axis(program: Program) -> list[Advice]:
@@ -51,10 +93,20 @@ def _never_an_axis(program: Program) -> list[Advice]:
51
93
 
52
94
  A dimension a relation has a column over is reached: its members are the
53
95
  labels that column is checked against, and a ``where`` selects on them,
54
- so it is in use even where nothing is indexed by it.
96
+ so it is in use even where nothing is indexed by it. A dimension only a
97
+ given declaration indexes is reached too: the column exists, in another
98
+ file.
55
99
  """
56
100
  reached: set[str] = set()
57
- for declaration in (*program.parameters.values(), *program.variables.values(), *program.constraints.values()):
101
+ for declaration in (
102
+ *program.parameters.values(),
103
+ *program.variables.values(),
104
+ *program.constraints.values(),
105
+ *program.given.parameters.values(),
106
+ *program.given.variables.values(),
107
+ *program.given.expressions.values(),
108
+ *program.given.constraints.values(),
109
+ ):
58
110
  reached.update(declaration.dims)
59
111
  reached |= _produced_axes(program)
60
112
  reached |= {dim for lk in program.relations.values() for dim in lk.dims}
@@ -59,7 +59,9 @@ def unbounded_notes(program: Program) -> list[Advice]:
59
59
 
60
60
  Returns:
61
61
  One note per variable that is unbounded on the side its objective term
62
- improves toward and named by no constraint.
62
+ improves toward and named by no constraint. A given column, which the
63
+ program reads and does not declare, has its bounds where it is built,
64
+ so it gets none.
63
65
  """
64
66
  if program.objective is None:
65
67
  return []
@@ -76,7 +78,7 @@ def unbounded_notes(program: Program) -> list[Advice]:
76
78
  minimize = program.objective.sense == 'minimize'
77
79
  notes: list[Advice] = []
78
80
  for vname, sign in signs.items():
79
- if sign is None or vname in constrained:
81
+ if sign is None or vname in constrained or vname not in program.variables:
80
82
  continue
81
83
  side: BoundSide = 'lower' if minimize == (sign == '+') else 'upper'
82
84
  if _is_open(program.variables[vname], side):