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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,25 @@ compiled from a recipe names its fields `arg1`, `arg2`, ... for that reason.
Each value is checked against the kind and type the schema declares for its
slot; nothing is coerced across it.

### Several models in one library

One shared library may export any number of models, each under its own symbol
prefix with its own schema and its own instances. A leading string argument
selects one by name — the name **is** the prefix its ABI functions are
exported under (unambiguous, since a model argument is never a string):

```python
m = cnlpmodels.CModel("@grid", "acopf", bus, 100.0) # acopf_* inside libgrid.so
d = cnlpmodels.CModel("@grid", "dcopf", bus) # dcopf_* in the same file
sch = cnlpmodels.schema(lib, "acopf") # schemas are per model
```

A mistyped name is refused at selection, with the witness symbol named, rather
than surfacing as a raw `undefined symbol` several calls later. Omitting the
name keeps the single-model spelling, where the prefix falls back to the
library name — a one-model library is unaffected. This is the same selection
spelling as CNLPModels.jl's `CNLPModel(lib, :acopf, ...)`.

## Implementing a compatible library

1. Export the functions above with C linkage under one prefix.
Expand Down
61 changes: 58 additions & 3 deletions src/cnlpmodels/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,12 @@ def _paths():
def lib(name):
"""Resolve `lib<name>.so` against the search path — also accepting the
`<dir>/<name>/lib/` and `<dir>/lib/` layouts `compile_library` produces —
load it, and cache the handle by name."""
load it, and cache the handle by name.

A leading `@` is accepted and ignored: this function only ever takes a
name, and the sigil spelling travels from `CModel`'s string argument."""
if name.startswith("@"):
name = name[1:]
if name not in _LIBS:
ext = {"win32": ".dll", "darwin": ".dylib"}.get(sys.platform, ".so")
fname = f"lib{name}{ext}"
Expand Down Expand Up @@ -155,9 +160,33 @@ def _check(st, what):
raise RuntimeError(f"{what} returned nonzero status {st}")


def schema(lib, *, prefix="rec"):
"""The library's data schema (ABI v2), as published by `<prefix>_schema`."""
def _require_model(lib, model):
"""A name this library does not carry is reported here, clearly.

`_nvar` is the witness symbol: the ABI requires it of every model however
the model is instantiated — unlike `_new` (absent from builder-only
models) or `_data_begin` (absent from one-knob ones). Without this check a
mistyped name surfaces as a raw ctypes `undefined symbol` error several
layers down."""
try:
getattr(lib, f"{model}_nvar")
except AttributeError:
where = getattr(lib, "_name", "this library")
raise ValueError(
f"{where} carries no model named {model!r} "
f"(it exports no {model}_nvar)"
) from None


def schema(lib, model=None, *, prefix="rec"):
"""The library's data schema (ABI v2), as published by `<prefix>_schema`.

In a library carrying several models the schema is per model — name the
one you want, `schema(lib, "acopf")`, exactly as in `CModel`."""
import json
if model is not None:
_require_model(lib, model)
prefix = model
fn = getattr(lib, f"{prefix}_schema")
fn.restype = _c_int
fn.argtypes = [ctypes.POINTER(ctypes.c_uint8), _c_int]
Expand Down Expand Up @@ -338,6 +367,16 @@ class CModel:
m = cnlpmodels.CModel("rosen", 1000) # ./rosen (file or bundle dir)
m = cnlpmodels.CModel("/opt/models/rosen", 1000) # full path

One library may carry **several models**, each under its own symbol
prefix with its own schema and instances. A leading string argument
selects one by name — the name is the prefix, mirroring CNLPModels.jl's
`CNLPModel(lib, :acopf, ...)`; unambiguous, since a model argument is
never a string. A mistyped name is refused at selection, not as a raw
`undefined symbol` several calls later:

m = cnlpmodels.CModel("@grid", "acopf", bus, 100.0) # acopf_* in libgrid.so
d = cnlpmodels.CModel("@grid", "dcopf", bus) # dcopf_*, same file

