Skip to content

feat(language): a where may read a predicate through a relation with at() - #634

Merged
FBumann merged 1 commit into
mainfrom
feat/where-at
Sep 23, 2026
Merged

FBumann merged 1 commit into
mainfrom
feat/where-at

Conversation

@FBumann

@FBumann FBumann commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Prompt: "It does mix curve and non curve converters!" … "Yes" (to: take option 1 of #258, at() in a where:, as its own PR, then stack the piecewise change on #630)

Note

The following content was generated by AI.

What this changes

A where: may read a predicate through a relation: at(<predicate>, by=, over=, into=). It is true where the relation has a row and the predicate holds at the coordinate that row maps to. Closes #258 with option 1.

variables:
  rate:
    dims: [flow, snapshot]
    where: "at(has_curve, by=converter_of, over=converter, into=flow)"

$$\mathit{rate}_{f,t} \in \mathbb{R} \qquad \forall, f \in \mathcal{F},\ t \in \mathcal{T} ,:, \mathrm{has_curve}_{\mathrm{converter_of}(f)}$$

The rules, and where each one lives
  • It is the third operator that reads a predicate, beside shift and count. The grammar already parsed name(<predicate>, kw=…), so the grammar does not change. Resolution routes at to _pulled_back.
  • It follows the expression at's rules, and uses its refusals. by, over and into must all be written and nothing else is taken. The read lands on the relation's key, and the predicate must carry every dimension the read consumes. The dims logic moved out of _at_dims into dimensions.pulled_back_dims, which the expression and the predicate share.
  • It maps and never widens. The consumed dims go and the produced dims arrive, so the frame check applies unchanged. Its message names the relation: a where-predicate read through 'lk' reads dims ['g'] outside the frame ['h'].
  • It is false where the relation has no row, which is what a missing row already means in a mask.
  • What it adds over arithmetic. at(cap, by=…) > 0 already worked, because an arithmetic side of a comparison accepts at. The predicate form reads what arithmetic cannot: definedness, a bool, a variable's existence, and connectives over them.
  • Program vocabulary: PulledBackPredicate(operand: Mask, direction: Direction, dims). names_read includes the relation. Lowering rebuilds the operand as it does for TranslatedPredicate.
  • In a case when: it is refused as undecidable, as shift and count are. Which rows a relation maps is the data's to say.
  • The typesetter re-indexes the leaf, as the expression at does: has\_curve_{converter\_of(f)}.
Guard table: each guard deleted in turn, whole suite run, tree restored
Guard Caught by
at reads a predicate 29 tests, incl. …[under-a-read]
at takes by, over, into and nothing else 3, incl. …[a-read-naming-no-columns]
the read is held to the dims rules of at …[a-read-through-a-dim-the-predicate-lacks], …[a-read-onto-a-dim-the-predicate-carries]
the edge= hint is only for shift test_a_read_given_an_edge_is_not_told_about_translations
the frame check names the relation test_a_read_landing_outside_the_frame_names_the_relation
the read is lowered test_a_predicate_read_through_a_relation_is_lowered_and_keeps_the_relation_in_reach
the relation is in names_read the same, and test_a_read_lands_on_the_dims_it_produces_and_reads_the_relation
the typesetter reads at the relation the golden files (new zoned constraint) and the notation page
a read is undecidable in a case test_a_read_through_a_relation_is_undecidable_in_a_case_when
Gates, and what was not run
Gate Result
pytest -q -n auto 1564 passed, 6 skipped
ruff check, ruff format --check clean
pyrefly check 12 errors, the same count as main in this venv (missing-import / untyped-import and their consequences)
prettier, typos, reuse lint clean
mkdocs build only the docs.python.org inventory fetch fails (proxy 403)
golden output, notation page, schema regenerated and read; the schema does not change
compile-tex, taplo, zizmor, pixi run ci not run: no pixi and no LaTeX here

The gates ran from a uv venv, not pixi run, because pixi.sh returns 403 here.

Deliberately not done

Why

fluxopt mixes converters that run on a piecewise curve with converters that do not. A piecewise: block's rows for a converter's flows are over flow, and the mask saying which converters have a curve is over converter. Until a where: can read that mask through converter_of, the block has to build a curve for every converter. The same spelling is the one PyPSA's retired-asset port asked for in #258.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HxoXSQCyrcDs6vuRk3x6tz


Generated by Claude Code

…at()

`at(<predicate>, by=<relation>, over=<a>, into=<b>)` reads a mask over
coarse coordinates at fine ones, as `at` reads an array: true where the
relation has a row and the predicate holds at the coordinate it maps to,
false where the relation has no row. It is the third operator that reads
a predicate, beside `shift` and `count`, and is held to the rules of the
expression `at`, so a bad read is refused in the same words in both.

The program carries it as `PulledBackPredicate`: the operand mask, the
`Direction`, and the dims after the read. `names_read` includes the
relation. Exclusivity refuses it in a case `when:` as undecidable, as it
refuses `shift` and `count`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxoXSQCyrcDs6vuRk3x6tz
@read-the-docs-community

Copy link
Copy Markdown

@FBumann
FBumann merged commit 6190cf4 into main Sep 23, 2026
6 checks passed
FBumann pushed a commit that referenced this pull request Sep 23, 2026
#634's `at()` over a predicate is ported into the resolver that builds
program nodes: `PulledBackPredicate` is built from the `Direction` the
relation read returns, and `pulled_back_dims` serves the expression and
the predicate alike.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwZZXWXoJfSMabvXTUndtn
FBumann added a commit to fluxopt/specsolve that referenced this pull request Sep 23, 2026
…at() (#1718)

> **Prompt:** Update lpspec to the latest mathspec release (119)

> [!NOTE]
> The following content was generated by AI.

The pin moves from math-spec alpha.116 to alpha.119. Both lanes now
build the new `at(<predicate>, by=, over=, into=)` in a `where:`
(energy-models/mathspec#634), and they agree on the result. A
coordinate with no relation row reads false. 70 insertions, 34
deletions.

<details><summary>What moved</summary>

* **`PulledBackPredicate`**: the relational lane walks the coordinates
the operand admits through `walk_join`, as an expression's `at` does.
The linopy lane reads the evaluated mask through `operator_at`. Before
this change, both lanes hit `assert_never`.
* **`PiecewiseExpansionError` is gone upstream**
(energy-models/mathspec#638). A piecewise block now raises
`DimensionError`, so `lps.PiecewiseExpansionError` goes too, with no
alias. `test_api`, `test_architecture` and `test_piecewise` follow, and
so does `docs/reference/api.md`.
* **`ArithmeticComparison` is gone upstream**
(energy-models/mathspec#631). The two lanes' branches for it were never
reached, so they go. `NEVER_LOWERED` in `test_resolution_parity` goes
with them.
* **Tests**: `test_a_where_reads_a_relation` gains 2 cases and
`test_a_relation_where_agrees_with_the_oracle` gains 3: a total
relation, a partial one, a negation and a conjunction.
`COVERED_ELSEWHERE` names the oracle test for `PulledBackPredicate`.
Before the implementation, the coverage guard failed on it.
* **Upstream now refuses `sum()` over a scalar**, so the
carried-parameter probe in `test_strategy` reads `soc_initial` bare. It
still asserts the refusal names "carried".
* `uv.lock` is relocked. Only the math-spec entry changed.

</details>

<details><summary>Mutation table</summary>

Run by hand on the committed tree. Each file was restored through `git
checkout --` and `__pycache__` was dropped. The runs cover
`test_label_coords.py` and `test_resolution_parity.py`.

| Mutation | Result |
| --- | --- |
| linopy: a missing relation row reads true (`fillna(True)`) | caught, 1
failed |
| polars: a missing row reads true (`fill_null(True)`) | caught, 5
failed |
| polars: the operand's mask is ignored (`masked(..., None)`) | caught,
5 failed |

</details>

<details><summary>Gate</summary>

```
ruff check .            clean
ruff format --check .   327 files already formatted
pyrefly check           0 errors (20 suppressed)
pytest -q -n auto       3796 passed, 403 skipped, 1 xfailed, 11 failed
```

The run used `uv` with the `[linopy]` extra, not pixi. The xfail is the
known `osemosys_utopia` / #894 case. Of the 11 failures, one was the
`test_strategy` probe, which is fixed in the second commit. The other 10
are the gurobi and xpress parametrizations of `test_diagnostics` and
`TestThePositionalHandoff`. They raise `ModuleNotFoundError` because
neither package is installed here, and they fail the same way on
`origin/main`.

**Not run**: `docs-build`, `docs-test`, `test-floors`, `test-bench`, the
gurobi and xpress sinks. I did not regenerate the gallery pages. The
typesetter changed upstream (energy-models/mathspec#629), but the doc
tests passed.

</details>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01LzN7uYEvHW9dDGCmtAzbkD

---
_Generated by [Claude
Code](https://claude.ai/code/session_01LzN7uYEvHW9dDGCmtAzbkD)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

may a where: carry operators — relocation through a lookup, reduction over a dimension it does not span

2 participants