Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions src/lpspec/relational/engines/polars/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,15 +382,23 @@ def _read_back(
) -> pl.LazyFrame:
"""One declaration's coordinates in label order, beside its values.

**The order is not re-established here, because it was never lost.**
Every path in :meth:`~lpspec.relational.engines.polars.labels.Labeller.frame`
hands back a label-ascending frame and two of them verify it
(:func:`~lpspec.relational.engines.polars.labels._in_label_order`), so
this reads the ordering rather than imposing it. Sorting again moved a
full copy of the coordinates — strings included — at the moment the
solver's own model is still resident, which is the worst point in the
process to allocate one.

The slice is attached as a column rather than concatenated as a frame
so that a length that does not match the coordinates raises instead of
padding with nulls — the block bookkeeping is an invariant, and the one
way it could go wrong is the one a silently short vector would hide.
:func:`_spanning` has already refused a vector that does not span the
model, so what is left here is the block bookkeeping alone.
padding with nulls — though :func:`_spanning` has already refused a
vector that does not span the model, so what is left here is the block
bookkeeping alone.
"""
start, height = self._blocks[name]
return labels.sort(label).select(*dims).with_columns(values.slice(start, height))
return labels.select(*dims).with_columns(values.slice(start, height))

def _primal(self, name: str, values: pl.Series | None) -> pl.DataFrame:
return self._solution_frame(name, values).collect(engine='streaming')
Expand Down
4 changes: 3 additions & 1 deletion src/lpspec/relational/engines/polars/labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,9 @@ def _in_label_order(frame: pl.DataFrame, label: str) -> pl.DataFrame:
(`compiler._coordinate_product`). That is an implementation detail of a
dependency, and a label is arithmetic on ordinals rather than a position —
so a change there would not corrupt a *label*. It would silently stop the
frame being sorted, and `cols` is read positionally.
frame being sorted, and **two readers take that order on trust**: `cols` is
handed to a solver positionally, and `executor._read_back` reads a solution
back against these coordinates without sorting them again.

So the claim is verified. `is_sorted` is a linear scan over a column the
frame already holds; the sort behind it is the correctness floor and is
Expand Down
33 changes: 33 additions & 0 deletions tests/test_relational.py
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,39 @@ def test_infinite_bounds_survive_the_handoff(dispatch_data):
assert ex.solve().is_ok


def test_a_solution_is_read_back_in_label_order_without_sorting_for_it():
"""The order is produced by the labeller, so the read-back only reads it.

Seeding the vector with an ``arange`` makes every value its own label, so a
read-back in label order comes out ascending — which is the contract
`sol.primal` states, asserted directly rather than through the sort that
used to establish it. The mask puts the variable on the factored path and
the constraint on the counted one, so neither is ordered by being a scan.

The plan assertion is the other half: re-imposing the order is not wrong,
it moved a full copy of the coordinates — 681 MB reading `dispatch/l` back
— at the moment the solver's own model is still resident.
"""
model = {
'dimensions': {'t': {'dtype': 'int', 'values': [0, 1, 2, 3]}, 'g': {'values': ['a', 'b', 'c']}},
'parameters': {'cap': {'dims': ['g']}, 'load': {'dims': ['t']}},
'variables': {'p': {'foreach': ['t', 'g'], 'where': 'cap > 0', 'bounds': {'lower': 0, 'upper': 'cap'}}},
'constraints': {'meet': {'foreach': ['t'], 'where': 'load > 0', 'expression': 'sum(p, over=g) >= load'}},
'objectives': {'o': {'sense': 'minimize', 'expression': 'sum(sum(p, over=g), over=t)'}},
}
sources = {
'cap': pl.DataFrame({'g': ['a', 'b', 'c'], 'value': [5.0, 0.0, 7.0]}),
'load': pl.DataFrame({'t': [0, 1, 2, 3], 'value': [1.0, 0.0, 2.0, 3.0]}),
}
with lps.build(model, sources) as ex:
primal = pl.Series('value', np.arange(ex._n_cols, dtype=np.float64))
dual = pl.Series('value', np.arange(ex._n_rows, dtype=np.float64))
variable = ex._solution_frame('p', primal)
assert 'SORT' not in variable.explain(optimized=False), 'the labeller already ordered this'
assert variable.collect()['value'].to_list() == list(range(len(primal))), 'primal not in label order'
assert ex._dual('meet', dual)['value'].to_list() == list(range(len(dual))), 'dual not in label order'


@pytest.mark.parametrize('length', [2, 5], ids=['short', 'long'])
def test_a_solver_vector_that_does_not_span_the_model_is_refused(monkeypatch, length):
"""A wrong length is a different model's answer, not a short one.
Expand Down
Loading