diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 07cc2ef4a..54a11c6c8 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -112,7 +112,7 @@ flowchart TB
Y --> AST
- AST["the whole model, typed and checked
before a byte of data is read"]
+ AST["the whole model, typed and checked
before a byte of data is read
lps.load_schema → MathSchema"]
AST --> SHOW
AST --> CHECK
@@ -120,30 +120,30 @@ flowchart TB
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
lps.to_latex · to_typst · to_markdown
how names print: SymbolTable"])
+ S2(["drive it from the command line
python -m lpspec <format>"])
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?
lps.check"])
+ C2(["do the dimensions line up?
lps.check — 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
lps.solve · lps.build for several sinks"])
+ R2(["write an LP file for anything else
lps.write"])
+ R3(["put the same math on a linopy model
lpspec.linopy.build · .extend"])
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
result.objective · .primal · .dual
bridges out: .to_pandas · .to_parquet"])
A2(["derived results and diagnostics"])
A3(["change a number, re-solve, keep the labels"])
end
@@ -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:
@@ -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
@@ -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
diff --git a/src/lpspec/__init__.py b/src/lpspec/__init__.py
index 7a67ec52b..dd16389cb 100644
--- a/src/lpspec/__init__.py
+++ b/src/lpspec/__init__.py
@@ -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
@@ -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'
diff --git a/tests/test_architecture.py b/tests/test_architecture.py
index 67b557a64..0e2911d6f 100644
--- a/tests/test_architecture.py
+++ b/tests/test_architecture.py
@@ -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."""