Skip to content

Commit a396190

Browse files
authored
Merge pull request #5 from madsuite-org/anvil/model-selection
Select a model by name in a library carrying several
2 parents c7ec320 + 3856751 commit a396190

3 files changed

Lines changed: 129 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,25 @@ compiled from a recipe names its fields `arg1`, `arg2`, ... for that reason.
100100
Each value is checked against the kind and type the schema declares for its
101101
slot; nothing is coerced across it.
102102

103+
### Several models in one library
104+
105+
One shared library may export any number of models, each under its own symbol
106+
prefix with its own schema and its own instances. A leading string argument
107+
selects one by name — the name **is** the prefix its ABI functions are
108+
exported under (unambiguous, since a model argument is never a string):
109+
110+
```python
111+
m = cnlpmodels.CModel("@grid", "acopf", bus, 100.0) # acopf_* inside libgrid.so
112+
d = cnlpmodels.CModel("@grid", "dcopf", bus) # dcopf_* in the same file
113+
sch = cnlpmodels.schema(lib, "acopf") # schemas are per model
114+
```
115+
116+
A mistyped name is refused at selection, with the witness symbol named, rather
117+
than surfacing as a raw `undefined symbol` several calls later. Omitting the
118+
name keeps the single-model spelling, where the prefix falls back to the
119+
library name — a one-model library is unaffected. This is the same selection
120+
spelling as CNLPModels.jl's `CNLPModel(lib, :acopf, ...)`.
121+
103122
## Implementing a compatible library
104123

105124
1. Export the functions above with C linkage under one prefix.

‎src/cnlpmodels/__init__.py‎

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,12 @@ def _paths():
6565
def lib(name):
6666
"""Resolve `lib<name>.so` against the search path — also accepting the
6767
`<dir>/<name>/lib/` and `<dir>/lib/` layouts `compile_library` produces —
68-
load it, and cache the handle by name."""
68+
load it, and cache the handle by name.
69+
70+
A leading `@` is accepted and ignored: this function only ever takes a
71+
name, and the sigil spelling travels from `CModel`'s string argument."""
72+
if name.startswith("@"):
73+
name = name[1:]
6974
if name not in _LIBS:
7075
ext = {"win32": ".dll", "darwin": ".dylib"}.get(sys.platform, ".so")
7176
fname = f"lib{name}{ext}"
@@ -155,9 +160,33 @@ def _check(st, what):
155160
raise RuntimeError(f"{what} returned nonzero status {st}")
156161

157162

