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
56 changes: 45 additions & 11 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,38 +112,38 @@ flowchart TB

Y --> AST

AST["the whole model, typed and checked<br/>before a byte of data is read"]
AST["the whole model, typed and checked<br/>before a byte of data is read<br/><b>lps.load_schema → MathSchema</b>"]

AST --> SHOW
AST --> CHECK
AST --> RUN

subgraph SHOW["show it — no data, no solver"]
direction TB
S1(["typeset the math for a paper or a review"])
S2(["drive it from the command line"])
S1(["typeset the math for a paper or a review<br/><b>lps.to_latex · to_typst · to_markdown</b><br/>how names print: <b>SymbolTable</b>"])
S2(["drive it from the command line<br/><b>python -m lpspec &lt;format&gt;</b>"])
S3(["watch what a build is doing"])
end

subgraph CHECK["check it — no data, no solver"]
direction TB
C1(["will this build, and is the math sayable?"])
C2(["do the dimensions line up?"])
C1(["will this build, and is the math sayable?<br/><b>lps.check</b>"])
C2(["do the dimensions line up?<br/><b>lps.check</b> — same pass, same answer"])
C3(["will that solver take it, and how big is it?"])
end

subgraph RUN["run it — the only part that touches your data"]
direction TB
R1(["stream it straight into a solver"])
R2(["write an LP file for anything else"])
R3(["put the same math on a linopy model"])
R1(["stream it straight into a solver<br/><b>lps.solve</b> · <b>lps.build</b> for several sinks"])
R2(["write an LP file for anything else<br/><b>lps.write</b>"])
R3(["put the same math on a linopy model<br/><b>lpspec.linopy.build · .extend</b>"])
end

R1 --> ANS

