Skip to content

feat(program): a model says whether a horizon may be solved a window at a time, before a driver cuts one - #248

Closed
FBumann wants to merge 2 commits into
mainfrom
claude/separable-along
Closed

FBumann wants to merge 2 commits into
mainfrom
claude/separable-along

Conversation

@FBumann

@FBumann FBumann commented Aug 29, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: "I really like both 2 AND 3 … Keep it simple and encapsulated, like the rest of the codebase" — and, on the shape it took, "Why does separability not live on program? … I think it should" and "Why not a dict with each dim as a key?"

Note

The following content was generated by AI.

What this changes

One dataclass and one attribute, beside the Footprint the program already answers.

program.separability['snapshot'].windowable   # True
program.separability['snapshot'].halo         # 1 — two windows overlap by a row
program.separability['snapshot'].coupled      # {} — nothing ties the axis together

A rolling-horizon or myopic driver asks one thing of a model before it starts: would windowing change the answer? Storage carried over a snapshot survives being cut into overlapping windows. An annual budget does not — and both window into pieces that solve, so nothing else says so.

pointwise only                     windowable=True  halo=0
storage carried over one row       windowable=True  halo=1
cyclic storage                     windowable=False constraint 'carry': wraps around snapshot,
                                                    so its first row reads its last
an annual budget                   windowable=False constraint 'annual': sums over snapshot

Why it belongs here, and in this shape

It is the locality ceiling.md already argues in — pointwise, bounded halo, global — asked about a dimension rather than about an operator. The language owns those words and never answered the question with them.

Derived, not declared. Not a PiecewiseDeclaration: nothing writes it, and a separable: key would let a file claim what the compiler can check. The precedent is Footprint.

On the Program, not the Spec. Locality lives in Translate, Window and Sum — lowered nodes. A Spec holds expression strings, so answering there means parsing twice.

Two arguments in this section were wrong, and the review corrected both. I first put it in a module beside boundedness.py and defended that on dependency direction; where_parser imports only errors and expression_parser, so there was no direction to protect, and Program.dimension(name) already shows a parameterised accessor is idiomatic here. Then I defended a function over a cached_property on the grounds that a property would have to walk every axis eagerly — and dismissed the cost with a measurement of ten walks read as one. Measured properly, on the shipped example: one walk answering every axis is 831 µs against 844 µs for a single axis, because every construct that ties an axis names the axis it ties. So the walk is per-program, not per-dimension, and the shape is a cached_property returning a mapping keyed by dimension.

The crux

A reduction means opposite things by position. In a constraint, a sum over the axis ties every window to every other. In the objective it is additively separable — an objective is a sum, so summing the windows' objectives is summing the model's. A verdict treating the two alike would refuse every windowable model there is; the mutation table below shows it fails six tests.

Two bugs the tests caught, and one was the motivating case

A cases: mask did not reach the walk. The first version reported a cases: block seeded with position(h) == 0 as windowable. It is not: the seed fires once over a horizon and once per window. children(Cases) returns a region's value and not its when, so no expression walk reaches a mask written inside cases: — and a rolling horizon seeding its opening state is exactly that shape. The masks a block is judged on are now collected during the walk rather than read off the declaration alone. Worth knowing for any future analysis pass: that is a trapdoor in children, not in this module.

Converting to a mapping introduced a second one. Sum.over is a tuple, and the per-axis version had only ever been asked about one axis at a time. A mutation coupling only the first axis of a multi-axis reduction survived all 902 tests — a hole the conversion opened and nothing existing could see. test_a_reduction_over_several_axes_couples_every_one_of_them is that hole closed.

What it does not decide

Whether the modeller wanted the window. A position(t) == 0 seed is a horizon's start or a window's start depending on intent, and both are models somebody means. This reports that windowing changes which rows it selects and stops.

What lpspec removes or changes

Nothing today; one thing worth having. solve_over already sweeps an axis, and strategy.py already drives a build a slice at a time — neither asks whether slicing is sound, because nothing could answer. A driver can now refuse, or warn, before solving windows whose answer is wrong.

That is the whole of it: no verb changes, nothing binds differently, and a consumer that ignores this is exactly as correct as it is today.

Verified

