diff --git a/docs/reference/language/piecewise.md b/docs/reference/language/piecewise.md index 9730c3b1..eed01470 100644 --- a/docs/reference/language/piecewise.md +++ b/docs/reference/language/piecewise.md @@ -65,7 +65,9 @@ curve, and the [typeset output](../typeset.md) prints the curve itself. their own, which is the model a consumer that builds rows reads. A link names the row it writes, so a link may not take a name the block -already writes for itself, such as `convexity` or `lam`. +already writes for itself, such as `convexity` or `lam`. No two blocks may write +the same name: in a file with blocks `a` and `a_b`, a link `b_x` of `a` is +refused, because its row `a_b_x` is also the row of the link `x` of `a_b`. The breakpoint order is the declared order of `along`. A curve whose breakpoints decrease in that order is refused when the data binds. @@ -222,9 +224,14 @@ writes `at(coupling_lam, by=generator_of, over=generator, into=flow)` into that row, so the weights stay on `dims:` and the model never names them. `by:`, `over:` and `into:` are written together. A walk states the relation, the -columns it consumes and the columns it produces, and none is defaulted. A link -whose row is finer than `dims:` is always a walk: a link that names only -`into:` is refused. +columns it consumes and the columns it produces, and none is defaulted. Each of +`over:` and `into:` names at least one column. A link whose row is finer than +`dims:` is always a walk: a link that names only `into:` is refused. + +A walk is held to every rule of `at`, as the model loads, and a refusal names +the link. `into:` names key columns of the relation, and the read has one value +at each coordinate it lands on. A key column that the walk does not name is +joined on, so its dimension is one of `dims:`. A block whose only link walks a relation is a curve. Two links is what a curve needs when a link is one row; a walked link is one row per fine coordinate, so @@ -250,17 +257,21 @@ The `power` row is built where `at(has_curve, by=generator_of, over=generator, into=flow)` holds, which is at every flow of a generator with a curve. The values of a walked link are asked for at the same rows, so a flow of a generator with no curve needs no row in -`bp_power`. A mask over dimensions the walk keeps, such as `snapshot` alone, -reaches the row as written. A mask that carries some of the dimensions the walk -reads through and not the others is refused, and the message names the ones -missing. +`bp_power`. A mask that carries no dimension the walk consumes, such as +`snapshot` alone, reaches the row as written. This is also true when the +relation is keyed on `snapshot` too, because the row keeps every dimension the +walk joins on. A mask that carries a dimension the walk consumes and not every +dimension the walk joins on is refused, and the message names the ones missing. +A mask over a dimension a walk produces, such as `flow`, is refused: the mask +says which curves exist, and there is one curve per coordinate of `dims:`. | A walked link | | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | _over_ | names a column over a dimension of `dims:` | -| _into_ | names a column over a dimension that `dims:` does not carry, and that is not `along` | +| _into_ | names key columns over dimensions that `dims:` does not carry, and that are not `along` | +| _by_ | a relation whose other key columns are over dimensions of `dims:` | | _values_ | follows the **link's** row: `bp_power` is per flow, not per generator | -| `where:` | on the block reaches the link's row read through the relation, or as written where the mask carries none of the dimensions the walk reads through | +| `where:` | on the block reaches the link's row read through the relation, or as written where the mask carries none of the dimensions the walk consumes | | `method:` | `adjacency` or `sos2`. `lp` loses the abscissa its segment line is written against, and `convex` loses the pair of values parameters it reads a shape from | ### Signs diff --git a/src/math_spec/_expression_resolver.py b/src/math_spec/_expression_resolver.py index 2d0f7423..7a9bd07f 100644 --- a/src/math_spec/_expression_resolver.py +++ b/src/math_spec/_expression_resolver.py @@ -502,7 +502,7 @@ def relation_ref( return self.partition(name, operator, along, named['within']) if not ({'over', 'into'} <= set(named)): return None # refused already, by the call shape or by the role that named no column - return self._direction(name, operator, named['over'], named['into']) + return self.direction(name, operator, named['over'], named['into']) def _role_name(self, value: ArithmeticNode, operator: str, key: str) -> tuple[str, ...] | None: """``over=`` or ``into=`` as the column names it must be — one bare name, or a bracketed list of them.""" @@ -513,7 +513,7 @@ def _role_name(self, value: ArithmeticNode, operator: str, key: str) -> tuple[st ) return None - def _direction( + def direction( self, name: str, operator: str, diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index 3de54aed..3e4559ba 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -20,7 +20,7 @@ from math_spec.dimensions import check_schema, dims_of from math_spec.errors import SchemaError, prefixed from math_spec.expansion import expand, parse_template -from math_spec.piecewise import assumptions_of, declaration_of, lp_domain_refusal, resolve_links +from math_spec.piecewise import assumptions_of, declaration_of, lp_domain_refusal, resolve_links, resolve_walks from math_spec.program import ( Assumption, BooleanLiteral, @@ -52,7 +52,7 @@ if TYPE_CHECKING: from math_spec.model import AssumptionBlock, Spec - from math_spec.program import Expression + from math_spec.program import Direction, Expression def lower(schema: Spec) -> Program: @@ -159,15 +159,16 @@ def lower(schema: Spec) -> Program: if (assumption := _assumption(aname, adef, ns, errors)) is not None: assumptions[aname] = assumption - curves: dict[str, tuple[tuple[Expression, ...], Mask | None]] = {} + curves: dict[str, tuple[tuple[Expression, ...], dict[str, Direction], Mask | None]] = {} for pname, pdef in schema.piecewise.items(): links = resolve_links(pname, pdef, ns, errors) + walks = resolve_walks(pname, pdef, ns, errors) where = mask_of(resolve_where_text(pdef.where, ns, f"piecewise '{pname}' where", errors)) - if links is None: + if links is None or walks is None: continue if pdef.method == 'lp' and (refusal := lp_domain_refusal(pname, pdef, links)) is not None: errors.append(refusal) - curves[pname] = (links, where) + curves[pname] = (links, walks, where) if errors: raise SchemaError('\n'.join(errors)) @@ -175,13 +176,13 @@ def lower(schema: Spec) -> Program: roots = [side for c in constraints.values() for side in (c.lhs, c.rhs)] if objective is not None: roots.append(objective.expression) - roots.extend(link for links, _ in curves.values() for link in links) + roots.extend(link for links, _, _ in curves.values() for link in links) in_math = frozenset(node.name for node in walk(*roots) if isinstance(node, Named)) piecewise = {} - for pname, (links, where) in curves.items(): + for pname, (links, walks, where) in curves.items(): pdef = schema.piecewise[pname] - piecewise[pname] = declaration_of(schema, pname, pdef, links, where) + piecewise[pname] = declaration_of(schema, pname, pdef, links, walks, where) for aname, assumed in assumptions_of(pname, piecewise[pname], pdef.where).items(): assumption = _assumption(aname, assumed, ns, errors) assert assumption is not None and not errors, 'what a method assumes is stated in the language' diff --git a/src/math_spec/model.py b/src/math_spec/model.py index cca5c76d..7f5d8e82 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -539,6 +539,12 @@ def _check_walk(self) -> PiecewiseLink: f'consumes and the columns it produces, as at() does; none is defaulted.' ) raise ValueError(msg) + if empty := [k for k in ('over', 'into') if written[k] == []]: + msg = ( + f'{empty[0]}: [] names no column — a walk consumes at least one column of the relation and produces ' + f'at least one. Name a column, or a list of them.' + ) + raise ValueError(msg) return self @model_validator(mode='before') diff --git a/src/math_spec/piecewise.py b/src/math_spec/piecewise.py index 5dddb674..a6b885e8 100644 --- a/src/math_spec/piecewise.py +++ b/src/math_spec/piecewise.py @@ -23,7 +23,8 @@ import math_spec.sos as sos from math_spec._expression_parser import NAME -from math_spec.dimensions import dims_of +from math_spec._expression_resolver import ExpressionResolver +from math_spec.dimensions import dims_of, pulled_back_dims from math_spec.errors import DimensionError from math_spec.model import AssumptionBlock, Curvature, PiecewiseBlock, PiecewiseLink, Spec, VariableBlock from math_spec.program import Link, PiecewiseDeclaration, PiecewiseMethod, VariableDeclaration, carries_variable @@ -32,7 +33,7 @@ if TYPE_CHECKING: from collections.abc import Iterable - from math_spec.program import Expression, Mask + from math_spec.program import Direction, Expression, Mask from math_spec.resolution import Namespace @@ -154,11 +155,12 @@ def assumptions_of(name: str, curve: PiecewiseDeclaration, where: str | None) -> """ d = curve.along mask, frame, exists = _masks(where, d, ragged=curve.ragged) - rewrite = ( - f'Bind the rows, or narrow where: {mask!r} to where the curve runs.' - if mask is not None - else 'Bind the rows, or declare where: to say how far the curve runs.' - ) + if mask is not None: + rewrite = f'Bind the rows, or narrow where: {mask!r} to where the curve runs.' + elif where is not None: + rewrite = f"Bind the rows, or let where: {where!r} test '{d}' too, to say how far each curve runs." + else: + rewrite = 'Bind the rows, or declare where: to say how far the curve runs.' assumed: dict[str, AssumptionBlock] = {} if values := [link.values for link in curve.links if not link.walks]: assumed[f'{name}_complete'] = AssumptionBlock( @@ -408,6 +410,31 @@ def resolve_links(name: str, pw: PiecewiseBlock, ns: Namespace, errors: list[str return tuple(link for link in links if link is not None) +def resolve_walks(name: str, pw: PiecewiseBlock, ns: Namespace, errors: list[str]) -> dict[str, Direction] | None: + """Block *name*'s walks by link key, each read as ``at`` reads its relation, or ``None`` once one failed. + + The expansion writes a walked row as ``at(_lam, by=, over=, + into=)``, so a walk is held to every rule that call is held to, and + refused here on the link the file wrote. Each refusal is appended to + *errors*. + """ + walks: dict[str, Direction] = {} + failed = False + for key, link in pw.links.items(): + if not link.walks: + continue + assert link.by is not None + resolver = ExpressionResolver(ns, f"piecewise '{name}' link '{key}'", errors) + if (problem := resolver.not_a_relation(link.by, 'at', 'by')) is not None: + errors.append(problem) + failed = True + elif (direction := resolver.direction(link.by, 'at', _named(link.over), _named(link.into))) is None: + failed = True + else: + walks[key] = direction + return None if failed else walks + + def lp_domain_refusal(name: str, pw: PiecewiseBlock, links: tuple[Expression, ...]) -> str | None: """The refusal for a ``method: lp`` curve whose x-link carries no variable, or ``None``. @@ -429,30 +456,40 @@ def lp_domain_refusal(name: str, pw: PiecewiseBlock, links: tuple[Expression, .. def declaration_of( - schema: Spec, name: str, pw: PiecewiseBlock, links: tuple[Expression, ...], where: Mask | None + schema: Spec, + name: str, + pw: PiecewiseBlock, + links: tuple[Expression, ...], + walks: dict[str, Direction], + where: Mask | None, ) -> PiecewiseDeclaration: - """Block *name* as the program carries it, with *links* and *where* typed, every fit rule decided. + """Block *name* as the program carries it, with *links*, *walks* and *where* typed, every fit rule decided. - Each link's row is ``dims:``, or its refinement through the link's - relation; its expression carries exactly that row, its values parameter + A walk reads the curve's weights at the block's own dims, so it consumes + dims of ``dims:``, joins on dims of ``dims:``, and produces dims of its + own. Each link's row is ``dims:``, or its refinement through the link's + walk; its expression carries exactly that row, its values parameter varies along it and the breakpoint dim and nothing else, and the ``where:`` tests ``dims:`` and the breakpoint dim alone. A walked row - reads the where through its relation when the mask carries every dim the - walk reads the curve at. Decided here, on the link the file wrote, rather - than on the emitted declarations, whose refusal would name ``_lam`` - — a variable the author never wrote. + reads the where through its relation when the mask carries a dim the + walk consumes. Decided here, on the link the file wrote, rather than on + the emitted declarations, whose refusal would name ``_lam`` — a + variable the author never wrote. Raises: - DimensionError: A link that does not fit its row, a where outside - ``dims:``, or a mask carrying part of what a walk reads through. + DimensionError: A walk that does not fit ``dims:``, a link that does + not fit its row, a where outside ``dims:``, or a mask carrying + part of what a walk reads through. """ ctx = f"piecewise '{name}'" - rows = {key: _row(schema, pw, link) for key, link in pw.links.items()} + for key, walk in walks.items(): + _walk_fits(f"{ctx} link '{key}'", pw, walk) + rows = {key: _row(schema, pw, walks.get(key)) for key in pw.links} for node, (key, row) in zip(links, rows.items(), strict=True): _link_fits(ctx, key, pw, dims_of(node, schema, f"{ctx} link '{key}'"), row) for (key, link), row in zip(pw.links.items(), rows.values(), strict=True): _values_fit(schema, ctx, key, pw, link, row) - _where_fits(ctx, pw, where) + _where_fits(ctx, pw, where, walks) carried = (where.dims if where is not None else frozenset()) - {pw.along} typed = tuple( Link( @@ -464,7 +501,7 @@ def declaration_of( link.by, _named(link.over), _named(link.into), - _reads(schema, ctx, key, pw, link, carried), + _reads(ctx, key, pw, walks.get(key), carried), ) for node, (key, link) in zip(links, pw.links.items(), strict=True) ) @@ -473,15 +510,49 @@ def declaration_of( ) -def _row(schema: Spec, block: PiecewiseBlock, link: PiecewiseLink) -> tuple[str, ...]: - """The dims one link's row is built over: ``dims:``, or its refinement through the link's relation. +def _walk_fits(ctx: str, block: PiecewiseBlock, walk: Direction) -> None: + """A walk reads the curve's weights, which are over ``dims:`` and the breakpoint dim, as ``at`` would. + + The rules ``at`` holds its operand to are :func:`pulled_back_dims`'s. + The ones checked first are the same rules, refused in terms of the + block, since there the rewrite is an edit to ``dims:``. + """ + consumed, produced = set(walk.consumed_dims), set(walk.produced_dims) + if missing := sorted(consumed - set(block.dims)): + raise DimensionError( + f"{ctx}: over reaches {missing}, which the block's dims {block.dims} do not carry. A walk " + f"consumes one of the curve's own dimensions — name a column over one of {block.dims}, or declare " + f'it in dims:.' + ) + if framed := sorted(produced & set(block.dims)): + raise DimensionError( + f"{ctx}: into reaches {framed}, which the block's dims {block.dims} already carry. The block " + f"builds one curve per coordinate of dims:, so {framed} cannot also index this link's rows — drop " + f'it from dims:, or walk into a dimension of its own.' + ) + if block.along in produced: + raise DimensionError( + f"{ctx}: into reaches '{block.along}', the breakpoint dim. A walk indexes the link's rows, " + f'and every row runs along the breakpoints.' + ) + if joined := sorted(set(walk.joined_dims) - set(block.dims)): + raise DimensionError( + f"{ctx}: '{walk.name}' is keyed on {joined} too, which the block's dims {block.dims} do not carry. " + f'A walk reads the curve at every key column it does not name, so the curve varies along them — add ' + f'{joined} to dims:, or walk through a relation keyed by the columns into names.' + ) + pulled_back_dims(walk, frozenset((*block.dims, block.along)), ctx, "the curve's weights") + + +def _row(schema: Spec, block: PiecewiseBlock, walk: Direction | None) -> tuple[str, ...]: + """The dims one link's row is built over: ``dims:``, or its refinement through the link's walk. The produced dims stand where the consumed ones did, so a walked row reads in the shape of the curve it ties rather than in relation order. """ - if not link.walks: + if walk is None: return tuple(block.dims) - consumed, produced = _walk(schema, link) + consumed, produced = set(walk.consumed_dims), set(walk.produced_dims) refined: list[str] = [] for d in block.dims: if d in consumed: @@ -491,45 +562,34 @@ def _row(schema: Spec, block: PiecewiseBlock, link: PiecewiseLink) -> tuple[str, return tuple(refined) -def _reads( - schema: Spec, ctx: str, key: str, block: PiecewiseBlock, link: PiecewiseLink, carried: frozenset[str] -) -> bool: +def _reads(ctx: str, key: str, block: PiecewiseBlock, walk: Direction | None, carried: frozenset[str]) -> bool: """Whether a walked link's row reads the block's ``where:`` through its relation; ``False`` for one that does not walk. - A walked row is over the dims the walk produces, where a mask over the - ones it consumes cannot be read as written. Read through the relation it - can, as ``at`` reads it, when the mask carries every dim the walk consumes - or joins on (*carried* is what the mask carries, the breakpoint dim - aside). A mask carrying none of them is over dims the row keeps, and - reads as written. + A walked row is over the dims the walk produces, where a mask over a + dim it consumes cannot be read as written. Read through the relation it + can, as ``at`` reads it, when the mask carries every dim the walk + consumes or joins on (*carried* is what the mask carries, the breakpoint + dim aside). A mask carrying none the walk consumes is over dims the row + keeps, the joined ones among them, and reads as written. Raises: - DimensionError: The mask carries some of the dims the walk reads - through and not the rest. + DimensionError: The mask carries a dim the walk consumes, and not + every dim the walk reads through. """ - if not link.walks: + if walk is None: + return False + consumed = frozenset(walk.consumed_dims) + if not consumed & carried: return False - assert link.by is not None - consumed, _ = _walk(schema, link) - relation = schema.relations[link.by] - roles = dict(relation.pairs) - written = {*_named(link.over), *_named(link.into)} - needed = consumed | {roles[c] for c in relation.key_roles if c not in written} - if (partial := sorted(needed - carried)) and needed & carried: + needed = consumed | frozenset(walk.joined_dims) + if partial := sorted(needed - carried): raise DimensionError( f"{ctx} link '{key}': where {block.where!r} carries {sorted(needed & carried)} and not {partial}, and " - f"the link reads the curve through '{link.by}' at all of {sorted(needed)}. Carry all of them in the " - f'where, so the row reads it through the relation, or none, so the row reads it as written.' + f"the link reads the curve through '{walk.name}' at all of {sorted(needed)}. Carry all of them in the " + f'where, so the row reads it through the relation, or none of {sorted(consumed)}, so the row reads it ' + f'as written.' ) - return bool(needed & carried) - - -def _walk(schema: Spec, link: PiecewiseLink) -> tuple[frozenset[str], frozenset[str]]: - """The dims one walked link consumes and produces, read off the relation it names.""" - assert link.by is not None - roles = dict(schema.relations[link.by].pairs) - consumed, produced = (frozenset(roles[c] for c in _named(written)) for written in (link.over, link.into)) - return consumed, produced + return True def _named(written: str | list[str] | None) -> tuple[str, ...]: @@ -588,14 +648,25 @@ def _values_fit( ) -def _where_fits(ctx: str, block: PiecewiseBlock, where: Mask | None) -> None: +def _where_fits(ctx: str, block: PiecewiseBlock, where: Mask | None, walks: dict[str, Direction]) -> None: """A block's ``where:`` tests ``dims:`` and the breakpoint dim, and nothing else. A walked link's values parameter carries the link's own row, so a where - naming it is refused here too: raggedness is the curve's. + naming it is refused here too: raggedness is the curve's. A dim a walk + produces is refused without the advice to add it to ``dims:``, which the + walk would then refuse. """ dims = where.dims if where is not None else frozenset() - if stray := sorted(dims - set(block.dims) - {block.along}): + stray = sorted(dims - set(block.dims) - {block.along}) + for key, walk in walks.items(): + if into := [d for d in stray if d in walk.produced_dims]: + raise DimensionError( + f"{ctx}: where {block.where!r} tests {into}, and {into} is what link '{key}' walks into — the " + f'where says which curves exist, one per coordinate of dims {block.dims}, and {into} indexes only ' + f"that link's rows. Test {block.dims} in the where, or mask the link's own variable over {into} " + f'to leave its rows unbuilt.' + ) + if stray: raise DimensionError( f'{ctx}: where {block.where!r} tests {stray}, which dims {block.dims} does not carry — a mask says ' f'which of the curves the block builds exist, and cannot add coordinates. Add {stray} to dims:, ' diff --git a/src/math_spec/validation.py b/src/math_spec/validation.py index 669dd537..c06dbeab 100644 --- a/src/math_spec/validation.py +++ b/src/math_spec/validation.py @@ -67,27 +67,40 @@ def to_spec(model: str | Path | Mapping[str, object] | Spec) -> Spec: def emitted_name_errors(schema: Spec, program: Program) -> list[str]: - """Every name a set or curve of *program* would write out that *schema* already declares. + """Every name a set or curve of *program* would write out that *schema* declares, or another one writes too. Read off the program rather than the file, since what a curve writes is decided by the curve as lowered — its links, its method, its mask. """ + emitters = [ + *((f"Sos '{name}'", EmittedSet.of(name, block.sos_type).by_kind) for name, block in program.sos.items()), + *((f"piecewise '{name}'", EmittedCurve.of(name, curve).by_kind) for name, curve in program.piecewise.items()), + ] errors = [ - error - for name, block in program.sos.items() - for error in _collisions(schema, f"Sos '{name}'", EmittedSet.of(name, block.sos_type).by_kind) + f"piecewise '{name}': link '{row.removeprefix(f'{name}_')}' names its row '{row}', which the block " + f'already writes for itself. Rename the link.' + for name, curve in program.piecewise.items() + for row in EmittedCurve.of(name, curve).reused ] - for name, curve in program.piecewise.items(): - written = EmittedCurve.of(name, curve) - errors.extend( - f"piecewise '{name}': link '{row.removeprefix(f'{name}_')}' names its row '{row}', which the block " - f'already writes for itself. Rename the link.' - for row in written.reused - ) - errors.extend(_collisions(schema, f"piecewise '{name}'", written.by_kind)) + for context, by_kind in emitters: + errors.extend(_collisions(schema, context, by_kind)) + errors.extend(_shared(emitters)) return errors +def _shared(emitters: Iterable[tuple[str, Iterable[tuple[str, Iterable[str]]]]]) -> Iterator[str]: + """The refusal for each name two expansions both write, since the second would overwrite the first.""" + first: dict[tuple[str, str], str] = {} + for context, by_kind in emitters: + for kind, names in by_kind: + for one in names: + if (owner := first.setdefault((kind, one), context)) != context: + yield ( + f"{context}: its expansion writes {kind} '{one}', which {owner} also writes. Rename one of " + f'the blocks, or the link whose row it is.' + ) + + def reference_errors(schema: Spec) -> list[str]: """Every cross-declaration rule *schema* breaks, collected rather than raised on the first.""" return [ @@ -286,9 +299,9 @@ def _piecewise_references(schema: Spec) -> Iterator[str]: """Every declaration a block names by key exists and has the shape the block needs. The breakpoint dim, the frame ``dims:`` states, each link's values - parameter, the relation and columns a walk reads through, and the gate. - What a link's expression and the where carry is resolution's to say, and - whether the pieces fit together is decided as the block is lowered + parameter, and the gate. What a link's expression, its walk and the + where carry is resolution's to say, and whether the pieces fit together + is decided as the block is lowered (:func:`math_spec.piecewise.declaration_of`). """ for name, block in schema.piecewise.items(): @@ -328,7 +341,7 @@ def _piecewise_references(schema: Spec) -> Iterator[str]: def _piecewise_link_shape( schema: Spec, name: str, block: PiecewiseBlock, key: str, link: PiecewiseLink ) -> Iterator[str]: - """One link's values parameter, and the relation its walk names, exist as the link needs them.""" + """One link's values parameter exists as the link needs it.""" context = f"piecewise '{name}' link '{key}'" if link.values not in schema.parameters: yield f"{context}: values references undeclared parameter '{link.values}'" @@ -342,53 +355,6 @@ def _piecewise_link_shape( f"{context}: values parameter '{link.values}' must carry dim " f"'{block.along}' (has {schema.parameters[link.values].dims})" ) - if link.walks: - yield from _piecewise_walk_shape(schema, context, block, link) - - -def _piecewise_walk_shape(schema: Spec, context: str, block: PiecewiseBlock, link: PiecewiseLink) -> Iterator[str]: - """A walk's relation is declared, it consumes the block's own dims, and it produces dims of its own.""" - assert link.by is not None and link.over is not None and link.into is not None - if link.by not in schema.relations: - yield ( - f"{context}: by references undeclared relation '{link.by}'. A walked link reads the curve's " - f'weights through a declared relation — declare it, or drop by, over and into.' - ) - return - roles = dict(schema.relations[link.by].pairs) - sides: list[frozenset[str]] = [] - for side, written in (('over', link.over), ('into', link.into)): - named = [written] if isinstance(written, str) else list(written) - if stray := [c for c in named if c not in roles]: - yield f"{context}: {side} names {stray}, which relation '{link.by}' has no column for (it has {sorted(roles)})" - return - if len(set(named)) != len(named): - yield f'{context}: {side} repeats a column: {named}' - return - sides.append(frozenset(roles[c] for c in named)) - consumed, produced = sides - if shared := sorted(consumed & produced): - yield ( - f'{context}: over and into both reach {shared}, so the walk consumes and produces one dimension. ' - f'Name different columns on each side.' - ) - elif missing := sorted(consumed - set(block.dims)): - yield ( - f"{context}: over reaches {missing}, which the block's dims {block.dims} do not carry. A walk " - f"consumes one of the curve's own dimensions — name a column over one of {block.dims}, or declare " - f'it in dims:.' - ) - elif framed := sorted(produced & set(block.dims)): - yield ( - f"{context}: into reaches {framed}, which the block's dims {block.dims} already carry. The block " - f"builds one curve per coordinate of dims:, so {framed} cannot also index this link's rows — drop " - f'it from dims:, or walk into a dimension of its own.' - ) - elif block.along in produced: - yield ( - f"{context}: into reaches '{block.along}', the breakpoint dim. A walk indexes the link's rows, " - f'and every row runs along the breakpoints.' - ) def _collisions(schema: Spec, context: str, by_kind: Iterable[tuple[str, Iterable[str]]]) -> Iterator[str]: diff --git a/tests/test_piecewise.py b/tests/test_piecewise.py index f114faf9..70a7944b 100644 --- a/tests/test_piecewise.py +++ b/tests/test_piecewise.py @@ -643,6 +643,28 @@ def test_a_curves_conditions_cannot_collide_with_a_written_assumption(): expanded(override(LP, assumptions={'cost_curve_increasing': 'bp_x > 0'}), 'piecewise') +@pytest.mark.parametrize( + ('where', 'advice'), + [ + pytest.param(None, 'declare where: to say how far the curve runs', id='no-where'), + pytest.param('curved', "let where: 'curved' test 'bp' too", id='a-where-over-dims'), + pytest.param('curved AND bp_power_on', "narrow where: 'curved AND bp_power_on'", id='a-ragged-where'), + ], +) +def test_a_missing_breakpoint_names_a_rewrite_the_block_can_take(where, advice): + """A block with a `where:` over `dims:` was told to declare `where:`, which it already had.""" + model = override( + WALKED, + **{ + 'parameters.curved': {'dims': ['generator'], 'dtype': 'bool'}, + 'parameters.bp_power_on': {'dims': ['generator', 'bp'], 'dtype': 'bool'}, + }, + ) + assumptions = expand_piecewise(schema_of(model, **{'piecewise.coupling.where': where})).assumptions + for name in ('coupling_complete', 'coupling_power_complete'): + assert advice in assumptions[name].description, f'{name} names the rewrite for its own where' + + @pytest.mark.parametrize('suffix', ['increasing', 'curvature', 'breakpoints', 'contiguous']) def test_every_check_has_a_sentence(suffix): assumptions = expanded(LP_MASKED, 'piecewise').program.assumptions @@ -775,6 +797,16 @@ def test_a_where_the_block_cannot_read_is_refused(patch, match): schema_of(MASKED, **patch) +def test_a_where_over_a_dim_a_link_walks_into_is_not_sent_to_dims(): + """The refusal said to add `flow` to `dims:`, and the walk into `flow` was then refused for that very edit.""" + with pytest.raises(LanguageError, match=r"\['flow'\] is what link 'power' walks into") as refused: + schema_of( + WALKED, + **{'parameters.on_flow': {'dims': ['flow'], 'dtype': 'bool'}, 'piecewise.coupling.where': 'on_flow'}, + ) + assert 'to dims:' not in str(refused.value), 'no advice the walk refuses' + + def test_segment_lines_carry_the_mask_that_no_weight_can_hand_them(): """`method: lp` emits no weights, so its three rows take the block's where themselves or stand everywhere.""" expanded = expand_piecewise(schema_of(LP_WHERE)) @@ -944,7 +976,7 @@ def _walk(**written: object) -> dict[str, object]: r"link 'power': into reaches \['flow'\], which the block's dims .* already carry", id='a-walk-into-a-dim-the-block-has', ), - pytest.param(_walk(into=['flow', 'flow']), r"link 'power': into repeats a column", id='a-repeated-column'), + pytest.param(_walk(into=['flow', 'flow']), r"link 'power': .*names a column twice", id='a-repeated-column'), pytest.param( {'relations.slot_of': {'key': 'bp', 'values': 'generator'}} | _walk(by='slot_of', over='generator', into='bp'), @@ -956,6 +988,56 @@ def _walk(**written: object) -> dict[str, object]: 'nothing pins the operating point', id='every-row-bounded', ), + pytest.param( + _walk(over=[]) + | { + 'variables.power.dims': ['generator', 'snapshot'], + 'parameters.bp_power.dims': ['generator', 'bp'], + 'constraints.balance.expression': 'sum(power, over=generator) == load', + }, + r'links.power: over: \[\] names no column', + id='an-empty-over', + ), + pytest.param( + _walk(into=[]) + | { + 'variables.power.dims': ['snapshot'], + 'parameters.bp_power.dims': ['bp'], + 'constraints.balance.expression': 'power == load', + }, + r'links.power: into: \[\] names no column', + id='an-empty-into', + ), + pytest.param( + { + 'piecewise.coupling.dims': ['flow', 'snapshot'], + 'piecewise.coupling.links': { + 'power': ['power', 'bp_power'], + 'fuel': { + 'expression': 'fuel', + 'values': 'bp_fuel', + 'by': 'generator_of', + 'over': 'flow', + 'into': 'generator', + }, + }, + }, + r"link 'fuel': at\(by=generator_of\): into=\['generator'\] names \['generator'\], which the key", + id='a-walk-landing-off-the-key', + ), + pytest.param( + { + 'dimensions.period': {'dtype': 'int'}, + 'relations.generator_of': {'key': ['flow', 'period'], 'values': 'generator'}, + }, + r"link 'power': 'generator_of' is keyed on \['period'\] too, which the block's dims", + id='a-walk-joining-on-a-dim-the-block-lacks', + ), + pytest.param( + {'relations.generator_of': {'key': {'flow': 'flow', 'site': 'generator'}, 'values': 'generator'}}, + r"link 'power': at\(by=generator_of\) joins 'generator_of' on \['generator'\] through more than one", + id='a-walk-joining-on-the-dim-it-consumes', + ), ], ) def test_a_walked_block_the_language_cannot_read_is_refused(patch, match): @@ -992,6 +1074,52 @@ def test_a_link_named_after_a_row_the_block_writes_is_refused(link, match): schema_of(WALKED, **{f'piecewise.coupling.links.{link}': ['fuel', 'bp_fuel']}) +#: A second curve whose name extends the first's, so a link of the first can spell one of its rows. +BESIDE = override( + WALKED, + **{ + 'piecewise.coupling_b': { + 'along': 'bp', + 'dims': ['generator', 'snapshot'], + 'links': {'fuel': ['fuel', 'bp_fuel'], 'power': WALKED['piecewise']['coupling']['links']['power']}, + } + }, +) + + +@pytest.mark.parametrize( + ('key', 'link', 'match'), + [ + pytest.param( + 'b_fuel', + ['fuel', 'bp_fuel'], + "writes constraint 'coupling_b_fuel', which piecewise 'coupling' also writes", + id='a-link-row', + ), + pytest.param( + 'b_convexity', + ['fuel', 'bp_fuel'], + "writes constraint 'coupling_b_convexity', which piecewise 'coupling' also writes", + id='a-row-the-other-block-writes-for-itself', + ), + pytest.param( + 'b', + WALKED['piecewise']['coupling']['links']['power'], + "writes assumption 'coupling_b_complete', which piecewise 'coupling' also writes", + id='a-walked-links-own-condition', + ), + ], +) +def test_a_name_two_blocks_would_both_write_is_refused(key, link, match): + """Links take any name, so `coupling`'s link `b_fuel` spelled `coupling_b`'s row `coupling_b_fuel`. + + Both blocks loaded, and the expansion wrote one row over the other, so + one block's link was never stated. + """ + with pytest.raises(LanguageError, match=match): + schema_of(BESIDE, **{f'piecewise.coupling.links.{key}': link}) + + def test_a_link_name_no_row_could_take_is_refused(): with pytest.raises(LanguageError, match=r"links: \['2nd'\] is not a name"): schema_of(WALKED, **{'piecewise.coupling.links.2nd': ['fuel', 'bp_fuel']}) @@ -1083,6 +1211,26 @@ def test_a_mask_over_dims_the_walk_keeps_reaches_the_walked_row_as_written(): assert expanded.constraints['coupling_power'].where == 'season' +def test_a_mask_over_a_dim_the_walk_joins_on_reaches_the_walked_row_as_written(): + """The relation is keyed by flow and snapshot, and `season` tests only `snapshot`, which the walked row keeps. + + The join column was counted with the ones the walk consumes, so this mask + was refused as carrying part of what the walk reads through, and no + rewrite kept it. + """ + expanded = expand_piecewise( + schema_of( + WALKED, + **{ + 'relations.generator_of': {'key': ['flow', 'snapshot'], 'values': 'generator'}, + 'parameters.season': {'dims': ['snapshot'], 'dtype': 'bool'}, + 'piecewise.coupling.where': 'season', + }, + ) + ) + assert expanded.constraints['coupling_power'].where == 'season' + + def test_a_mask_carrying_part_of_what_a_walk_reads_through_is_refused(): """The relation is keyed by flow and snapshot, so the read joins on snapshot and needs the mask to carry it too.""" model = override( @@ -1106,9 +1254,9 @@ def test_a_walked_links_breakpoints_are_asked_only_at_the_rows_it_reads_the_curv """Asked with the other links, `bp_power` was demanded at every flow, including those of a generator with no curve.""" assumptions = schema_of(model).expand('piecewise').program.assumptions walked = assumptions['coupling_power_complete'] - assert walked.predicate.names_read == frozenset({'bp_power'}) + assert walked.predicate.names_read == frozenset({'bp_power'}), 'the walked link asks for its own values alone' assert walked.where is not None and walked.where.dims == frozenset({'flow'}), 'asked per flow the walk reaches' - assert walked.where.names_read == frozenset(read) + assert walked.where.names_read == frozenset(read), 'the where reads the mask, if any, and the relation' assert assumptions['coupling_complete'].predicate.names_read == frozenset({'bp_fuel'}), ( 'the link on dims: keeps the block condition to itself' ) @@ -1145,7 +1293,7 @@ def test_one_walked_link_is_a_curve_because_the_relation_gives_it_its_arity(): expanded = expand_piecewise(schema_of(WALKED, **POWER_ONLY)) assert expanded.constraints['coupling_convexity'].dims == ['generator', 'snapshot'], 'one curve per generator' assert expanded.constraints['coupling_power'].dims == ['flow', 'snapshot'], 'one row per flow, sharing it' - assert 'coupling_fuel' not in expanded.constraints + assert 'coupling_fuel' not in expanded.constraints, 'no row for a link the block does not declare' def test_one_link_that_walks_nothing_is_still_a_bound_rather_than_a_curve():