subgraph ANS["your answers, as tables you can join"]
direction TB
A1(["values and shadow prices, by name"])
A1(["values and shadow prices, by name<br/><b>result.objective · .primal · .dual</b><br/>bridges out: <b>.to_pandas · .to_parquet</b>"])
A2(["derived results and diagnostics"])
A3(["change a number, re-solve, keep the labels"])
end
Expand All @@ -158,7 +158,10 @@ flowchart TB
class RUN,CHECK,SHOW,ANS fam
```

**Solid is what ships today; dashed is what the shape makes cheap.** None of the
**Solid is what ships today; dashed is what the shape makes cheap** — which is
also to say the solid boxes are the ones with a name in them. That is the whole
Python surface bar the error hierarchy, which is cross-cutting rather than a
box; [the table below](#the-python-surface) is the same set, listed. None of the
dashed boxes is a rewrite — each reads the same AST the engine reads, so a
renderer is a tree walk, a check is a pass with no data bound, and a new output
format is one function in `relational/sinks/`. `typeset/` is that claim cashed:
Expand Down Expand Up @@ -188,6 +191,34 @@ ceiling in [docs/design/ceiling.md](design/ceiling.md) protects — a new consum
is free, a new primitive is taxed. What is planned, and why, is
[docs/ROADMAP.md](ROADMAP.md).

### The Python surface

**Sixteen names, and that is the feature.** The model is the YAML file; Python
is how you *run* it, so the surface is the diagram above written out — one
group per box, nothing that constructs math, nothing that reaches the plan.

| | `lpspec.` | |
|---|---|---|
| **run it** | `solve` · `build` · `write` · `check` | the four verbs; `check` binds no data |
| **load it** | `load_schema` · `MathSchema` | parse and validate; the schema is the contract under the YAML |
| **show it** | `to_latex` · `to_typst` · `to_markdown` · `SymbolTable` | one per format, plus how names print |
| **catch it** | `LinopyYamlError` · `LanguageError` · `DataError` · `DimensionError` · `SchemaError` · `PiecewiseExpansionError` | one hierarchy, split model from run |

Plus two on the opt-in shim — `lpspec.linopy.build` and `.extend` — and the
methods of what `solve` hands back (`Result`: `objective`, `primal`, `dual`,
`to_pandas` / `to_dataarray` / `to_parquet`, the status pair, `close`).

`tests/test_architecture.py` pins all of it: `__all__` must match the table,
**and** no public non-module attribute may exist outside it. Both directions,
because either alone rots — the first catches a name documented and never
exported, the second a helper that leaked into the namespace by being imported
at the top of `__init__.py`. That check found one the day it was written.

There is deliberately no Python API for *constructing* a model, no way to hand
in a plan, and no registry to populate. That is hard rule 5 below, and it is
what makes a `.yaml` file the thing you review, diff and cite — rather than the
serialisation of a Python object you would have to run to understand.

## Hard rules

*Enforced, not aspirational: `tests/test_architecture.py` encodes these as
Expand Down Expand Up @@ -247,7 +278,10 @@ that made an implementation choice load-bearing in the language's rulebook.
whether that seam is ever blessed is open (see
[Composition](design/ceiling.md#composition-component-libraries)). The Python
surface is the runner (`api.py`); the plan is internal, and a stable
plan-construction API is a later possibility, not a current contract.
plan-construction API is a later possibility, not a current contract. The
whole of it is [sixteen names](#the-python-surface), pinned by a test — so
the surface grows through a list a reviewer reads, the way every other fence
here does.

## The relational lane

Expand Down
4 changes: 2 additions & 2 deletions src/lpspec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
result.to_dataarray('p') # labelled, for array post-processing
"""

from importlib.metadata import PackageNotFoundError
from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
from importlib.metadata import version as _installed_version

from lpspec.api import build, check, load_schema, solve, write
Expand Down Expand Up @@ -52,5 +52,5 @@
try:
# the git tag is the source of truth; hatch-vcs bakes it into the metadata
__version__ = _installed_version('lpspec')
except PackageNotFoundError: # running from a source tree with nothing installed
except _PackageNotFoundError: # running from a source tree with nothing installed
__version__ = '0.0.0'
77 changes: 77 additions & 0 deletions tests/test_architecture.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,83 @@ def test_typesets_import_closure_needs_no_third_party_engine():
)


#: The whole Python surface, by role. Hard rule 5 says the public interface is
#: a declared model rather than a Python API — this is what "rather than" is
#: worth in names. Adding one is a row here, which is a line in a diff a
#: reviewer reads; the fences elsewhere in this file work the same way.
PUBLIC_API = {
'run it': {'build', 'check', 'solve', 'write'},
'load it': {'load_schema', 'MathSchema'},
'show it': {'to_latex', 'to_markdown', 'to_typst', 'SymbolTable'},
'catch it': {
'LinopyYamlError',
'LanguageError',
'DataError',
'DimensionError',
'SchemaError',
'PiecewiseExpansionError',
},
}

#: The opt-in shim, which is a surface of its own — and deliberately two verbs.
PUBLIC_API_LINOPY = {'build', 'extend'}


def test_the_public_surface_is_exactly_what_is_declared():
"""Hard rule 5, in names: the Python surface is narrow, and stays narrow.

Narrow is a feature, not an accident — it is the half of "the public
interface is a declared model" that a reader can count. A model travels as
YAML; Python is how you *run* it, so the runner has four verbs, the
language one loader, the readers one each, and the errors one hierarchy.

Two directions, because either alone rots. ``__all__`` must match the
table (a name added quietly, or documented and never exported), and no
public non-module attribute may exist outside it (a helper that leaked
into the namespace by being imported at the top of ``__init__``).
"""
import inspect

import lpspec

declared = {name for names in PUBLIC_API.values() for name in names}
assert set(lpspec.__all__) == declared, (
f'lpspec.__all__ and PUBLIC_API disagree: only in __all__ '
f'{sorted(set(lpspec.__all__) - declared)}, only in the table '
f'{sorted(declared - set(lpspec.__all__))} — add the name to PUBLIC_API '
f'with the role it plays, and to docs/ARCHITECTURE.md'
)

leaked = sorted(
name
for name in dir(lpspec)
if not name.startswith('_')
and name not in declared
and not inspect.ismodule(getattr(lpspec, name)) # submodules are import paths, not API
)
assert not leaked, (
f'public names outside __all__: {leaked} — a surface that grows by '
f'accident is not narrow. Import it privately, or declare it.'
)


def test_the_linopy_shim_stays_two_verbs():
"""The shim attaches YAML math to a model that already exists.

That is the whole of it — ``build`` makes one, ``extend`` adds to one. A
third verb would mean the shim had started being a lane of its own, which
hard rule 3 spends its length refusing. Read statically: the module
imports linopy, and this must run on a bare install.
"""
tree = ast.parse((PKG / 'linopy' / '__init__.py').read_text())
declared = next(
ast.literal_eval(node.value)
for node in tree.body
if isinstance(node, ast.Assign) and any(ast.unparse(t) == '__all__' for t in node.targets)
)
assert set(declared) == PUBLIC_API_LINOPY, f'the linopy shim exports {sorted(declared)}'


def test_expansion_has_no_mutable_module_state():
"""Hard rule 5: YAML files are self-contained — nothing importable may
accumulate state that changes what a file means."""
Expand Down