Repository navigation
feat(api): a sweep can be cut by several axes at once, each carrying its own state, such as a rolling horizon per scenario or a pathway whose decades roll through their hours - #1888
Draft
FBumann wants to merge 125 commits into
Conversation
…sks for it, and a sweep can hold the activity too Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBvwp6NU7YjszFz5QMRf1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBvwp6NU7YjszFz5QMRf1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBvwp6NU7YjszFz5QMRf1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBvwp6NU7YjszFz5QMRf1
…ith the same sign on every solver Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBvwp6NU7YjszFz5QMRf1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBvwp6NU7YjszFz5QMRf1
…hive carry them Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBvwp6NU7YjszFz5QMRf1
…ar each constraint is from binding Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBvwp6NU7YjszFz5QMRf1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBvwp6NU7YjszFz5QMRf1
…-8x46sq-outputs # Conflicts: # CHANGELOG.md
…puts' into claude/festive-carson-8x46sq
…-8x46sq-outputs # Conflicts: # CHANGELOG.md
…puts' into claude/festive-carson-8x46sq
…s no activity Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBvwp6NU7YjszFz5QMRf1
…puts' into claude/festive-carson-8x46sq
… is found by its kind's name The engine computes each output asked for in one place instead of threading the activity through the read-back. A sweep slice collects the frames the result already holds rather than re-reading them per name. Result and Sweep find a kind's reader by its name, so a new output adds no dispatch entry. The read-only wrappers around three module tables are dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UFX6qbCYnXKTVkucgxEqfS
…puts' into claude/festive-carson-8x46sq # Conflicts: # src/specsolve/relational/engine/engine.py # src/specsolve/relational/result.py # src/specsolve/strategy.py # src/specsolve/sweep.py
…gives the duals' reason Strict xfail: today the bridge lists no names and fails unpacking them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UFX6qbCYnXKTVkucgxEqfS
…the duals' reason The duals' reason is given where an output is read, so the bridges and the reader share it rather than the reader alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UFX6qbCYnXKTVkucgxEqfS
…n-8x46sq-slack # Conflicts: # src/specsolve/relational/engine/engine.py # src/specsolve/relational/result.py # src/specsolve/sweep.py
…-8x46sq-outputs # Conflicts: # CHANGELOG.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UFX6qbCYnXKTVkucgxEqfS
…ow that activity is laid out as an output Activity is laid out by the same per-constraint helper as the duals, so the dual's label-order assertion covers it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UFX6qbCYnXKTVkucgxEqfS
…into review/1884-tidy # Conflicts: # docs/reference/sweeps.md
start='previous' is now the default and carries the solver on, so a chain no longer restarts it: each slice passes its own start to Model.solve, and the basis read per slice goes. Under an executor a chain is one task only where an axis carries; 'previous' alone no longer groups slices, since it is the default and would leave a sweep of one axis a single task. 'previous' with an executor is no longer refused, and a start that leaves a slice no row warns rather than refusing the sweep. Tests: the chain-cold test goes with the behaviour, and the executor test counts one build per chain. A main test moves carry= onto its axis. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
Main holds the stack below as squash commits: its tree is #1884's last head, merged in the commit before this one, plus #1893's test fix, taken here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
…ort-e3g6om-windows Brings main, which now holds the stack below, and the start= redesign: 'previous' is the default and carries the solver on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
…ort-e3g6om-windows Brings main through #1888. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
The column names specsolve writes now live in relational/names.py (#1889); the stitch and the uncut archive read VALUE from there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
…ort-e3g6om-windows Brings main through #1888: the column names now live in relational/names.py (#1889), and the stitch over every window and the uncut archive read VALUE from there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
FBumann
removed this pull request from stack #1886
October 9, 2026 08:46
FBumann
added this pull request to stack #1917
October 9, 2026 08:46
Main's answers now carry their spec rather than a digest, and an archive no longer digests its sources (#1913, #1904): the spill writes the spec beside the keys, the sweep folds it in, and a sweep archive holds the spec, the sources, the axes and the answer. Main raised ANSWER_LAYOUT to 5 and INPUTS_LAYOUT to 2 for that; this branch had taken the same numbers for its own key and axis files, so they rise to 6 and 3, and a file in either earlier layout is refused. The tests' dataclasses.replace import, which main dropped, comes back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
…ort-e3g6om-windows Brings main through #1888: answers carry their spec (#1913), and the layouts are ANSWER_LAYOUT 6 and INPUTS_LAYOUT 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
Main released 0.8.0 with ANSWER_LAYOUT 4 and INPUTS_LAYOUT 1, one rise each since 0.7.0. This branch's key and axis files rise them once more since that release, to 5 and 2, and the save docstring's stamp follows. The changelog takes main's 0.8.0 section; this PR's line stays under Upcoming version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
…ort-e3g6om-windows Brings main's 0.8.0 release through #1888: the layouts are ANSWER_LAYOUT 5 and INPUTS_LAYOUT 2, and this PR's changelog line stays under Upcoming version beside #1888's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
…ort-e3g6om-windows Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
Main's archive now writes its answer once, through the writer the archive hands it (#1922, #1924): sweep_manifest, Spill.write_frames and Sweep._frames_by_slice are taken over every key column, so the archive, the spill and the in-memory windows keep one column per axis. The key columns read off a sweep's record in one place, Sweep._key_columns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
…ort-e3g6om-windows Brings main's single-write archive through #1888: sweep_manifest now names the stitch's locals, dims, keys and outer keys, as the manifest this branch wrote by hand did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
…hains' into up/1890
…rt-e3g6om-chains Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
…rdly-lamport-e3g6om-windows Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
FBumann
added a commit
that referenced
this pull request
Oct 12, 2026
…lve_over, so an archive's axis holds its carry (#1980) > **Prompt:** There is a pr that changes the axis classes to include the carry > Maybe we should extract this into main first > [!NOTE] > The following content was generated by AI. `carry=` moves from `solve_over` onto `EachCoordinate` and `EachWindow`, as #1888 has it, for one axis. An archive's axis now holds its carry, so `solve_over(spec, sources, archive.axis)` runs a sweep again. `axis.json` is unchanged byte for byte. <details><summary>What changed</summary> - **`EachCoordinate(..., carry=)` and `EachWindow(..., carry=)`**, a keyword-only `{parameter: variable}`, empty by default. `solve_over` loses `carry=`. - **A hand-built list carries nothing.** It has no axis to hold a carry. No test used one. - **`SweepArchive.carry` and `ArchivedInputs.carry` are gone.** The carry is `archive.axis.carry`. - **The carry stays out of the hash.** It is a mapping, so a frozen axis with one would be unhashable otherwise. Equality still compares it. Axes on `main` are hashable, and a new test (`test_an_axis_that_carries_is_still_hashable`) holds that. - The executor refusal, the seed check and the line-up checks are unchanged. They read the carry off the axis. - Docs: the sweeps reference, the sweep tour, the archiving how-to, the glossary entry and both examples move to `carry=` on the axis. </details> <details><summary>Why no layout number rises</summary> On `main`, the archive wrote the carry as a `carry` key beside the axis's own keys in `axis.json`. The axis now writes the same key in the same place. I wrote the same carried rolling horizon with released 0.8.1 and with this branch, and `cmp` reports the two `axis.json` files identical: ``` {"each": "window", "dim": "snapshot", "steps": 4, "lookahead": 2, "into": "t", "carry": {"soc_initial": "soc"}} ``` So `INPUTS_LAYOUT` stays at 1, and an 0.8.1 archive reads back with its carry on the axis. </details> <details><summary>Breaking, and where coverage moved</summary> - `solve_over(..., axis, carry=c)` → `solve_over(..., EachWindow(..., carry=c))`, or `replace(axis, carry=c)` for an axis held in a constant. Every call in tests, examples and docs moved. The rewrite matches #1888's (`4cb40c4c`) call for call, so #1888 merges `main` with few conflicts. - `archive.carry` → `archive.axis.carry`, and `inputs.carry` → `inputs.axis.carry`. The two assertions that read them now read the axis. `test_a_rolling_horizon_archive_reads_back_its_answer` now compares against the axis with its carry. </details> <details><summary>Mutation table, gates, and what was not done</summary> | mutation | result | tree | |---|---|---| | `axes.py`: `hash=False` dropped from both `carry` fields | caught: `test_an_axis_that_carries_is_still_hashable[coordinate]` and `[window]`, `TypeError: unhashable type: 'dict'` | clean | I took it by hand: committed tree, `git checkout --` restore, `__pycache__` dropped. Before the probe existed, the full suite passed with this mutation (5605 passed). That is why the probe was added. - **Gates.** `pixi run check`: lint, format and typecheck clean, 5605 passed, 566 skipped, 4 xfailed. `pixi run -e docs docs-build`: strict, no issues; the sweep tour runs its examples. Not run: `docs-test`, `test-bench`, `test-floors`. - **Not done.** Several axes and chains stay in #1888. A public way to save an axis (`to_dict`) is a separate PR. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01G1TmcQZZCctsVr54HE93qS --- _Generated by [Claude Code](https://claude.ai/code/session_01G1TmcQZZCctsVr54HE93qS)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
FBumann
added a commit
that referenced
this pull request
Oct 12, 2026
…ver takes that data in its place (#1981) > **Prompt:** And about the axis from: not sure what's the best api here!! Maybe the dataclass could have a from and to json or yaml? Or dict? > **Prompt:** Stack it onto 1980 > [!NOTE] > The following content was generated by AI. `EachCoordinate.to_dict()` and `EachWindow.to_dict()` write an axis as plain data, carry included. `solve_over` takes that mapping in the axis's place. An archive's `axis.json` is the same mapping, so the browser app can solve from it without importing `specsolve.axes`. <details><summary>The shape, and why it matches the rest of the API</summary> - **Write with a method, read where the value is taken.** This follows the spec: mathspec's `Spec.to_dict()` writes it, and `to_spec`, `check`, `build` and `solve` take the mapping in its place. There is no public reader function. `axis_from` stays private, as `solve_over`'s reader and the archive's. - **A dict, not JSON or YAML.** `json.dumps(axis.to_dict())` is the JSON form. The spec has `to_yaml` because its file is YAML; an axis lives in `axis.json`. - **The keys are the dataclass fields.** `axis_from` reads the keys an axis takes and the ones it needs off `dataclasses.fields`, so a field added to an axis is accepted and named in refusals with no second list to update. `each` picks the class from one table, `_EACH`. - **The carry is one nested key, `CARRY`.** Its parameters and variables are keys inside it, and a dimension's name is always a value, so no name in a spec can collide with the format: a dimension called `steps` and a carry of `{'steps': ..., 'dim': ...}` read back equal. - **Refusals name the keys.** An `each` that names no axis, a key the axis does not take, and a key it needs and lacks each raise `SpecsolveError` before a slice is taken. For example: `EachWindow given as a mapping takes 'each' and ['dim', 'steps', 'lookahead', 'into', 'carry'], of which ['dim', 'steps', 'lookahead', 'into'] are required: ['lookahead'] is missing.` - **No `carry` key where nothing is carried**, so an archive's `axis.json` stays byte-identical and `INPUTS_LAYOUT` stays at 1. A test holds the literal dict. - `axis_manifest` is gone: the archive writes `axis.to_dict()`. </details> <details><summary>Mutation table, full suite</summary> I took these by hand, because each is a substitution: committed tree, `git checkout --` restore, `__pycache__` dropped on both sides, tree checked clean after every run. The first four were taken on `ab3f613`. The last was taken again on `8a558c0`, after `to_dict` was rewritten. | mutation | result | tree | |---|---|---| | `axis_from`: no refusal of an `each` that names no axis | caught: `…refused_with_the_keys_it_takes[no-each]`, `[an-each-that-names-no-axis]` | clean | | `axis_from`: unknown keys not looked for | caught: `[a-key-the-axis-does-not-take]` | clean | | `axis_from`: missing keys not looked for | caught: `[a-key-the-axis-needs]` | clean | | `solve_over`: the mapping not read as an axis | caught: `test_solve_over_takes_an_axis_as_its_dict` and the four refusal cases | clean | | `to_dict`: the `carry` key written even when empty (`if self.carry` → `if True`) | caught: `test_an_axis_that_carries_nothing_writes_no_carry_key` | clean | The last test was written for that mutation. The round-trip and archive tests would not catch it, because both sides go through `to_dict`. </details> <details><summary>Gates, and what was not done</summary> - **Gates**, on `8a558c0`. `pixi run check`: lint, format and typecheck clean, 5621 passed, 566 skipped, 4 xfailed. `pixi run -e docs docs-test`: 112 passed, so the new docstring links resolve. `pixi run -e docs docs-build` passed, strict, on `ab3f613`; the later commit touches no page. - **Docs.** The sweeps reference ("The axes") says an axis is also plain data and how a bad mapping is refused. - **Not done.** `load_archive` and `load_inputs` still give the axis back as an object, not a dict. #1888 adds a tuple of axes; when it merges this in, `to_dict` and `axis_from` need a form for the tuple. The `each` key and its two values are still string literals, in `to_dict` and `_EACH`. The browser app's worker is not changed here. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01G1TmcQZZCctsVr54HE93qS --------- Co-authored-by: Claude <noreply@anthropic.com>
…rt-e3g6om-windows Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
…nce one axis writes the axis.json main writes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
The following content was generated by AI.
solve_overtakes a tuple of axes, outer first, each with its owncarry=(#1980), and any of them may be windows. A carry reaches its axis's next label; an executor runs independent chains concurrently. Each chain answers exactly as its own sweep would.What changed
key_name=with a tuple are refused before a slice is built.Slice.ownsholds one count per axis.'previous'(refactor(api): keep= is gone, and a re-solve carries on from where the last solve ended unless start=None says otherwise #1884) carries on along it; otherwise each slice is one task. A sweep of one chain still refuses an executor with a carry.Slice.keyis a tuple of labels,KeyColumnscarries the key columns and their types through the spill, the record and the archive.Sweep.key_namesis new;Sweep.key_nameis the last.Sweep.keysholds labels for one axis and tuples for several. On disk,slice_axisandslicejoin names and labels with/.{'each': 'axes', 'axes': [each.to_dict(), ...]}.solve_overtakes it in the tuple's place, and a nested sweep'saxis.jsonis it. One axis writes exactly what main writes.keep_windows,per_window,start=an earlier sweep, and an undeclared expression off an archive all work over a tuple;load_archiveandload_inputsgive the tuple back as.axis. The archive's uncut sources drop every window's local index.ANSWER_LAYOUTrises from 4 to 5 (the spill manifest nameskey_names, and the stitch its locals, dims, keys and outer keys), once since 0.8.1.INPUTS_LAYOUTstays 1: one axis writes theaxis.jsonmain writes, so every 0.8.1 archive's spec and sources still read; a tuple'saxis.jsonis new, and 0.8.1 cannot read it.Merged with main: #1884, #1980 and #1981
'previous'the default, carrying the solver on rather than matching the slice before by coordinate. Each slice passes its own start toModel.solve; a chain is one task only where an axis carries. The refusal of'previous'with an executor on one chain went with its test and its mutation row.carry=onto the axis for one axis, which this PR had done for every axis. Main's version is taken; the tuple keeps each axis's own carry.to_dictandaxis_from. The tuple form above extends it; the stack's ownaxis_manifestis gone.Breaking, and coverage that moved
Slice.keyis a tuple, andSlice.ownsone count per axis.ANSWER_LAYOUT;load_inputsstill reads a 0.8.1 archive's spec and sources.window-outsidebecameone-local-index-for-two-windows. Theno-eachrefusal now listsaxesamong the kinds.Tests
tests/test_nested_sweep.py. The oracle is the loop it replaces.existingcopied by hand from the previous period's last window; 2040's first window differs from 2030's, so the fleet did arrive.(period, snapshot)once, the lookahead decade dropped; per window it keeps both keys and both locals. With the fleet carried, each decade window equals its own hourly horizon over its decade and the next,existingset by hand; every window's objectives match.archive.axis, evaluate an undeclared expression;start=an earlier nested sweep gives each window its own rows. A nestedaxis.jsonis the tuple's plain data, reads back as the tuple, and solves to the same answer.Where a start is in play, tests compare objectives: this LP has several optimal vertices, and a start can end on another.
Mutation table
python -m tools.mutatefor the deletions, scoped totests/test_nested_sweep.pyandtests/test_strategy.py; hand mutations bysed, restored withgit checkout --,__pycache__dropped on both sides. Rows 1–13 were taken on 1de4d33 and rows 14–16 on 507123e, before main was merged in, so their line numbers are those commits'; row 17 on e1a9ebb. Clean after every run.axes.py:337-341)axes.py:342-347), since replaced by row 14axes.py:349-353)strategy.py:380-385)key_name=with a tuple of axes (strategy.py:962-966)strategy.py:197-201)strategy.py:719)on=keys)axes.py:354-358)owns[-1])eachand a list ofaxes(axes.py:524-528)Verification
pytest -n 4, HiGHS, Gurobi and Xpress) on 740bd8b, main through 9c13352 merged in, under polars 2.0.0: 5570 passed, 566 skipped, 4 xfailed.python -m tools.changelog check: releases nothing on merge.ruff check .,ruff format --check .,pyrefly check: 0 errors.zensical build --strict: pass.test-bench(nothing underbench/changed). No benchmark: nothing claims a speed. CodSpeed's memory flag on an earlier head compared against a main older than chore(bench): an A/B of two git refs over a grid of models proves a change is faster and builds the same model #1940, and this PR touches nothing a benchmark builds.Deliberately not done
solve_overtosolve_sweep: its own PR.per_window=Truereturns the innermost windows, keyed by every window key.🤖 Generated with Claude Code
https://claude.ai/code/session_014wLrXpeSGDqoBFxyrs8NEz