158-
def schema(lib, *, prefix="rec"):
159-
"""The library's data schema (ABI v2), as published by `<prefix>_schema`."""
163+
def _require_model(lib, model):
164+
"""A name this library does not carry is reported here, clearly.
165+
166+
`_nvar` is the witness symbol: the ABI requires it of every model however
167+
the model is instantiated — unlike `_new` (absent from builder-only
168+
models) or `_data_begin` (absent from one-knob ones). Without this check a
169+
mistyped name surfaces as a raw ctypes `undefined symbol` error several
170+
layers down."""
171+
try:
172+
getattr(lib, f"{model}_nvar")
173+
except AttributeError:
174+
where = getattr(lib, "_name", "this library")
175+
raise ValueError(
176+
f"{where} carries no model named {model!r} "
177+
f"(it exports no {model}_nvar)"
178+
) from None
179+
180+
181+
def schema(lib, model=None, *, prefix="rec"):
182+
"""The library's data schema (ABI v2), as published by `<prefix>_schema`.
183+
184+
In a library carrying several models the schema is per model — name the
185+
one you want, `schema(lib, "acopf")`, exactly as in `CModel`."""
160186
import json
187+
if model is not None:
188+
_require_model(lib, model)
189+
prefix = model
161190
fn = getattr(lib, f"{prefix}_schema")
162191
fn.restype = _c_int
163192
fn.argtypes = [ctypes.POINTER(ctypes.c_uint8), _c_int]
@@ -338,6 +367,16 @@ class CModel:
338367
m = cnlpmodels.CModel("rosen", 1000) # ./rosen (file or bundle dir)
339368
m = cnlpmodels.CModel("/opt/models/rosen", 1000) # full path
340369
370+
One library may carry **several models**, each under its own symbol
371+
prefix with its own schema and instances. A leading string argument
372+
selects one by name — the name is the prefix, mirroring CNLPModels.jl's
373+
`CNLPModel(lib, :acopf, ...)`; unambiguous, since a model argument is
374+
never a string. A mistyped name is refused at selection, not as a raw
375+
`undefined symbol` several calls later:
376+
377+
m = cnlpmodels.CModel("@grid", "acopf", bus, 100.0) # acopf_* in libgrid.so
378+
d = cnlpmodels.CModel("@grid", "dcopf", bus) # dcopf_*, same file
379+
341380
The arguments are the values the model is instantiated with — one per field
342381
of the library's schema, positionally, in the order the library publishes
343382
them, which is the same spelling the producer side uses
@@ -354,10 +393,26 @@ class CModel:
354393
"""
355394

356395
def __init__(self, lib, *args, prefix=None):
396+
# A leading string argument names a MODEL in a library carrying
397+
# several — the name is the symbol prefix its ABI functions are
398+
# exported under, mirroring CNLPModels.jl's
399+
# `CNLPModel(lib, :acopf, ...)`. Unambiguous: a model argument is
400+
# never a string.
401+
model = None
402+
if args and isinstance(args[0], str):
403+
model, args = args[0], args[1:]
404+
if prefix is not None and prefix != model:
405+
raise TypeError(
406+
f"both a model name ({model!r}) and prefix= ({prefix!r}) "
407+
"were given; they mean the same thing — give one"
408+
)
409+
prefix = model
357410
if isinstance(lib, str):
358411
prefix = prefix if prefix is not None else _default_prefix(lib)
359412
lib = _resolve_spec(lib)
360413
prefix = prefix if prefix is not None else "rec"
414+
if model is not None:
415+
_require_model(lib, model)
361416
# Instantiate before resolving the evaluation table, so a failure to
362417
# build the model surfaces as what it is — not as a missing evaluation
363418
# symbol on a library that never got that far. Same order as the Julia

‎tests/test_cnlpmodels.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,3 +292,55 @@ def test_evaluation_shape_guards(lib):
292292
m.obj(np.zeros(3))
293293
with pytest.raises(ValueError, match=r"y must have shape \(1,\)"):
294294
m.hess(np.zeros(4), np.zeros(2))
295+
296+
297+
# ── Selecting a model by name in a library carrying several ──────────────────
298+
# The fixture carries several models in ONE shared library; a leading string
299+
# argument names one, and the name is the symbol prefix — the same selection
300+
# spelling as CNLPModels.jl's `CNLPModel(lib, :tq, ...)`.
301+
302+
303+
def test_model_selection_by_name(lib):
304+
x = np.array([0.5, 0.25, 2.0, -1.0])
305+
n, s, w = 4, 2.0, np.array([1.0, 2.0, 3.0, 4.0])
306+
307+
m = cnlpmodels.CModel(lib, "tq", 4)
308+
assert m.nvar == 4
309+
ms = cnlpmodels.CModel(lib, "sq", n, s, w) # builder-only sibling
310+
assert m.obj(x) == ((x - 1.0) ** 2).sum()
311+
assert ms.obj(x) == (w * (x - s) ** 2).sum()
312+
313+
# Instances of DIFFERENT models coexist as freely as instances of one.
314+
m6 = cnlpmodels.CModel(lib, "tq", 6)
315+
assert m6.nvar == 6
316+
assert m.obj(x) == ((x - 1.0) ** 2).sum()
317+
assert ms.obj(x) == (w * (x - s) ** 2).sum()
318+
319+
320+
def test_unknown_model_name_is_refused_clearly(lib):
321+
# A mistyped name is reported at selection, with the witness spelled out —
322+
# not as a raw ctypes `undefined symbol` several calls later.
323+
with pytest.raises(ValueError, match=r"carries no model named 'nosuch'"):
324+
cnlpmodels.CModel(lib, "nosuch", 4)
325+
with pytest.raises(ValueError, match=r"carries no model named"):
326+
cnlpmodels.schema(lib, "nosuch")
327+
328+
329+
def test_model_name_and_prefix_must_agree(lib):
330+
with pytest.raises(TypeError, match=r"give one"):
331+
cnlpmodels.CModel(lib, "tq", 4, prefix="sq")
332+
assert cnlpmodels.CModel(lib, "tq", 4, prefix="tq").nvar == 4
333+
334+
335+
def test_schema_by_model_name(lib):
336+
sch = cnlpmodels.schema(lib, "sq")
337+
assert [f["name"] for f in sch["fields"]] == ["n", "s", "w"]
338+
339+
340+
def test_at_sigil_is_accepted_by_lib(lib, tmp_path):
341+
import shutil
342+
shutil.copy(pathlib.Path(lib._name), tmp_path / "libtoy9.so")
343+
cnlpmodels.set_path(tmp_path)
344+
assert cnlpmodels.lib("@toy9") is cnlpmodels.lib("toy9") # one cache entry
345+
m = cnlpmodels.CModel("@toy9", "tq", 4) # with model selection
346+
assert m.nvar == 4

0 commit comments

Comments
 (0)