The arguments are the values the model is instantiated with — one per field
of the library's schema, positionally, in the order the library publishes
them, which is the same spelling the producer side uses
Expand All @@ -354,10 +393,26 @@ class CModel:
"""

def __init__(self, lib, *args, prefix=None):
# A leading string argument names a MODEL in a library carrying
# several — the name is the symbol prefix its ABI functions are
# exported under, mirroring CNLPModels.jl's
# `CNLPModel(lib, :acopf, ...)`. Unambiguous: a model argument is
# never a string.
model = None
if args and isinstance(args[0], str):
model, args = args[0], args[1:]
if prefix is not None and prefix != model:
raise TypeError(
f"both a model name ({model!r}) and prefix= ({prefix!r}) "
"were given; they mean the same thing — give one"
)
prefix = model
if isinstance(lib, str):
prefix = prefix if prefix is not None else _default_prefix(lib)
lib = _resolve_spec(lib)
prefix = prefix if prefix is not None else "rec"
if model is not None:
_require_model(lib, model)
# Instantiate before resolving the evaluation table, so a failure to
# build the model surfaces as what it is — not as a missing evaluation
# symbol on a library that never got that far. Same order as the Julia
Expand Down
52 changes: 52 additions & 0 deletions tests/test_cnlpmodels.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,3 +292,55 @@ def test_evaluation_shape_guards(lib):
m.obj(np.zeros(3))
with pytest.raises(ValueError, match=r"y must have shape \(1,\)"):
m.hess(np.zeros(4), np.zeros(2))


# ── Selecting a model by name in a library carrying several ──────────────────
# The fixture carries several models in ONE shared library; a leading string
# argument names one, and the name is the symbol prefix — the same selection
# spelling as CNLPModels.jl's `CNLPModel(lib, :tq, ...)`.


def test_model_selection_by_name(lib):
x = np.array([0.5, 0.25, 2.0, -1.0])
n, s, w = 4, 2.0, np.array([1.0, 2.0, 3.0, 4.0])

m = cnlpmodels.CModel(lib, "tq", 4)
assert m.nvar == 4
ms = cnlpmodels.CModel(lib, "sq", n, s, w) # builder-only sibling
assert m.obj(x) == ((x - 1.0) ** 2).sum()
assert ms.obj(x) == (w * (x - s) ** 2).sum()

# Instances of DIFFERENT models coexist as freely as instances of one.
m6 = cnlpmodels.CModel(lib, "tq", 6)
assert m6.nvar == 6
assert m.obj(x) == ((x - 1.0) ** 2).sum()
assert ms.obj(x) == (w * (x - s) ** 2).sum()


def test_unknown_model_name_is_refused_clearly(lib):
# A mistyped name is reported at selection, with the witness spelled out —
# not as a raw ctypes `undefined symbol` several calls later.
with pytest.raises(ValueError, match=r"carries no model named 'nosuch'"):
cnlpmodels.CModel(lib, "nosuch", 4)
with pytest.raises(ValueError, match=r"carries no model named"):
cnlpmodels.schema(lib, "nosuch")


def test_model_name_and_prefix_must_agree(lib):
with pytest.raises(TypeError, match=r"give one"):
cnlpmodels.CModel(lib, "tq", 4, prefix="sq")
assert cnlpmodels.CModel(lib, "tq", 4, prefix="tq").nvar == 4


def test_schema_by_model_name(lib):
sch = cnlpmodels.schema(lib, "sq")
assert [f["name"] for f in sch["fields"]] == ["n", "s", "w"]


def test_at_sigil_is_accepted_by_lib(lib, tmp_path):
import shutil
shutil.copy(pathlib.Path(lib._name), tmp_path / "libtoy9.so")
cnlpmodels.set_path(tmp_path)
assert cnlpmodels.lib("@toy9") is cnlpmodels.lib("toy9") # one cache entry
m = cnlpmodels.CModel("@toy9", "tq", 4) # with model selection
assert m.nvar == 4
Loading