Skip to content

Which layer owns which rule

fluxopt refuses a bad model in four places. That is one more than most projects need and fewer than it looks, because each answers a question the others cannot — but only if a rule is written in the layer that can answer it.

This page exists because two of them had drifted. A rule about a single field's value was being enforced on a materialised array three layers away, and seven checks sat in a layer that could never reach them. Neither was visible without going looking, so the point of writing the ownership down is that the next check has an obvious home and the next reader can tell when one is in the wrong one.

The four

layer answers fires when
1 the element — pydantic on elements.py / components.py is this one element internally coherent? the user constructs it
2 the system — validation.validate_system do these elements refer to each other resolvably? FlowSystem(...), and ModelData.build
3 the data — ModelData.__post_init__ and each container's is this table self-consistent? building it, and reloading it
4 the bind — specsolve, with the program's assumptions: does this data fit the program, and hold what it assumes? every bind: solve, build, a sweep

1. The element

A rule decidable from one element's own fields. size_max >= size_min, uptime_max >= uptime_min, PiecewiseConversion needs >= 2 flows, method being one of four literals.

This is the layer with the best error, and it is not close. It fires on the value the user typed, names the field, and quotes what was passed:

Sizing(size_min=-5, size_max=9)
  -> size_min: Input should be greater than or equal to 0 [input_value=-5]

If a rule can be stated here, state it here. The same rule enforced at layer 3 reads Sizing.size_min < 0 on [np.str_('f')] — the entity reconstructed from an array coordinate, several hundred lines from the mistake.

2. The system

A rule needing more than one element: duplicate ids, a flow naming a carrier nobody declared, an effect referenced but never defined, a node not in its carrier's node list, an objective naming an effect that does not exist.

Layer 1 cannot see any of these, because an element does not know what else exists. validate_system runs on every path into the data layer, which is what makes it the place to put such a rule once.

3. The data

A rule about a materialised table's internal consistency: pair_converter naming a converter the table does not carry, governed_by naming a component without a Status, a ramp limit on a flow with no size.

Two things reach this layer that the earlier ones could not see, and both are real reasons for it to exist.

Reload. ModelData round-trips through a directory of parquet tables, and a file that was hand-edited — or written by an older version — never passed through layers 1 and 2. Every check here is answering "could this table have arrived broken?", and the honest test for whether one belongs is:

Could a caller reach this through the public API without layer 1 or 2 having already refused it?

If not, the check is dead. Seven were: Unknown effect {k!r} in ... in five container builders, all of them behind validate_system's sweep of the same element models. They were reachable only through the private container API.

A value the element could not see is no longer this layer's. A Variate may be a ProfileRef — a name pointing at numbers supplied later — so Storage refuses eta_charge=1.5 at construction and cannot refuse eta_charge=ProfileRef(...). That range, and every other range on a value the program reads, is an assumptions: entry in the fragment that declares the parameter (layer 4). It fires on the numbers that actually reach the program, whether they came from a resolved reference, a reloaded file or a table the caller edited.

What stays here is structure a table can get wrong on reload: a reference to an entity the table does not carry, a literal outside its set, a cycle in contribution_from, a ramp on a flow with no size. A check that duplicates layer 1 for that reason — PiecewiseData.method is a Literal on the element and re-checked here — earns its place, and its docstring should say so. Two value rules stay because the program cannot state them: a status flow's lower bound above zero (the program cannot tell a flow's own status from its component's), and Investment.lifetime (bound only as the window derived from it).

4. The bind

Whether the data fits the program: unknown labels for a declared dimension, a column typed float where the file says bool, a missing lookup column, a constant side the parameters do not cover, a null bound, a duplicate coordinate.

Do not write these. specsolve already does, against the program's own declarations, and its messages name the parameter, the dimension, the offending values and the rewrite. Anything fluxopt writes here is a second implementation of a check the binder is going to run anyway — and one that cannot see the program, so it will be the weaker of the two.

A range on a value the program reads is written here, as an assumption. size_min <= size_max is size_bounds_are_ordered in sizing.yaml; the typeset program prints it, and specsolve refuses a table that breaks it with the entry's name and description. tests/math/test_assumptions.py breaks each one.

Deciding

Can one element answer it alone?                  -> 1, the element
Does it need to see other elements?               -> 2, the system
A range on a value the program reads?             -> 4, an assumptions: entry
Table structure only a reload can break?          -> 3, the data
Is it about shape, dtype, or coverage?            -> 4, leave it to specsolve

Two smells worth naming, both of which had occurred:

  • A rule that has to reconstruct which entity failed is in too deep a layer. It had the entity when the user wrote it.
  • A check nothing can reach is a rule that moved up a layer and left a copy behind. Delete the copy; the earlier one is the one with the better message.

Where this leaves layer 3

The reload half of it stays only as long as ModelData serializes itself field by field. If a reloaded file were rebuilt through ModelData.build rather than reconstructed around it, layers 1 and 2 would run on the way back in and there would be nothing left for reload to catch. That is a consequence of the tidy-frames re-cut rather than a reason for it, but it is a second reason.

The ProfileRef half has moved to the program's assumptions, which check values that arrive later wherever they arrive. So layer 3 is down to the structure of its own tables.