Toolchain at the versions pixi.toml pins: ruff format --check / ruff check (0.16.1) clean, pyrefly (1.2.0) 0 errors, pytest -q 905 passed, 0 skipped (885 on the base — 20 new). reuse, typos, prettier --check, render_tex (27 models) and mkdocs build --strict were run on the top of the stack (#250), which contains these commits.

The reading.md section is executed — that page's Python blocks run against a real model and every # value line is checked. Its own example is the page's own model, which separates along neither axis, and the report names the three constraints the piecewise: block emitted — a coupling introduced by an expansion, named under the name the expansion gave it.

Method for the 831 µs / 844 µs above: timeit on the shipped reading.md model, _separabilities(program) against the single-axis version at the same base commit, best of five, cache bypassed each call. It is the whole reason the walk is not held per dimension, so it is quoted here rather than left in the tree.

Not run: zizmor, taplo, and the tectonic half of compile-tex. mkdocs build --strict needed docs.python.org/objects.inv dropped from mkdocs.yml, which this environment's proxy refuses.

Mutation table

Mutation Result
the objective is treated like a constraint 6 failed
a wrapped shift reports a halo instead of coupling 1 failed
a parameter offset is treated as a literal 3 failed
cased regions stop contributing their masks 1 failed
a sum_back halo is the width rather than width − 1 1 failed
only the first axis of a multi-axis reduction couples 1 failed (0 before the test above was added)
restored all green

Deliberately not done

No public where-walker. A where tree is not an expression tree, so this needed a small recursion of its own. It is private, with one caller; it belongs beside children in where_parser the day there are two, and the docstring says so.

halo is a plain int, never int | None. It is how far the program reads across the axis, which is true whether or not anything couples — overlap alone is only enough where nothing does. The two questions are already separate (windowable, then halo), and a None a caller must check twice is worse than one they read after the verdict. An earlier version of this description, and of the docstring, said halo was "meaningful only where nothing is coupled"; that was wrong about what the number is, and the review fixed both.

Stack: seventh, on #247 → #246 → #245 → #244 → #243 → #242 → #168.


Generated by Claude Code

Closes #298

@read-the-docs-community

read-the-docs-community Bot commented Aug 29, 2026 •

Copy link
Copy Markdown

FBumann pushed a commit that referenced this pull request Aug 29, 2026
…ing for one already walked the whole program

It was a method taking a dimension, which meant a walk per axis and a caller
that had to know which axis to ask about. Every construct that ties an axis
names the axis it ties, so one walk answers for all of them: measured at 831us
against 844us for the single axis it replaced, on the largest model shipped
(#248).

So it is a cached mapping like the footprint beside it, complete over the
declared dimensions — an axis nothing mentions is trivially windowable rather
than absent. A reduction collapsing several axes now couples each of them,
which the per-axis form could not get wrong and this one could.

Co-Authored-By: Claude <noreply@anthropic.com>
FBumann pushed a commit that referenced this pull request Aug 30, 2026
…ing for one already walked the whole program

It was a method taking a dimension, which meant a walk per axis and a caller
that had to know which axis to ask about. Every construct that ties an axis
names the axis it ties, so one walk answers for all of them: measured at 831us
against 844us for the single axis it replaced, on the largest model shipped
(#248).

So it is a cached mapping like the footprint beside it, complete over the
declared dimensions — an axis nothing mentions is trivially windowable rather
than absent. A reduction collapsing several axes now couples each of them,
which the per-axis form could not get wrong and this one could.

Co-Authored-By: Claude <noreply@anthropic.com>
@FBumann
FBumann force-pushed the claude/separable-along branch from aac6faf to d48505b Compare August 30, 2026 07:13
FBumann pushed a commit that referenced this pull request Aug 30, 2026
…ing for one already walked the whole program

It was a method taking a dimension, which meant a walk per axis and a caller
that had to know which axis to ask about. Every construct that ties an axis
names the axis it ties, so one walk answers for all of them: measured at 831us
against 844us for the single axis it replaced, on the largest model shipped
(#248).

So it is a cached mapping like the footprint beside it, complete over the
declared dimensions — an axis nothing mentions is trivially windowable rather
than absent. A reduction collapsing several axes now couples each of them,
which the per-axis form could not get wrong and this one could.

Co-Authored-By: Claude <noreply@anthropic.com>
@FBumann
FBumann force-pushed the claude/separable-along branch from d48505b to c5065c2 Compare August 30, 2026 07:17
FBumann pushed a commit that referenced this pull request Aug 30, 2026
…ing for one already walked the whole program

It was a method taking a dimension, which meant a walk per axis and a caller
that had to know which axis to ask about. Every construct that ties an axis
names the axis it ties, so one walk answers for all of them: measured at 831us
against 844us for the single axis it replaced, on the largest model shipped
(#248).

So it is a cached mapping like the footprint beside it, complete over the
declared dimensions — an axis nothing mentions is trivially windowable rather
than absent. A reduction collapsing several axes now couples each of them,
which the per-axis form could not get wrong and this one could.

Co-Authored-By: Claude <noreply@anthropic.com>
@FBumann
FBumann force-pushed the claude/separable-along branch from c5065c2 to 5f083ef Compare August 30, 2026 18:19
FBumann pushed a commit that referenced this pull request Aug 31, 2026
…ing for one already walked the whole program

It was a method taking a dimension, which meant a walk per axis and a caller
that had to know which axis to ask about. Every construct that ties an axis
names the axis it ties, so one walk answers for all of them: measured at 831us
against 844us for the single axis it replaced, on the largest model shipped
(#248).

So it is a cached mapping like the footprint beside it, complete over the
declared dimensions — an axis nothing mentions is trivially windowable rather
than absent. A reduction collapsing several axes now couples each of them,
which the per-axis form could not get wrong and this one could.

Co-Authored-By: Claude <noreply@anthropic.com>
@FBumann
FBumann force-pushed the claude/separable-along branch from 5f083ef to 5918b28 Compare August 31, 2026 18:08
@FBumann
FBumann changed the base branch from claude/the-waist-as-a-value to claude/what-counts-as-a-verb August 31, 2026 18:08
…at a time, before a driver cuts one

A rolling horizon or a myopic pathway asks one thing of a model before it
starts: would windowing change the answer? separability reports the overlap two
windows need and, where none would do, names each declaration and the construct
tying the axis together. A reduction means opposite things by position — in a
constraint a sum ties every window to every other, in the objective it is
additively separable.

Squashed from the four commits of claude/separable-along and rebased onto main
alone, dropping the closed #247 serialisation base it was stacked on.

Co-Authored-By: Claude <noreply@anthropic.com>
@FBumann
FBumann force-pushed the claude/separable-along branch from 5918b28 to b207667 Compare August 31, 2026 18:29
FBumann added a commit that referenced this pull request Aug 31, 2026
…bproblem is a call rather than a second file

A myopic pathway, a rolling horizon and a Benders subproblem share one move: a
variable stops being a decision and becomes a number somebody else chose. fix
takes every name in one call and validates once at the end. Two translations are
decisions, not copies: a where-masked variable becomes coverage: masked, and a
binary or integer variable becomes an int parameter — never a float, never a
bool that would read as a mask.

Rebased onto the parameter-coverage branch (#243) it needs for Coverage, off the
closed #247/#248 stack it was authored on; only the fix additions are kept.

Co-Authored-By: Claude <noreply@anthropic.com>
@FBumann
FBumann changed the base branch from claude/what-counts-as-a-verb to main August 31, 2026 18:29
@FBumann
FBumann marked this pull request as ready for review August 31, 2026 20:48
@FBumann
FBumann requested a review from brynpickering as a code owner August 31, 2026 20:48
FBumann added a commit that referenced this pull request Sep 2, 2026
…a time, and what each coordinate needs from its neighbours

One dataclass and one attribute beside the Footprint the program already
answers: program.separability[dim] says how far a row reads behind and
ahead along the axis, which declarations tie it together and the modelling
change that would lift each, which reaches only data can decide and the
parameter or lookup that decides them, and which masks count a position a
window would restart. windowable and independent are the two verdicts a
rolling-horizon driver and a scenario sweep ask before cutting a slice.

Carries #248 rebased onto alpha.67, where a mask is a Mask and its leaves
come off Mask.atoms rather than a walk of its own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPBsP3UM6esCBREVmjMEhZ
FBumann added a commit that referenced this pull request Sep 2, 2026
…a time, and what each coordinate needs from its neighbours

One dataclass and one attribute beside the Footprint the program already
answers: program.separability[dim] says how far a row reads behind and
ahead along the axis, which declarations tie it together and the modelling
change that would lift each, which reaches only data can decide and the
parameter or lookup that decides them, and which masks count a position a
window would restart. windowable and independent are the two verdicts a
rolling-horizon driver and a scenario sweep ask before cutting a slice.

Carries #248 rebased onto alpha.67, where a mask is a Mask and its leaves
come off Mask.atoms rather than a walk of its own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPBsP3UM6esCBREVmjMEhZ
FBumann added a commit that referenced this pull request Sep 2, 2026
…a time, and what each coordinate needs from its neighbours

One dataclass and one attribute beside the Footprint the program already
answers: program.separability[dim] says how far a row reads behind and
ahead along the axis, which declarations tie it together and the modelling
change that would lift each, which reaches only data can decide and the
parameter or lookup that decides them, and which masks count a position a
window would restart. windowable and independent are the two verdicts a
rolling-horizon driver and a scenario sweep ask before cutting a slice.

Carries #248 rebased onto alpha.67, where a mask is a Mask and its leaves
come off Mask.atoms rather than a walk of its own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPBsP3UM6esCBREVmjMEhZ
FBumann added a commit that referenced this pull request Sep 2, 2026
…a time, and what each coordinate needs from its neighbours

One dataclass and one attribute beside the Footprint the program already
answers: program.separability[dim] says how far a row reads behind and
ahead along the axis, which declarations tie it together and the modelling
change that would lift each, which reaches only data can decide and the
parameter or lookup that decides them, and which masks count a position a
window would restart. windowable and independent are the two verdicts a
rolling-horizon driver and a scenario sweep ask before cutting a slice.

Carries #248 rebased onto alpha.67, where a mask is a Mask and its leaves
come off Mask.atoms rather than a walk of its own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPBsP3UM6esCBREVmjMEhZ
FBumann added a commit that referenced this pull request Sep 2, 2026
…a time, and what each coordinate needs from its neighbours

One dataclass and one attribute beside the Footprint the program already
answers: program.separability[dim] says how far a row reads behind and
ahead along the axis, which declarations tie it together and the modelling
change that would lift each, which reaches only data can decide and the
parameter or lookup that decides them, and which masks count a position a
window would restart. windowable and independent are the two verdicts a
rolling-horizon driver and a scenario sweep ask before cutting a slice.

Carries #248 rebased onto alpha.67, where a mask is a Mask and its leaves
come off Mask.atoms rather than a walk of its own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPBsP3UM6esCBREVmjMEhZ
@FBumann

FBumann commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Superseeded by #374

@FBumann FBumann closed this Sep 2, 2026
FBumann added a commit that referenced this pull request Sep 2, 2026
…a time, and what each coordinate needs from its neighbours (#374)

* feat(program): a model says whether a horizon may be solved a window at a time, before a driver cuts one

A rolling horizon or a myopic pathway asks one thing of a model before it
starts: would windowing change the answer? separability reports the overlap two
windows need and, where none would do, names each declaration and the construct
tying the axis together. A reduction means opposite things by position — in a
constraint a sum ties every window to every other, in the objective it is
additively separable.

Squashed from the four commits of claude/separable-along and rebased onto main
alone, dropping the closed #247 serialisation base it was stacked on.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(program): a model says whether an axis may be built a window at a time, and what each coordinate needs from its neighbours

One dataclass and one attribute beside the Footprint the program already
answers: program.separability[dim] says how far a row reads behind and
ahead along the axis, which declarations tie it together and the modelling
change that would lift each, which reaches only data can decide and the
parameter or lookup that decides them, and which masks count a position a
window would restart. windowable and independent are the two verdicts a
rolling-horizon driver and a scenario sweep ask before cutting a slice.

Carries #248 rebased onto alpha.67, where a mask is a Mask and its leaves
come off Mask.atoms rather than a walk of its own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPBsP3UM6esCBREVmjMEhZ

* refactor(program): an undecided reach says what it stands as, and the verdict folds a parameter's values in

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgEyTfEbZzdTFavr43bRgr

* refactor(program): a verdict reports only what a window reads ahead, and nothing reads it for a scenario sweep

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgEyTfEbZzdTFavr43bRgr

---------

Co-authored-by: Claude <noreply@anthropic.com>
@FBumann
FBumann deleted the claude/separable-along branch September 9, 2026 06:45
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.

nothing says whether a horizon may be solved a window at a time, so every driver decides for itself

1 participant